Patrones para Sistemas Distribuidos con Spring Boot: Guía PRO (Parte 1)

Cesar A. Fernandez B.

Cesar A. Fernandez B.

Sr. Software Engineer

20 de abril de 2025 12 min
Artículo

Bueno muchachones, hoy venimos con algo serio, pero sin dejar el flow relajado que nos caracteriza. Vamos a hablar de sistemas distribuidos con Spring Boot, algo vital para dejar atrás esos monolitos anticuados que tiemblan al mínimo estrés. El futuro es claro: aplicaciones escalables, resilientes y fáciles de mantener.

Spring Boot es como ese pana que siempre te ayuda en los proyectos difíciles, quitándote la carga aburrida para que te enfoques en lo realmente importante: la lógica del negocio. Prepara tu café o una cervecita, que esto va para largo pero sabroso.


1. El Patrón Microservicios: Divide y conquista como un PRO

Este es el papá de los patrones hoy en día. La idea es sencilla pero poderosa: en vez de tener una sola aplicación gigante (el monolito), la rompes en pedacitos más pequeños, independientes, que se comunican entre sí. Cada pedacito, o microservicio, se enfoca en una tarea específica del negocio (manejar usuarios, procesar pagos, controlar inventario, etc.).

Por qué es la movida?

  • Escalabilidad Fina: Necesitas más potencia para procesar pagos cuando te lanzas una promo? Escalas solo el servicio de pagos, no toda la aplicación. Eficiencia pura!!!

  • Resiliencia: Si un servicio se cae (porque siempre algo puede fallar, pana), no tumba toda la aplicación. Los demás pueden seguir funcionando.

  • Tecnología Flexible: Quieres usar Java para un servicio, Python para otro de IA y Node.js para otro? Dale!!! Cada microservicio puede usar la tecnología que mejor le cuadre.

  • Despliegues Independientes: Actualizas el servicio de inventario sin tocar el de usuarios. Agilidad al máximo mijo!!!

Spring Boot al Rescate:

Spring Boot y su hermano, Spring Cloud, te dan el combo completo:

  • Service Discovery (Eureka, Consul): Para que los servicios se encuentren entre ellos.
  • Configuración Centralizada (Spring Cloud Config): Manejar las properties de todos tus servicios desde un solo sitio. Orden en la pela!!!
  • Resiliencia (Resilience4j): Para manejar fallos en la comunicación (más de esto luego!!!).
  • API Gateway (Spring Cloud Gateway): Un "portero" para tus servicios (también lo veremos!!!).

Por ejemplo: Un E-commerce

tenemos una tienda online. En vez de un monstruo, tienes: 'ServicioOrdenes', 'ServicioPagos', 'ServicioInventario', 'ServicioUsuarios'.

Mira qué fácil se ve un controlador básico para 'ServicioOrdenes' con Spring Boot: (Recuerda mi bro, que son ejemplos simplificados para que se entienda)

@RestController
@RequestMapping("/api/v1/ordenes") // Buena práctica versionar tu API, mi pana!
public class OrderController {

    // Inyectamos la lógica de negocio. Spring se encarga de la magia.
    private final OrderService orderService;

    // Inyección por constructor: la forma PRO! Más limpio y testeable.
    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<Order> crearOrden(@RequestBody @Valid OrderRequest request) {
        // @Valid para que Spring Boot valide el request antes de que llegue aquí. ¡Menos código para ti!
        Order nuevaOrden = orderService.crearNuevaOrden(request);
        // Devolvemos 201 Created y la orden creada. RESTful como debe ser!
        return ResponseEntity.status(HttpStatus.CREATED).body(nuevaOrden);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Order> obtenerOrden(@PathVariable Long id) {
        // Optional<Order> para manejar el caso de que no exista. Adiós NullPointerExceptions!
        return orderService.buscarOrdenPorId(id)
                .map(ResponseEntity::ok) // Si existe, devuelve 200 OK con la orden
                .orElse(ResponseEntity.notFound().build()); // Si no, devuelve 404 Not Found. Elegante.
    }

    // ... otros endpoints (actualizar, listar, etc.) ...
}

@Service // Aquí vive la lógica pura del negocio.
public class OrderService {

    // Probablemente inyectarías un Repositorio aquí (JPA, etc.)
    // private final OrderRepository orderRepository;

    // Mock simple para el ejemplo
    private Map<Long, Order> orderRepository = new ConcurrentHashMap<>(); // Usar concurrente por si acaso!

    public Order crearNuevaOrden(OrderRequest request) {
        // Aquí va tu lógica: validar ítems, calcular total, etc.
        Order order = new Order();
        order.setId(System.nanoTime()); // Mejor usar UUIDs en la vida real, pana.
        // ... Mapear datos del request a la orden ...
        orderRepository.put(order.getId(), order);
        System.out.println("¡Orden creada con éxito!: " + order.getId());
        // Aquí podrías publicar un evento (spoiler del siguiente patrón!)
        return order;
    }

    public Optional<Order> buscarOrdenPorId(Long id) {
        return Optional.ofNullable(orderRepository.get(id));
    }

    // ... más métodos de negocio ...
}

// Clases DTO (Data Transfer Object) para los requests y entidades simples
// record OrderRequest(...) {} // Java Records son chéveres para DTOs
// class Order { Long id; ... }

PRO Tip: Definir bien los límites de cada microservicio (lo que se llama "Bounded Context" en DDD) es crucial. Si lo haces mal, terminas con un "monolito distribuido", y eso es peor que empezar con un monolito normal! Piénsalo bien, haz los diagramitas bro, discute con tu equipo (Bueno si no estas solo jaja jaja)

Diagrama de Arquitectura de Microservicios


2. El Patrón Event-Driven: Comunicación sin estrés!!!

En vez de que los servicios se llamen directamente unos a otros esperando respuesta (como una llamada telefónica), aquí la cosa es más relajada: un servicio "publica" un evento (dice "Hey, pasó esto!") y otros servicios interesados "escuchan" y reaccionan a ese evento, cada uno a su ritmo. Es como mandar un mensaje a un grupo de WhatsApp: lo mandas una vez y cada quien lo lee y responde cuando puede.

Entonces tenemos muchachones

  • Desacoplamiento Máximo: El servicio que crea la orden no necesita saber quién va a reaccionar (Inventario, Notificaciones, Envíos...). Solo lanza el evento y listo.

  • Escalabilidad y Resiliencia: Puedes añadir nuevos "escuchadores" sin tocar al que publica. Si un escuchador falla, los demás pueden seguir procesando. El sistema de mensajería (como Kafka o RabbitMQ) suele manejar la persistencia y reintentos.

  • Procesamiento en Tiempo Real (o casi): Las cosas ocurren a medida que los eventos fluyen.

Spring Boot al Rescate:

  • Spring Cloud Stream: Abstracción brutal para hablar con sistemas de mensajería (Kafka, RabbitMQ). Escribes tu código una vez y puedes cambiar de broker solo con configuración, como que fuera magia papa...

  • Spring for Apache Kafka / Spring AMQP: Integraciones más directas si prefieres controlar más detalles. (aun un poco mas odioso)

Siguiendo con el ejmeplo del E-commerce

Cuando 'ServicioOrdenes' crea una orden, publica un evento 'OrdenCreadaEvent'. 'ServicioInventario' escucha y descuenta el stock. 'ServicioNotificaciones' escucha y envía un email al cliente.

// En ServicioOrdenes (el productor del evento)
@Service
public class OrderService {
    // Inyectamos el KafkaTemplate que Spring Boot configura casi solo
    private final KafkaTemplate<String, OrdenCreadaEvent> kafkaTemplate;
    private final String topicName = "ordenes.topic"; // Mejor en properties, claro!

    public OrderService(KafkaTemplate<String, OrdenCreadaEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
        // ... otras dependencias ...
    }

    public Order crearNuevaOrden(OrderRequest request) {
        // ... (Lógica para crear la orden como antes) ...
        Order nuevaOrden = /* ... crearla ... */ ;
        orderRepository.put(nuevaOrden.getId(), nuevaOrden);

        // Aquí está la acción! Publicamos el evento.
        OrdenCreadaEvent event = new OrdenCreadaEvent(nuevaOrden.getId(), nuevaOrden.getUserId(), nuevaOrden.getItems());
        try {
            // Usamos el ID de la orden como clave del mensaje Kafka (buena práctica para particionamiento)
            kafkaTemplate.send(topicName, String.valueOf(nuevaOrden.getId()), event);
            System.out.println("Evento OrdenCreadaEvent publicado para orden: " + nuevaOrden.getId());
        } catch (Exception e) {
            // Ojo!!! Manejar errores aquí es CLAVE. Qué pasa si Kafka no está?, Reintentos?, Compensación?
            System.err.println("¡Error publicando evento Kafka! " + e.getMessage());
            // Considerar patrones como Outbox aquí para garantizar la entrega.
        }

        return nuevaOrden;
    }
    // ...
}
// En ServicioInventario (el consumidor del evento)
@Service
public class InventoryEventListener {

    private final InventoryService inventoryService; // Lógica de negocio de inventario

    public InventoryEventListener(InventoryService inventoryService) {
        this.inventoryService = inventoryService;
    }

    // Escuchando el topic! Spring Boot hace fácil configurar el listener.
    @KafkaListener(topics = "ordenes.topic", groupId = "inventario-group", containerFactory = "kafkaListenerContainerFactory")
    // groupId asegura que si escalas este servicio, solo una instancia procese cada mensaje.
    // containerFactory permite configurar detalles (concurrencia, manejo de errores, etc.)
    public void handleOrdenCreadaEvent(OrdenCreadaEvent event, @Header(KafkaHeaders.RECEIVED_KEY) String key) {
        System.out.println("Evento recibido en Inventario (key: " + key + "): Orden ID " + event.getOrderId());
        try {
            // Lógica para actualizar el inventario basada en event.getItems()
            inventoryService.actualizarStock(event.getItems());
            System.out.println("Inventario actualizado para orden: " + event.getOrderId());
            // Si todo bien, Kafka marca el mensaje como procesado (commit offset).
        } catch (Exception e) {
            // Manejo de errores aquí también es VITAL!!!
            System.err.println("¡Error procesando evento de orden " + event.getOrderId() + "! " + e.getMessage());
            // Reintentar?, Mandar a Dead Letter Queue (DLQ)? Spring tiene mecanismos para esto.
            // Lanzar la excepción puede hacer que Spring reintente si está configurado.
            throw new RuntimeException("Fallo al procesar evento de inventario", e);
        }
    }
}

// Necesitarás una clase para el evento (puede ser un record)
// import java.util.List;
// public record OrdenCreadaEvent(Long orderId, String userId, List<Item> items) {}
// Y configurar Kafka en application.properties/yml (brokers, serializadores, etc.)

PRO Tip: La idempotencia es tu mejor amiga aquí. Asegúrate de que si tu servicio recibe el mismo evento dos veces (porque a veces pasa, mi bro), no haga la acción dos veces (imagina cobrarle dos veces al cliente!!! Frito). Usa identificadores únicos del evento o de la transacción para chequear si ya procesaste algo. Además, piensa bien tu estrategia de manejo de errores y reintentos. Qué pasa si no puedes procesar un evento?,Lo vas a reintentar N veces?, Lo mandas a una "cola de mensajes muertos" (DLQ), para analizarlo después? Spring Cloud Stream y Spring Kafka te ayudan con esto.

Diagrama de Arquitectura Orientada a Eventos


3. CQRS (Command Query Responsibility Segregation): Separar leer de escribir con estilo!!!

Este patrón suena más complicado de lo que es. La idea central es: una cosa es cambiar el estado del sistema (Comandos: crear orden, actualizar perfil, transferir dinero) y otra muy distinta es consultar el estado (Queries: ver historial de órdenes, buscar productos, obtener saldo). CQRS dice: Mi bro, separa esos dos caminos!!!.

Por qué separar y complicarte mas?

  • Optimización a Medida: Puedes optimizar el modelo de datos para escritura (quizás normalizado, enfocado en consistencia) y tener un modelo distinto para lectura (quizás desnormalizado, optimizado para velocidad de consulta, como vistas materializadas o bases de datos NoSQL).

  • Escalabilidad Diferente: A menudo lees datos muchas más veces de las que los escribes. Con CQRS, puedes escalar la parte de lectura independientemente de la de escritura.

  • Complejidad (Sí, es un trade-off): Puede añadir complejidad al sistema, especialmente si lo combinas con Event Sourcing (guardar todos los cambios como una secuencia de eventos). No es para todos los casos (hay que pensar bien el caso de uso).

Spring Boot y CQRS:

  • Axon Framework: Es el rey aquí. Un framework completo construido sobre CQRS y Event Sourcing que se integra muy bien con Spring Boot. Te da los bloques '@CommandHandler', '@QueryHandler', '@EventHandler', manejo de Sagas (otro spoiler!!!), etc.

  • Implementación Manual: También puedes hacerlo tú mismo sin un framework específico, separando lógicamente (o físicamente) tus modelos y servicios de comandos y queries.

Ejemplificamos entonces: Sistema Bancario (con onda Axon, recuerda que son ejemplos simplificados, solo para que se entienda la idea...)

Imagina retirar dinero (Comando) vs. consultar saldo (Query).

// --- Modelo de Comandos (Write Model) ---
// Usando Agregados de Axon/DDD. Representa una entidad transaccional.
@Aggregate
public class CuentaBancaria {

    @AggregateIdentifier // La clave que identifica esta cuenta
    private String accountId;
    private BigDecimal balance;

    // Constructor para crear la cuenta (manejador de comando de creación)
    @CommandHandler
    public CuentaBancaria(CrearCuentaCommand command) {
        // Validación básica
        if (command.getInitialBalance().compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Saldo inicial no puede ser negativo, bro!!!");
        }
        // Aplicamos un evento. Esto NO cambia el estado directamente.
        AggregateLifecycle.apply(new CuentaCreadaEvent(command.getAccountId(), command.getInitialBalance()));
    }

    // Este método es llamado por Axon CUANDO el evento CuentaCreadaEvent es aplicado.
    // AQUÍ es donde realmente cambia el estado del agregado.
    @EventSourcingHandler
    protected void on(CuentaCreadaEvent event) {
        this.accountId = event.getAccountId();
        this.balance = event.getInitialBalance();
        System.out.println("Manejando evento: Cuenta creada " + accountId + " con saldo " + balance);
    }

    // Manejador para el comando de retirar dinero
    @CommandHandler
    public void handle(RetirarDineroCommand command) {
        // Validación de negocio DENTRO del agregado. ¡Cohesión!
        if (this.balance.compareTo(command.getAmount()) < 0) {
            throw new SaldoInsuficienteException("¡No hay real suficiente en la cuenta " + accountId + "!");
        }
        if (command.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
             throw new IllegalArgumentException("¡Monto a retirar debe ser positivo!");
        }
        // Si todo OK, aplicamos el evento
        AggregateLifecycle.apply(new DineroRetiradoEvent(this.accountId, command.getAmount()));
    }

    // Event Sourcing Handler para actualizar el saldo cuando se retira dinero
    @EventSourcingHandler
    protected void on(DineroRetiradoEvent event) {
        this.balance = this.balance.subtract(event.getAmount());
        System.out.println("Manejando evento: Dinero retirado de " + accountId + ". Nuevo saldo: " + balance);
    }

    // Constructor vacío requerido por Axon
    protected CuentaBancaria() {}
}

// Clases de Comandos y Eventos (suelen ser records o clases simples)
// public record CrearCuentaCommand(String accountId, BigDecimal initialBalance) {}
// public record CuentaCreadaEvent(String accountId, BigDecimal initialBalance) {}
// public record RetirarDineroCommand(String accountId, BigDecimal amount) {}
// public record DineroRetiradoEvent(String accountId, BigDecimal amount) {}
// public class SaldoInsuficienteException extends RuntimeException { ... }
// --- Modelo de Consultas (Read Model) ---
// Una clase simple (proyección) que escucha eventos y actualiza una tabla/documento optimizado para lectura.
@Service
public class ProyeccionSaldoCuenta {

    // Inyectarías un repositorio para tu base de datos de lectura (e.g., Mongo, JPA, etc.)
    private final Map<String, BigDecimal> saldosCache = new ConcurrentHashMap<>(); // Simplificado

    // Escucha los eventos relevantes para actualizar la vista de lectura
    @EventHandler
    public void on(CuentaCreadaEvent event) {
        System.out.println("Proyección: Actualizando saldo para nueva cuenta " + event.getAccountId());
        saldosCache.put(event.getAccountId(), event.getInitialBalance());
    }

    @EventHandler
    public void on(DineroRetiradoEvent event) {
        System.out.println("Proyección: Actualizando saldo tras retiro en " + event.getAccountId());
        saldosCache.computeIfPresent(event.getAccountId(), (id, currentBalance) -> currentBalance.subtract(event.getAmount()));
    }

    // Manejador para responder a las queries de saldo
    @QueryHandler
    public SaldoCuenta handle(ConsultarSaldoQuery query) {
        System.out.println("Proyección: Respondiendo consulta de saldo para " + query.getAccountId());
        BigDecimal balance = saldosCache.getOrDefault(query.getAccountId(), BigDecimal.ZERO);
        // Podrías lanzar un error si la cuenta no existe, o devolver 0/null.
        return new SaldoCuenta(query.getAccountId(), balance);
    }
}

// Clase para la Query y el resultado
// public record ConsultarSaldoQuery(String accountId) {}
// public record SaldoCuenta(String accountId, BigDecimal balance) {}

PRO Tip: CQRS no es una bala de plata. Introduce complejidad (dos modelos, posible consistencia eventual en el lado de lectura). Aplícalo donde realmente tengas una diferencia significativa entre las necesidades de escritura y lectura, o donde la complejidad del dominio lo justifique. Empezar simple y refactorizar hacia CQRS si es necesario suele ser buena idea.

Diagrama del Patrón CQRS

Muchachones en la siguiente entrega veremos API Gateway, Sagas y Circuit Breaker. Activitos!!!

👁️ 177 vistas