Microservicios: arquitectura escalable y la importancia de modelar bien
Dividir una aplicación en microservicios es sencillo. Dividirla bien requiere entender qué es un dominio, por qué cada servicio debe ser dueño de sus datos, y cuándo no dividir es la decisión correcta.
Del monolito al microservicio
Un monolito no es malo. Para equipos pequeños y productos en fase temprana, un monolito bien estructurado es más barato de mantener que una malla de servicios. El problema aparece cuando el sistema crece: un cambio en el módulo de pagos requiere redesplegar toda la aplicación; escalar el catálogo de productos significa escalar también usuarios y pedidos aunque no lo necesiten; el equipo de frontend no puede avanzar porque el backend tiene una deuda técnica acumulada en un solo repositorio gigante.
Microservicios son la solución a ese problema específico — no a todos los problemas. La decisión de migrar tiene sentido cuando los cuellos de botella de despliegue, escalado o autonomía de equipo justifican la complejidad operativa añadida.
Por qué el modelo de datos es la decisión más importante
Muchos equipos comienzan dividiendo el código en servicios pero manteniendo una base de datos compartida. Es el peor de los dos mundos: tienes la complejidad de red de los microservicios sin la independencia de despliegue del monolito.
La regla es simple pero difícil de respetar: cada servicio es el único dueño de sus datos. Ningún otro servicio puede leer o escribir directamente en su base de datos. Si el Servicio de Pedidos necesita el nombre del cliente, lo pide al Servicio de Usuarios mediante una API — no hace un JOIN cross-service.
Esto tiene consecuencias en el diseño. Los datos que antes vivían en una tabla ahora tienen que existir en múltiples servicios en sus respectivos contextos. El usuario para el servicio de pagos no tiene los mismos atributos que el usuario para el servicio de CRM: son el mismo concepto de negocio pero modelos distintos. Esa duplicación es intencional y necesaria.
Fronteras de dominio (Bounded Contexts)
El concepto de Bounded Context del Domain-Driven Design es la guía más útil para decidir dónde cortar. Un bounded context es una zona del sistema donde un modelo de datos tiene un significado preciso y consistente. El concepto «Producto» en el catálogo incluye descripción, imágenes y variantes. El concepto «Producto» en el servicio de inventario es solo un SKU con una cantidad. Son el mismo objeto del mundo real pero modelos diferentes adaptados a su contexto.
Cuando defines mal las fronteras, los servicios empiezan a acoplarse: el servicio A necesita que el servicio B tenga una estructura específica para funcionar; un cambio en B rompe A sin que nadie lo anticipe. Esta dependencia oculta en el modelo de datos es la fuente más común de fragilidad en arquitecturas de microservicios.
Comunicación entre servicios
Hay dos patrones principales:
- Síncrono (REST / gRPC) — el servicio A llama al B y espera respuesta. Simple de razonar, pero introduce acoplamiento temporal: si B está caído, A falla. Adecuado para operaciones de lectura y casos donde el resultado inmediato es necesario.
- Asíncrono (mensajería) — A publica un evento en una cola (Kafka, RabbitMQ, SQS) y continúa sin esperar. B consume el evento cuando puede. Mayor resiliencia y desacoplamiento, pero la consistencia eventual requiere diseño explícito.
Un ejemplo concreto con FastAPI para el servicio de usuarios:
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"),
)
El DTO actúa como frontera explícita. Los campos internos (password_hash, internal_score) nunca abandonan el servicio. Si añades campos internos nuevos, el contrato externo no cambia y ningún consumidor se rompe.
Errores comunes
- Microservicios distribuidos por función técnica, no por dominio — «servicio de base de datos», «servicio de validación». No resuelven el problema de acoplamiento, solo lo mueven de sitio.
- Base de datos compartida — el anti-patrón más común y el más costoso de revertir. Si dos servicios escriben en la misma tabla, en realidad son un monolito disfrazado.
- Descomposición prematura — dividir antes de entender bien el dominio. Las fronteras mal trazadas en la fase temprana son muy caras de mover después, cuando el sistema tiene tráfico real.
- Ignorar la consistencia eventual — en sistemas distribuidos, la consistencia inmediata entre servicios tiene un coste alto. Diseñar asumiendo consistencia eventual desde el principio simplifica muchos casos.
¿Cuándo no usar microservicios?
Si el equipo tiene menos de 5-8 personas, si el producto aún no tiene product-market fit, o si no hay experiencia operativa con sistemas distribuidos, un monolito modular bien estructurado es casi siempre la mejor opción. Los microservicios son una solución a problemas de escala — de equipo, de carga o de dominio. Antes de tener esos problemas, son complejidad sin beneficio.