EstevezAlvarez
Microservices Architecture Python

Microservices: scalable architecture and the importance of sound modeling

Splitting an application into microservices is easy. Splitting it well requires understanding what a domain is, why each service must own its data, and when not splitting is the right decision.

Monolith Users module Orders module Payments module Catalog module a single database → Microservices Users svc DB: users_db Orders svc DB: orders_db Payments svc DB: payments_db Catalog svc DB: catalog_db API Gateway
In a monolith, all modules share a database and are deployed together. In microservices, each service owns its data and is deployed independently.

From monolith to microservice

A monolith is not bad. For small teams and early-stage products, a well-structured monolith is cheaper to maintain than a mesh of services. The problem arises as the system grows: a change to the payments module requires redeploying the entire application; scaling the product catalog also means scaling users and orders even when they do not need it; the frontend team cannot progress because the backend has accumulated technical debt in one giant repository.

Microservices solve that specific problem — not every problem. Migration makes sense when deployment, scaling or team-autonomy bottlenecks justify the added operational complexity.

Why the data model is the most important decision

Many teams start by splitting code into services while keeping a shared database. It is the worst of both worlds: the networking complexity of microservices without deployment independence.

The rule is simple but hard to follow: each service is the sole owner of its data. No other service may read or write directly to its database. If the Orders Service needs a customer’s name, it requests it from the Users Service through an API — it does not perform a JOIN across services.

This has design consequences. Data that previously lived in one table now needs to exist in multiple services within their respective contexts. A user in the payments service does not have the same attributes as a user in the CRM service: the business concept is the same, but the models differ. This duplication is intentional and necessary.

Domain boundaries (Bounded Contexts)

The concept of Bounded Context from Domain-Driven Design is the most useful guide for deciding where to split. A bounded context is an area of the system where a data model has a precise and consistent meaning. “Product” in the catalog includes descriptions, images and variants. “Product” in the inventory service is just an SKU with a quantity. They represent the same real-world object but use different models adapted to their context.

When boundaries are poorly defined, services become coupled: service A requires service B to have a specific structure; a change in B breaks A unexpectedly. This hidden dependency in the data model is the most common source of fragility in microservices architectures.

Orders svc GET /users/{id} → needs: name, email, address Contract (OpenAPI) GET /users/{user_id} → 200 UserPublicDTO Users svc exposes: name, email, address (public DTO) hides: password, etc.
The API contract defines exactly what each service exposes. The Orders Service only accesses data that the Users Service chooses to publish in its DTO — never the database directly.

Communication between services

There are two main patterns:

A concrete FastAPI example for the users service:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

# DTO público — solo lo que otros servicios pueden ver
class UserPublicDTO(BaseModel):
    user_id: str
    name: str
    email: str
    shipping_address: str | None = None

# Datos internos (nunca expuestos directamente)
_users_db: dict[str, dict] = {
    "u1": {
        "name": "Ana García",
        "email": "ana@example.com",
        "password_hash": "...",       # nunca sale
        "shipping_address": "Calle Mayor 1",
        "internal_score": 98,         # nunca sale
    }
}

@app.get("/users/{user_id}", response_model=UserPublicDTO)
def get_user_public(user_id: str):
    user = _users_db.get(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return UserPublicDTO(
        user_id=user_id,
        name=user["name"],
        email=user["email"],
        shipping_address=user.get("shipping_address"),
    )

The DTO acts as an explicit boundary. Internal fields (password_hash, internal_score) never leave the service. If you add new internal fields, the external contract stays unchanged and no consumer breaks.

Common mistakes

When should you avoid microservices?

If the team has fewer than 5–8 people, the product has not yet reached product-market fit, or there is no operational experience with distributed systems, a well-structured modular monolith is almost always the better choice. Microservices solve scale problems — in teams, workloads or domains. Before those problems exist, they add complexity without benefit.