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.
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.
Communication between services
There are two main patterns:
- Synchronous (REST / gRPC) — service A calls B and waits for a response. Easy to reason about, but it introduces temporal coupling: if B is down, A fails. Suitable for reads and cases that require an immediate result.
- Asynchronous (messaging) — A publishes an event to a queue (Kafka, RabbitMQ, SQS) and continues without waiting. B consumes it when it can. This improves resilience and decoupling, but eventual consistency requires explicit design.
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
- Microservices split by technical function rather than domain — “database service”, “validation service”. They do not solve coupling; they just move it elsewhere.
- Shared database — the most common antipattern and the most expensive to reverse. If two services write to the same table, they are effectively a disguised monolith.
- Premature decomposition — splitting before understanding the domain. Poor early boundaries are expensive to move later, once the system handles real traffic.
- Ignoring eventual consistency — in distributed systems, immediate consistency between services is costly. Designing for eventual consistency from the start simplifies many cases.
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.