Tradycyjne aplikacje CRUD często napotykają na ograniczenia w miarę wzrostu skali i złożoności. Model domenowy który świetnie sprawdza się przy zapisie danych, niekoniecznie jest optymalny do ich odczytu. CQRS oferuje eleganckie rozwiązanie tego problemu.
Dlaczego CQRS jest ważny?
W większości aplikacji biznesowych stosunek operacji odczytu do zapisu wynosi często 100:1 lub więcej. Optymalizacja tego samego modelu dla obu operacji prowadzi do kompromisów które szkodzą wydajności i czytelności kodu.
Co się nauczysz:
- Czym jest CQRS i jakie problemy rozwiązuje
- Kiedy stosować (i kiedy nie stosować) tego wzorca
- Jak implementować CQRS w aplikacjach Java
- Różnice między prostym CQRS a wersją z Event Sourcing
- Praktyczne przykłady implementacji
- Wzorce synchronizacji między modelami
Wymagania wstępne:
- Znajomość wzorców DDD (Domain-Driven Design)
- Doświadczenie z aplikacjami Spring/Spring Boot
- Podstawowa wiedza o architekturze mikroserwisów
- Znajomość wzorca Repository
Czym jest CQRS?
CQRS (Command Query Responsibility Segregation) to wzorzec architektoniczny który rozdziela model odpowiedzialny za modyfikację danych (Commands) od modelu służącego do ich odczytu (Queries).
Query – operacja która zwraca dane ale nie modyfikuje stanu systemu
Podstawowe założenia CQRS
Wzorzec opiera się na kilku kluczowych założeniach:
- Separacja interfejsów – osobne API dla komend i zapytań
- Różne modele – model zapisu może różnić się od modelu odczytu
- Optymalizacja niezależna – każda strona może być optymalizowana osobno
- Eventual consistency – model odczytu może być nieznacznie opóźniony
Implementacja prostego CQRS
Zacznijmy od prostej implementacji bez Event Sourcing:
Struktura projektu
// Command - zmiana stanu public class CreateOrderCommand { private String customerId; private Listitems; private BigDecimal totalAmount; // getters, konstruktor } // Command Handler @Service public class CreateOrderCommandHandler { private final OrderRepository repository; @Transactional public String handle(CreateOrderCommand command) { Order order = new Order( command.getCustomerId(), command.getItems(), command.getTotalAmount() ); // Walidacja biznesowa validateOrder(order); // Zapis do bazy Order saved = repository.save(order); // Publikacja eventu (opcjonalne) publishOrderCreatedEvent(saved); return saved.getId(); } }
Strona Query
// Query DTO - zoptymalizowany dla odczytu public class OrderSummaryDTO { private String orderId; private String customerName; // denormalizowane! private BigDecimal total; private String status; private LocalDateTime createdAt; // konstruktor, getters } // Query Handler @Service public class OrderQueryHandler { private final JdbcTemplate jdbcTemplate; public OrderSummaryDTO findOrderSummary(String orderId) { String sql = "" SELECT o.id, c.name as customer_name, o.total_amount, o.status, o.created_at FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.id = ? """; return jdbcTemplate.queryForObject(sql, new Object[]{orderId}, (rs, rowNum) -> new OrderSummaryDTO( rs.getString("id"), rs.getString("customer_name"), rs.getBigDecimal("total_amount"), rs.getString("status"), rs.getTimestamp("created_at").toLocalDateTime() ) ); } public ListfindOrdersByCustomer(String customerId) { // Zapytanie zoptymalizowane dla tego przypadku użycia // Może używać indeksów, materialized views itp. } }
CQRS z osobnymi bazami danych
Bardziej zaawansowana wersja wykorzystuje osobne bazy danych:
Architektura z dwoma bazami
@Configuration public class DatabaseConfig { // Write DB - PostgreSQL dla transakcyjności @Bean @Primary @ConfigurationProperties("spring.datasource.write") public DataSource writeDataSource() { return DataSourceBuilder.create().build(); } // Read DB - MongoDB dla elastycznych zapytań @Bean @ConfigurationProperties("spring.data.mongodb.read") public MongoClient readMongoClient() { // Konfiguracja MongoDB dla read model } } // Synchronizacja przez eventy @Component public class OrderProjector { private final MongoTemplate mongoTemplate; @EventHandler public void on(OrderCreatedEvent event) { OrderReadModel readModel = new OrderReadModel( event.getOrderId(), event.getCustomerId(), enrichWithCustomerData(event.getCustomerId()), event.getItems(), event.getTotalAmount() ); mongoTemplate.save(readModel); } @EventHandler public void on(OrderStatusChangedEvent event) { Query query = Query.query( Criteria.where("orderId").is(event.getOrderId()) ); Update update = Update.update("status", event.getNewStatus()) .set("lastModified", event.getTimestamp()); mongoTemplate.updateFirst(query, update, OrderReadModel.class); } }
Kiedy stosować CQRS?
Dobre przypadki użycia:
- Duża dysproporcja read/write – np. e-commerce gdzie przeglądanie >> zakupy
- Złożone projekcje danych – raporty wymagające joinów z wielu tabel
- Różne wymagania wydajnościowe – write wymaga ACID, read wymaga szybkości
- Collaborative domain – wiele użytkowników modyfikuje te same dane
- Event Sourcing – CQRS naturalnie współgra z tym wzorcem
Kiedy unikać CQRS:
- Proste aplikacje CRUD z symetrycznym read/write
- Aplikacje gdzie model domenowy jest prosty
- Systemy real-time wymagające natychmiastowej spójności
- Małe zespoły bez doświadczenia w distributed systems
Wzorce synchronizacji
Kluczowym wyzwaniem w CQRS jest synchronizacja między modelami:
1. Synchronous Projection
@Service @Transactional public class OrderService { private final OrderWriteRepository writeRepo; private final OrderReadRepository readRepo; public String createOrder(CreateOrderCommand command) { // Write model Order order = new Order(command); Order saved = writeRepo.save(order); // Natychmiastowa projekcja do read model OrderReadModel readModel = projectToReadModel(saved); readRepo.save(readModel); return saved.getId(); } }
2. Asynchronous Events
@Service public class OrderCommandHandler { private final EventBus eventBus; @Transactional public String handle(CreateOrderCommand command) { Order order = new Order(command); orderRepository.save(order); // Publikacja eventu - projekcja asynchroniczna eventBus.publish(new OrderCreatedEvent( order.getId(), order.getCustomerId(), order.getItems(), order.getTotalAmount() )); return order.getId(); } } // Osobny serwis nasłuchujący @Component public class ReadModelUpdater { @Async @EventListener public void handle(OrderCreatedEvent event) { // Aktualizacja read model // Może być w osobnym mikroserwisie! } }
CQRS z Event Sourcing
Naturalne połączenie – Event Store jako źródło prawdy:
// Aggregate z Event Sourcing @Aggregate public class Order { @AggregateIdentifier private String orderId; private OrderStatus status; private Listitems; @CommandHandler public Order(CreateOrderCommand command) { // Walidacja validateCommand(command); // Aplikacja eventu apply(new OrderCreatedEvent( command.getOrderId(), command.getCustomerId(), command.getItems() )); } @EventSourcingHandler public void on(OrderCreatedEvent event) { this.orderId = event.getOrderId(); this.status = OrderStatus.CREATED; this.items = event.getItems(); } @CommandHandler public void handle(ShipOrderCommand command) { if (status != OrderStatus.PAID) { throw new IllegalStateException("Cannot ship unpaid order"); } apply(new OrderShippedEvent(orderId, command.getTrackingNumber())); } } // Projekcja do read model @Component @ProcessingGroup("order-projections") public class OrderProjection { @EventHandler public void on(OrderCreatedEvent event, @Timestamp Instant timestamp) { // Tworzenie read model OrderView view = new OrderView(); view.setOrderId(event.getOrderId()); view.setCreatedAt(timestamp); // ... mapowanie pól orderViewRepository.save(view); } }
Testowanie CQRS
Testowanie wymaga osobnego podejścia dla Command i Query:
Testowanie Command Handler
@Test public class CreateOrderCommandHandlerTest { @Mock private OrderRepository repository; @Mock private EventBus eventBus; @InjectMocks private CreateOrderCommandHandler handler; @Test public void shouldCreateOrderAndPublishEvent() { // Given CreateOrderCommand command = new CreateOrderCommand( "customer-123", Arrays.asList(new OrderItem("product-1", 2)), new BigDecimal("99.99") ); Order savedOrder = new Order(); savedOrder.setId("order-456"); when(repository.save(any(Order.class))).thenReturn(savedOrder); // When String orderId = handler.handle(command); // Then assertEquals("order-456", orderId); verify(eventBus).publish(argThat(event -> event instanceof OrderCreatedEvent && ((OrderCreatedEvent) event).getOrderId().equals("order-456") )); } }
Testowanie Query Handler
@Test public class OrderQueryHandlerTest { @Autowired private OrderQueryHandler queryHandler; @Test @Sql("/test-data/orders.sql") // Przygotowanie read model public void shouldFindOrdersByCustomer() { // When Listorders = queryHandler .findOrdersByCustomer("customer-123"); // Then assertThat(orders).hasSize(3); assertThat(orders) .extracting(OrderSummaryDTO::getStatus) .containsOnly("COMPLETED", "SHIPPED", "PROCESSING"); } }
Pułapki i wyzwania
Rozwiązanie – Read Your Writes
@Service public class OrderFacade { private final CachewriteCache; public OrderDTO getOrder(String orderId, String userId) { // Sprawdź czy user właśnie utworzył to zamówienie String cacheKey = userId + ":" + orderId; Order cached = writeCache.getIfPresent(cacheKey); if (cached != null) { // Zwróć dane z write model return mapToDTO(cached); } // W przeciwnym razie użyj read model return orderQueryHandler.findOrder(orderId); } }
Rozwiązanie – Shared Kernel
// Wspólne reguły biznesowe public class PricingRules { public static BigDecimal calculateDiscount(Order order) { // Logika rabatów używana po obu stronach } public static boolean isEligibleForFreeShipping(Order order) { // Reguły darmowej wysyłki } }
Performance i skalowanie
CQRS umożliwia niezależne skalowanie:
Skalowanie Write Model
- Vertical scaling dla bazy transakcyjnej
- Partycjonowanie po agregacie (np. per customer)
- Write-behind caching dla performance
Skalowanie Read Model
- Read replicas bez ograniczeń
- Różne technologie dla różnych queries (Redis, Elasticsearch)
- CDN dla statycznych projekcji
- Materialized views dla złożonych raportów
FAQ – Często zadawane pytania
Nie, to osobne wzorce które dobrze się uzupełniają ale można stosować CQRS bez Event Sourcing. Prosty CQRS może używać tej samej bazy z różnymi modelami lub projekcjami.
Przyjmij że to feature, nie bug. Projektuj UI tak aby użytkownik wiedział że operacja jest w trakcie. Używaj optimistic updates w UI. Dla krytycznych operacji możesz zastosować synchroniczną projekcję.
Tak! To jedna z głównych zalet CQRS. Możesz mieć osobny model dla listy, szczegółów, raportów, wyszukiwania. Każdy zoptymalizowany pod konkretny przypadek użycia.
Gdy Twoja aplikacja ma proste CRUD operations, symetryczny read/write, niewiele użytkowników i proste raporty. CQRS ma sens gdy faktycznie potrzebujesz różnych modeli dla Command i Query.
Loguj wszystkie eventy z timestamp. Używaj correlation ID do śledzenia flow. Monitoruj lag między write i read model. Narzędzia jak Zipkin czy Jaeger pomagają w distributed tracing.
Tak! CQRS to wzorzec architektoniczny, nie deployment model. Możesz mieć CQRS w monolicie z logiczną separacją. Mikroserwisy ułatwiają fizyczną separację ale nie są wymagane.
Axon Framework to najpopularniejszy wybór – ma wbudowane wsparcie dla CQRS i Event Sourcing. Alternatywnie: Eventuate, Lagom lub własna implementacja z Spring + Kafka.
Przydatne zasoby:
- Martin Fowler o CQRS
- Axon Framework Documentation
- Greg Young – CQRS and Event Sourcing
- Axon Framework GitHub
- CQRS w kontekście mikroserwisów
🚀 Zadanie dla Ciebie
Zaimplementuj prosty CQRS dla systemu blogowego:
- Command side: CreatePostCommand, UpdatePostCommand, PublishPostCommand
- Write model: Post entity z pełną logiką domenową
- Read model: PostSummaryDTO (id, title, author, excerpt, tags)
- Synchronizacja przez Spring Events
- Bonus: Dodaj Elasticsearch dla full-text search w read model
Pamiętaj o testach jednostkowych dla obu stron!
CQRS to potężny wzorzec który rozwiązuje realne problemy w złożonych systemach. Czy używasz go już w swoich projektach? Jakie wyzwania napotkałeś przy implementacji? Podziel się doświadczeniami w komentarzach!