CQRS pattern – Command Query Responsibility

TL;DR: CQRS to wzorzec architektoniczny rozdzielający operacje odczytu (Query) od zapisu (Command). Pozwala na niezależną optymalizację obu ścieżek, różne modele danych i lepszą skalowalność. Idealny dla systemów z dużą dysproporcją między odczytem a zapisem lub złożonymi wymaganiami biznesowymi.

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).

Command – operacja która zmienia stan systemu ale nie zwraca danych (lub zwraca tylko ID/status)
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 List items;
    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 List findOrdersByCustomer(String customerId) {
        // Zapytanie zoptymalizowane dla tego przypadku użycia
        // Może używać indeksów, materialized views itp.
    }
}
Pro tip: W prostym CQRS możesz używać tej samej bazy danych dla Command i Query. Różnica polega na tym, że Query może korzystać z denormalizowanych widoków, projekcji czy nawet osobnych tabel read-model.

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);
    }
}
Uwaga: Synchronizacja między bazami wprowadza eventual consistency. Read model może być opóźniony o kilka milisekund względem write model. Musisz to uwzględnić w logice biznesowej!

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:

Typowy błąd: Stosowanie CQRS w prostych aplikacjach CRUD. To overengineering który tylko zwiększa złożoność bez realnych korzyści.
  • 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!
    }
}
Pro tip: Używaj message queue (RabbitMQ, Kafka) dla reliability. Spring Cloud Stream świetnie się sprawdza w takich scenariuszach.

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 List items;
    
    @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
        List orders = queryHandler
            .findOrdersByCustomer("customer-123");
        
        // Then
        assertThat(orders).hasSize(3);
        assertThat(orders)
            .extracting(OrderSummaryDTO::getStatus)
            .containsOnly("COMPLETED", "SHIPPED", "PROCESSING");
    }
}

Pułapki i wyzwania

Pułapka #1: Inconsistent read after write – użytkownik tworzy zamówienie i natychmiast próbuje je wyświetlić, ale read model jeszcze się nie zaktualizował.

Rozwiązanie – Read Your Writes

@Service
public class OrderFacade {
    private final Cache writeCache;
    
    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);
    }
}
Pułapka #2: Duplikacja logiki biznesowej między Command i Query side.

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

Czy CQRS wymaga Event Sourcing?

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.

Jak radzić sobie z eventual consistency?

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ę.

Czy mogę mieć wiele read models?

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.

Kiedy CQRS to overengineering?

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.

Jak debugować problemy z synchronizacją?

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.

Czy CQRS działa w monolicie?

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.

Jaki framework najlepiej wspiera CQRS w Javie?

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:

🚀 Zadanie dla Ciebie

Zaimplementuj prosty CQRS dla systemu blogowego:

  1. Command side: CreatePostCommand, UpdatePostCommand, PublishPostCommand
  2. Write model: Post entity z pełną logiką domenową
  3. Read model: PostSummaryDTO (id, title, author, excerpt, tags)
  4. Synchronizacja przez Spring Events
  5. 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!

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry