CodeGym /Cursos /Módulo 5. Spring /Lección 218: Práctica: diseño de un sistema con separació...

Lección 218: Práctica: diseño de un sistema con separación de comandos y consultas

Módulo 5. Spring
Nivel 14 , Lección 7
Disponible

Hoy vamos a diseñar un sistema real aplicando el enfoque CQRS. Recorreremos todas las etapas — desde la definición del problema hasta la implementación de los componentes clave. Imagina que creamos un sistema simplificado para gestionar pedidos, para que los clientes puedan hacer un pedido y los gestores puedan ver la lista de pedidos. ¡Vamos a ello!


Planteamiento del problema

Estamos desarrollando un microservicio para la gestión de pedidos en una tienda online. Los requisitos son los siguientes:

  1. Los usuarios pueden crear pedidos.
  2. Los administradores y gestores pueden ver la lista de pedidos, filtrar pedidos por estado y generar otros informes.
  3. El trabajo con los datos debe ser lo más eficiente posible.

Requisitos

  • Debe existir una separación clara entre operaciones de lectura y de escritura.
  • Los comandos (cambios de datos) y las consultas (lectura de datos) no deben interferir entre sí. Por ejemplo, operaciones de lectura largas para informes no deberían bloquear la creación de nuevos pedidos.
  • El sistema debe estar listo para escalar, con capacidad para manejar un gran número de usuarios.

Diseño

Arquitectura con CQRS

Primero vamos a separar responsabilidades. Definimos dos modelos:

  1. Modelo de comandos (Write Model): se encarga de cambiar el estado del sistema (por ejemplo, creación y modificación de pedidos).
  2. Modelo de consultas (Read Model): permite obtener datos en una forma optimizada para lectura (por ejemplo, lista de pedidos, detalles de un pedido).

Aquí tienes un esquema simplificado de la arquitectura:


[ Cliente ]
   |  
[ API Gateway ]  
   |  
[ Command Service ] <---> [ Write Database ]  
[ Query Service ] <---> [ Read Database ]

Elección de tecnologías

  • Command Side (Write): usaremos Spring Boot con JPA/Hibernate para trabajar con la base de datos donde se almacenarán los pedidos.
  • Query Side (Read): optimizaremos la lectura de datos usando una estructura adecuada para lectura, con posible uso de tablas separadas o proyecciones (por ejemplo, Elasticsearch para Full-Text Search).

Modelos de datos

1. Modelo de comandos:

La creación de un pedido incluye:

  • Identificador del pedido
  • Lista de productos
  • Fecha de creación
  • Estado del pedido

Ejemplo de modelo:


@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String customerName;

    @Column(nullable = false)
    private String status;

    @OneToMany
    private List<OrderItem> items;

    // Getters and Setters
}


@Entity
@Table(name = "order_items")
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String productName;
    private int quantity;
    private double price;

    // Getters and Setters
}

2. Modelo de consultas:

Para las consultas los datos se optimizan por adelantado. Por ejemplo, podemos crear una tabla plana que contenga toda la información del pedido para evitar joins complicados.

Ejemplo de modelo:


public class OrderReadModel {
    private Long id;
    private String customerName;
    private String status;
    private List<OrderItemReadModel> items;

    // Getters and Setters
}

public class OrderItemReadModel {
    private String productName;
    private int quantity;
    private double price;

    // Getters and Setters
}

Implementación

Empezamos por el lado de escritura. Usamos un controlador para procesar comandos.


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

    private final OrderService orderService;

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

    @PostMapping
    public ResponseEntity<String> createOrder(@RequestBody OrderRequest request) {
        orderService.createOrder(request);
        return ResponseEntity.ok("Order created successfully!");
    }
}

Capa de servicio:


@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public void createOrder(OrderRequest request) {
        Order order = new Order();
        order.setCustomerName(request.getCustomerName());
        order.setStatus("NEW");
        List<OrderItem> items = request.getItems().stream()
                .map(item -> {
                    OrderItem orderItem = new OrderItem();
                    orderItem.setProductName(item.getProductName());
                    orderItem.setQuantity(item.getQuantity());
                    orderItem.setPrice(item.getPrice());
                    return orderItem;
                }).collect(Collectors.toList());
        order.setItems(items);
        orderRepository.save(order);
    }
}

DTO (Data Transfer Object) para la solicitud:


public class OrderRequest {
    private String customerName;
    private List<OrderItemRequest> items;

    // Getters and Setters
}

Las consultas se manejan por separado. Crearemos el Query Service:


@RestController
@RequestMapping("/orders/query")
public class OrderQueryController {

    private final OrderQueryService orderQueryService;

    public OrderQueryController(OrderQueryService orderQueryService) {
        this.orderQueryService = orderQueryService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<OrderReadModel> getOrder(@PathVariable Long id) {
        return ResponseEntity.ok(orderQueryService.getOrder(id));
    }

    @GetMapping
    public ResponseEntity<List<OrderReadModel>> getAllOrders() {
        return ResponseEntity.ok(orderQueryService.getAllOrders());
    }
}

Capa de servicio:


@Service
public class OrderQueryService {

    private final OrderReadRepository orderReadRepository;

    public OrderQueryService(OrderReadRepository orderReadRepository) {
        this.orderReadRepository = orderReadRepository;
    }

    public OrderReadModel getOrder(Long id) {
        // Usamos el repositorio para obtener datos desde Read Database
        return orderReadRepository.findOrderById(id);
    }

    public List<OrderReadModel> getAllOrders() {
        return orderReadRepository.findAll();
    }
}

Repositorio para lectura:


public interface OrderReadRepository {
    OrderReadModel findOrderById(Long id);
    List<OrderReadModel> findAll();
}

Notas y mejoras

  1. Sincronización de datos entre Write y Read modelos:
    • Al actualizar datos en la Write Database podemos usar eventos (por ejemplo, Kafka) para actualizar la Read Database.
  2. Elección de Read Database:
    • En escenarios simples se puede usar la misma base de datos (pero con tablas separadas). En casos complejos para las consultas se pueden usar bases NoSQL, como Elasticsearch.
  3. Caché:
    • Para acelerar las consultas se puede usar caché, por ejemplo Redis.
  4. Manejo de errores:
    • Es obligatorio manejar errores, por ejemplo la validación de datos, la indisponibilidad de la base de datos, etc.

Así, gracias a CQRS hemos separado las operaciones de escritura y lectura de datos, lo que mejora el rendimiento y la escalabilidad del sistema. En proyectos reales el enfoque CQRS ayuda a manejar altas cargas y la complejidad del procesamiento de datos, proporcionando una arquitectura más manejable.

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION