Monolito o microservicios: cómo elegir correctamente

1. Descripción general

La pregunta “¿debería elegir monolito o microservicios?” surge casi en todas las reuniones de diseño de sistemas. Este artículo no te dará una respuesta única para todos, sino que analizará criterios específicos: tamaño del equipo, velocidad de despliegue, complejidad operativa y costo de comunicación entre servicios. Después de leerlo, tendrás un checklist para evaluar dónde debería estar tu sistema en el “eje” monolito – monolito modular – microservicios.

2. Monolito: simple pero fácil de subestimar

Monolito significa que todo el negocio (Order, Customer, Payment, Inventory…) corre en un proceso, un codebase, un despliegue.

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        Order order = orderService.create(request);
        return ResponseEntity.ok(OrderResponse.from(order));
    }
}

@Service
public class OrderService {

    private final InventoryService inventoryService;
    private final PaymentService paymentService;

    public OrderService(InventoryService inventoryService, PaymentService paymentService) {
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
    }

    public Order create(OrderRequest request) {
        inventoryService.reserve(request.productId(), request.quantity());
        paymentService.charge(request.customerId(), request.amount());
        return new Order(request); // suponemos que el constructor guarda en BD
    }
}

OrderService llama directamente a InventoryService y PaymentService mediante llamadas a métodos en la misma JVM. No hay red, no hay serialización, la transacción puede incluirse en un único @Transactional. Esta es la mayor ventaja del monolito: garantizar consistencia es fácil, depuración es sencilla, un despliegue resuelve todo.

Los inconvenientes aparecen cuando el equipo crece: todos editan el mismo codebase, el tiempo de compilación aumenta, el riesgo de desplegar un módulo pequeño también requiere reconstruir toda la aplicación.

Nota: Monolito no es sinónimo de “código desordenado”. Un monolito bien escrito con módulos claros (organizados por dominio, sin dependencias cruzadas) sigue funcionando bien para sistemas con millones de usuarios.

3. Microservicios: separar para escalar independientemente, a cambio de más complejidad operativa

Los microservicios separan Order, Inventory, Payment en servicios independientes que se comunican por red (REST, gRPC, cola de mensajes).

@Service
public class OrderService {

    private final RestClient restClient;

    public OrderService(RestClient restClient) {
        this.restClient = restClient;
    }

    public Order create(OrderRequest request) {
        // Llamar a Inventory Service por HTTP
        restClient.post()
                .uri("http://inventory-service/reservations")
                .body(new ReservationRequest(request.productId(), request.quantity()))
                .retrieve()
                .toBodilessEntity();

        // Llamar a Payment Service por HTTP
        restClient.post()
                .uri("http://payment-service/charges")
                .body(new ChargeRequest(request.customerId(), request.amount()))
                .retrieve()
                .toBodilessEntity();

        return new Order(request);
    }
}

Comparado con la versión monolito del punto 2, la diferencia está en restClient.post(...): la llamada dentro del proceso se ha convertido en una llamada HTTP a través de la red. Las consecuencias:

  • Cada llamada puede fallar por timeout de red, servicio caído, error DNS — problemas que no existen en el monolito.
  • Ya no hay transacciones ACID que atraviesen servicios. Debes manejar esto con el patrón saga o consistencia eventual.
  • Necesitas descubrimiento de servicios, balanceador de carga, rastreo distribuido para depurar cuando hay problemas.

3.1. Costo oculto: transacciones distribuidas

@Service
public class OrderSagaOrchestrator {

    public void createOrder(OrderRequest request) {
        try {
            inventoryClient.reserve(request.productId(), request.quantity());
            paymentClient.charge(request.customerId(), request.amount());
        } catch (PaymentFailedException e) {
            // Tienes que escribir manualmente el paso de compensación para revertir
            inventoryClient.release(request.productId(), request.quantity());
            throw e;
        }
    }
}

En el monolito, la reversión es automática con @Transactional. En microservicios, debes escribir tú mismo la lógica de compensación (release después de que falle reserve). Este es código que no existe en la versión monolito pero que es obligatorio en microservicios para evitar inconsistencia de datos.

4. Criterios de decisión: no solo imitar a “grandes empresas”

4.1. Tamaño del equipo y Ley de Conway

Ley de Conway: la estructura del sistema refleja la estructura de la organización. Si tienes 5 desarrolladores y los divides en 5 servicios, cada persona maneja un servicio — nadie revisa el código de nadie, y pierdes el beneficio de “múltiples ojos revisando un bug”.

Rangos de referencia prácticos:

1–15 dev, 1 equipo          -> Monolito (modular)
15–50 dev, 2–5 equipos      -> Monolito modular o separar 2-3 servicios grandes
50+ dev, múltiples equipos  -> Microservicios según bounded context

4.2. ¿La velocidad de despliegue realmente está limitada?

Si tu equipo despliega el monolito cada 2 semanas y no hay conflictos de merge graves, separar servicios para “despliegues independientes” no añade valor — solo suma costo operativo (CI/CD independiente, monitoreo independiente, versionado de API).

Los microservicios tienen sentido cuando: el equipo A necesita desplegar 10 veces/día para Payment, pero el equipo B solo despliega Inventory 1 vez/semana. Separarlos permite que A no espere a B.

4.3. Capacidad de tolerancia a fallos de la infraestructura

# docker-compose.yml - ejemplo de la complejidad mínima de infraestructura para microservicios
services:
  order-service:
    image: order-service:1.0
    depends_on:
      - inventory-service
      - payment-service
      - service-registry
  inventory-service:
    image: inventory-service:1.0
  payment-service:
    image: payment-service:1.0
  service-registry:
    image: eureka-server:1.0
  api-gateway:
    image: spring-cloud-gateway:1.0

Con solo 3 servicios de negocio, ya necesitas agregar service-registry y api-gateway. Multiplícalo por 20 servicios reales, y el número de componentes móviles crece exponencialmente — requiere un equipo DevOps fuerte para manejar Kubernetes, logging distribuido (ELK), rastreo (Jaeger/Zipkin).

Nota: Si la empresa aún no tiene a alguien que entienda orquestación de contenedores y observability stack, los microservicios convertirán cada incident en una sesión de “buscar una aguja en un pajar” entre muchos logs de servicio diferentes.

5. Monolito modular: alternativa intermedia frecuentemente ignorada

El monolito modular es un monolito pero cuyo código está organizado en módulos independientes que se comunican por interfaces claras, sin dependencias cruzadas de base de datos.

// Paquete: com.nonladev.shop.order
public interface InventoryPort {
    void reserve(String productId, int quantity);
}

// Paquete: com.nonladev.shop.inventory
@Service
public class InventoryService implements InventoryPort {
    @Override
    public void reserve(String productId, int quantity) {
        // lógica de negocio, accede a BD propia del módulo inventory
    }
}

OrderService solo depende de la interfaz InventoryPort, no conoce la implementación del módulo inventory. Gracias a esto, si más tarde necesitas separar inventory en un servicio independiente, solo reemplazas la implementación de InventoryPort con un cliente HTTP — el código que la llama en OrderService no cambia.

Esta es exactamente la estrategia de “comenzar con monolito, separar gradualmente cuando sea necesario” que muchos sistemas grandes (Shopify, GitHub en sus inicios) han aplicado con éxito.

6. Test: medir el costo de latencia al pasar de llamadas en-proceso a llamadas por red

El siguiente ejemplo de test ilustra la diferencia en latencia — un factor que deberías medir en la práctica antes de decidir separar un servicio.

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
class OrderServiceLatencyTest {

    @Test
    void inProcessCallShouldBeFasterThanRemoteCall() {
        InventoryPort inProcess = (productId, qty) -> { /* sin operación */ };
        long startInProcess = System.nanoTime();
        inProcess.reserve("P-1", 1);
        long inProcessDuration = System.nanoTime() - startInProcess;

        InventoryPort remoteSimulated = (productId, qty) -> {
            try {
                Thread.sleep(20); // simular round-trip de red ~20ms
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        };
        long startRemote = System.nanoTime();
        remoteSimulated.reserve("P-1", 1);
        long remoteDuration = System.nanoTime() - startRemote;

        assertThat(remoteDuration).isGreaterThan(inProcessDuration);
    }
}

Este test no mide el rendimiento real de tu infraestructura, pero ilustra la esencia: cada vez que Order llama a Inventory por red, sumas varios–decenas de milisegundos. Si un request necesita llamar a 5 servicios en cadena, la latencia acumulada puede exceder tu SLA — este es un costo que frecuentemente se ignora al dibujar diagramas de arquitectura en papel.

7. Cuándo NO elegir microservicios

  • Equipo más pequeño de 10 personas: el costo operativo (CI/CD, monitoreo, on-call) de múltiples servicios supera el beneficio de la separación.
  • Dominio aún inestable: si el negocio cambia constantemente (startup en búsqueda de product-market fit), los límites de servicio serán incorrectos, tendrás que separar y luego fusionar de nuevo — el doble de trabajo.
  • Sin observability: sin rastreo distribuido, logging centralizado, es prácticamente imposible depurar errores que cruzan servicios.
  • Necesitas alta consistencia fuerte (por ejemplo, banca central): mantenerlo en una frontera de transacción (monolito) es más seguro que implementar saga compleja.

Al contrario, los microservicios valen la pena cuando: múltiples equipos independientes, necesitas escalar partes independientemente (ej. servicio de búsqueda necesita más CPU que servicio de órdenes), o necesitas tecnologías diferentes para cada dominio (Node.js para chat en tiempo real, Java para billing).

8. Conclusión

No existe una arquitectura “absolutamente correcta” — solo la arquitectura adecuada para el tamaño de tu equipo, velocidad de despliegue y capacidad operativa actual. Si no estás seguro, comienza con monolito modular: mantén interfaces claras entre dominios, evita dependencias cruzadas de base de datos, para que cuando realmente necesites separar (por escala o porque el equipo crece), solo cambies la implementación, no reescribas todo el sistema. Los microservicios son una herramienta para resolver problemas de organización y escala, no un objetivo arquitectónico en sí mismo.

microserviciosmonolitosystem-designspring-bootarquitectura