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:
- Los usuarios pueden crear pedidos.
- Los administradores y gestores pueden ver la lista de pedidos, filtrar pedidos por estado y generar otros informes.
- 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:
- Modelo de comandos (Write Model): se encarga de cambiar el estado del sistema (por ejemplo, creación y modificación de pedidos).
- 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
- 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.
- 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.
- Caché:
- Para acelerar las consultas se puede usar caché, por ejemplo Redis.
- 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.
GO TO FULL VERSION