Command Query Responsibility Segregation (CQRS) in Microservices: Een Praktische Implementatie

Geschreven door: bert
| Datum: 07 / 05 / 2025

# Inleiding: De Uitdaging van Moderne Dataarchitectuur

In de wereld van microservices en gedistribueerde systemen worden we steeds vaker geconfronteerd met een fundamentele uitdaging: hoe schalen we onze applicaties efficiënt terwijl we zowel lees- als schrijfoperaties optimaal bedienen? De traditionele aanpak waarbij één datamodel beide verantwoordelijkheden draagt, leidt vaak tot prestatieproblemen en complexe codebases.

Na onze verkenningen van het Saga-patroon, Event Sourcing en de GRASP-richtlijnen in voorgaande blogs, is het tijd om een complementair patroon te onderzoeken: Command Query Responsibility Segregation (CQRS). Dit patroon vormt samen met Event Sourcing een krachtige combinatie die de backbone kan zijn van moderne, schaalbare applicaties.

# Wat is CQRS?

CQRS is gebaseerd op een eenvoudig maar krachtig principe: splits je datamodel in twee delen:

  1. Command-model: Geoptimaliseerd voor schrijfoperaties (commands)
  2. Query-model: Geoptimaliseerd voor leesoperaties (queries)

Dit in tegenstelling tot het traditionele CRUD-model, waarbij één model verantwoordelijk is voor zowel lezen als schrijven:

┌─────────────────────┐
│                     │
│   Traditioneel      │
│   CRUD Model        │
│                     │
└─────────────────────┘
        ▲   ▲
        │   │
        │   │
 ┌──────┘   └──────┐
 │                 │
 ▼                 ▼
┌─────────┐   ┌─────────┐
│Commands │   │ Queries │
└─────────┘   └─────────┘

Versus het CQRS-model:

┌─────────────────┐    ┌─────────────────┐
│                 │    │                 │
│  Command Model  │    │  Query Model    │
│                 │    │                 │
└─────────────────┘    └─────────────────┘
        ▲                    ▲
        │                    │
        │                    │
        │                    │
 ┌──────┘              ┌─────┘
 │                     │
 ▼                     ▼
┌─────────┐       ┌─────────┐
│Commands │       │ Queries │
└─────────┘       └─────────┘

# Waarom CQRS Implementeren?

Er zijn verschillende situaties waarin CQRS een uitstekende oplossing biedt:

  1. Asymmetrische schaalbaarheid: Leesoperaties (queries) komen vaak veel vaker voor dan schrijfoperaties (commands).
  2. Complexe domeinen: Bij complexe business logica kan het handig zijn om het schrijfmodel te optimaliseren voor consistentie en het leesmodel voor presentatie.
  3. Rapportage behoeften: Vaak heb je voor rapportages gedenormaliseerde data nodig, terwijl je voor transacties genormaliseerde data wilt.
  4. Integratie met Event Sourcing: CQRS werkt naadloos samen met Event Sourcing, wat we eerder hebben besproken.

# CQRS in de Praktijk: Een Java-implementatie

Laten we een praktische implementatie bekijken van CQRS in een Java-applicatie. We bouwen voort op onze eerdere discussie over microservices en gebruiken Spring Boot.

# 1. De Basisstructuur

// Command model - geoptimaliseerd voor schrijven
@Entity
public class ProductCommand {
    @Id
    private String productId;
    private String name;
    private BigDecimal price;
    private int stockQuantity;

    // Validatielogica en domeinregels hier
}

// Query model - geoptimaliseerd voor lezen
@Entity
@Table(name = "product_view")
public class ProductQuery {
    @Id
    private String productId;
    private String name;
    private BigDecimal price;
    private int stockQuantity;
    private String category;
    private List<String> tags;
    private int reviewCount;
    private BigDecimal averageRating;

    // Geen domeinlogica, pure data
}

# 2. De Command-zijde

@Service
public class ProductCommandService {
    private final ProductCommandRepository repository;
    private final EventPublisher eventPublisher;

    @Transactional
    public void createProduct(CreateProductCommand command) {
        // Validatie
        validateProductCommand(command);

        // Creëer het product
        ProductCommand product = new ProductCommand(
            UUID.randomUUID().toString(),
            command.getName(),
            command.getPrice(),
            command.getStockQuantity()
        );

        // Persisteer
        repository.save(product);

        // Publiceer event voor synchronisatie
        eventPublisher.publish(new ProductCreatedEvent(product));
    }

    @Transactional
    public void updateStock(UpdateStockCommand command) {
        ProductCommand product = repository.findById(command.getProductId())
            .orElseThrow(() -> new ProductNotFoundException(command.getProductId()));

        // Business regels toepassen
        if (command.getNewQuantity() < 0) {
            throw new InvalidStockQuantityException();
        }

        product.setStockQuantity(command.getNewQuantity());
        repository.save(product);

        // Publiceer event voor synchronisatie
        eventPublisher.publish(new ProductStockUpdatedEvent(product));
    }
}

# 3. De Query-zijde

@Service
public class ProductQueryService {
    private final ProductQueryRepository repository;

    public ProductQueryDTO getProductById(String id) {
        return repository.findById(id)
            .map(this::mapToDTO)
            .orElseThrow(() -> new ProductNotFoundException(id));
    }

    public List<ProductQueryDTO> findProductsByCategory(String category) {
        return repository.findByCategory(category).stream()
            .map(this::mapToDTO)
            .collect(Collectors.toList());
    }

    public List<ProductQueryDTO> findTopRatedProducts(int limit) {
        return repository.findByOrderByAverageRatingDesc(PageRequest.of(0, limit)).stream()
            .map(this::mapToDTO)
            .collect(Collectors.toList());
    }

    private ProductQueryDTO mapToDTO(ProductQuery product) {
        return new ProductQueryDTO(
            product.getProductId(),
            product.getName(),
            product.getPrice(),
            product.getStockQuantity(),
            product.getCategory(),
            product.getTags(),
            product.getReviewCount(),
            product.getAverageRating()
        );
    }
}

# 4. Synchronisatie tussen Command en Query modellen

De synchronisatie tussen de command- en query-modellen is een cruciaal onderdeel van CQRS. Hiervoor gebruiken we events:

@Service
public class ProductEventHandler {
    private final ProductQueryRepository queryRepository;

    @EventListener
    public void handleProductCreated(ProductCreatedEvent event) {
        ProductQuery productView = new ProductQuery();
        productView.setProductId(event.getProductId());
        productView.setName(event.getName());
        productView.setPrice(event.getPrice());
        productView.setStockQuantity(event.getStockQuantity());
        productView.setCategory(event.getCategory());
        productView.setTags(event.getTags());
        productView.setReviewCount(0);
        productView.setAverageRating(BigDecimal.ZERO);

        queryRepository.save(productView);
    }

    @EventListener
    public void handleStockUpdated(ProductStockUpdatedEvent event) {
        queryRepository.findById(event.getProductId())
            .ifPresent(product -> {
                product.setStockQuantity(event.getNewQuantity());
                queryRepository.save(product);
            });
    }
}

# Architecturale Overwegingen bij CQRS

Bij het implementeren van CQRS zijn er belangrijke architecturale overwegingen:

# 1. Eventual Consistency

Met gescheiden lees- en schrijfmodellen introduceren we eventual consistency: het leesmodel is niet altijd direct gesynchroniseerd met het schrijfmodel. Dit moet je ontwerp hierop aanpassen:

@RestController
@RequestMapping("/products")
public class ProductController {
    private final ProductCommandService commandService;
    private final ProductQueryService queryService;

    @PostMapping
    public ResponseEntity<String> createProduct(@RequestBody CreateProductRequest request) {
        CreateProductCommand command = mapToCommand(request);
        commandService.createProduct(command);

        // Return 202 Accepted omdat de view mogelijk nog niet is bijgewerkt
        return ResponseEntity.accepted().body("Product creation in progress");
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProductQueryDTO> getProduct(@PathVariable String id) {
        return ResponseEntity.ok(queryService.getProductById(id));
    }
}

# 2. Databasekeuze

Vaak is het zinvol om verschillende databases te gebruiken voor je command- en query-modellen:

  • Command-model: Vaak een relationele database (PostgreSQL, MySQL) voor ACID-transacties
  • Query-model: Kan een NoSQL-oplossing zijn (MongoDB, Elasticsearch) voor flexibiliteit en prestaties

# 3. Integratie met Event Sourcing

CQRS werkt uitzonderlijk goed samen met Event Sourcing, waarbij alle wijzigingen als events worden opgeslagen:

@Service
public class ProductEventSourcedService {
    private final EventStore eventStore;
    private final EventPublisher eventPublisher;

    @Transactional
    public void handleCommand(CreateProductCommand command) {
        // Valideer command
        validateCommand(command);

        // Creëer event
        ProductCreatedEvent event = new ProductCreatedEvent(
            UUID.randomUUID().toString(),
            command.getName(),
            command.getPrice(),
            command.getStockQuantity()
        );

        // Sla event op in event store
        eventStore.store(event);

        // Publiceer voor eventhandlers (om query model bij te werken)
        eventPublisher.publish(event);
    }
}

# Praktijkvoorbeeld: Een Online Webshop

Laten we de concepten toepassen op een praktisch voorbeeld: een online webshop.

# Het Command-model voor Bestellingen

@Service
public class OrderCommandService {
    private final OrderRepository repository;
    private final ProductCommandRepository productRepository;
    private final EventPublisher eventPublisher;

    @Transactional
    public String placeOrder(PlaceOrderCommand command) {
        // Validatie van beschikbaarheid
        validateProductAvailability(command.getItems());

        // Creëer order
        OrderCommand order = new OrderCommand(
            UUID.randomUUID().toString(),
            command.getCustomerId(),
            command.getItems(),
            OrderStatus.PENDING,
            LocalDateTime.now()
        );

        // Update voorraad
        updateInventory(command.getItems());

        // Persisteer order
        repository.save(order);

        // Publiceer event
        eventPublisher.publish(new OrderPlacedEvent(order));

        return order.getOrderId();
    }

    private void updateInventory(List<OrderItemCommand> items) {
        for (OrderItemCommand item : items) {
            ProductCommand product = productRepository.findById(item.getProductId())
                .orElseThrow(() -> new ProductNotFoundException(item.getProductId()));

            int newQuantity = product.getStockQuantity() - item.getQuantity();
            if (newQuantity < 0) {
                throw new InsufficientStockException(item.getProductId());
            }

            product.setStockQuantity(newQuantity);
            productRepository.save(product);

            // Publiceer event voor synchronisatie
            eventPublisher.publish(new ProductStockUpdatedEvent(
                product.getProductId(), newQuantity));
        }
    }
}

# Het Query-model voor Bestellingen

@Service
public class OrderQueryService {
    private final OrderQueryRepository repository;

    public List<OrderSummaryDTO> getCustomerOrders(String customerId) {
        return repository.findByCustomerId(customerId).stream()
            .map(this::mapToSummaryDTO)
            .collect(Collectors.toList());
    }

    public OrderDetailDTO getOrderDetails(String orderId) {
        return repository.findById(orderId)
            .map(this::mapToDetailDTO)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
    }

    // DTOs en mapping methoden...
}

# Prestatievoordelen van CQRS

Een van de grootste voordelen van CQRS is de mogelijkheid om de lees- en schrijfkant onafhankelijk te schalen. Laten we kijken naar enkele prestatieverbeteringen:

  1. Geoptimaliseerde leesmodellen: Je kunt query-specifieke denormaliseerde modellen maken.
  2. Caching: Het query-model kan effectiever worden gecached omdat het minder vaak verandert.
  3. Schaalbaar schrijven: Commands kunnen asynchroon worden verwerkt voor hogere throughput.

# Uitdagingen en Valkuilen

CQRS is niet zonder uitdagingen:

  1. Complexiteit: Het patroon introduceert extra complexiteit die niet altijd gerechtvaardigd is voor eenvoudige applicaties.
  2. Eventual Consistency: Gebruikers moeten mogelijk worden opgeleid dat wijzigingen niet altijd onmiddellijk zichtbaar zijn.
  3. Dubbele gegevensopslag: Je slaat gegevens dubbel op, wat meer opslagruimte vereist.

# Best Practices

Op basis van mijn ervaring met CQRS en Event Sourcing in productie, hier enkele best practices:

  1. Begin klein: Implementeer CQRS eerst voor één bounded context, niet voor je hele applicatie.
  2. Gebruik Event Sourcing verstandig: Niet elk deel van je applicatie heeft Event Sourcing nodig.
  3. Optimaliseer query-modellen: Maak specifieke query-modellen voor verschillende use cases.
  4. Monitoring: Implementeer monitoring om synchronisatieproblemen snel te detecteren.
  5. Idempotente event handlers: Zorg ervoor dat je event handlers idempotent zijn voor herhaalde events.

# Conclusie

CQRS is een krachtig patroon dat, wanneer juist toegepast, aanzienlijke voordelen biedt voor complexe, schaalbare applicaties. Het scheiden van lees- en schrijfmodellen geeft je de vrijheid om elke kant te optimaliseren voor zijn specifieke taak, wat leidt tot betere prestaties en schaalbaarheid.

In combinatie met patroonen die we eerder hebben besproken, zoals Saga en Event Sourcing, vormt CQRS een belangrijk onderdeel van de moderne architectuurgereedschapskist voor gedistribueerde systemen.

In een volgende blog zullen we dieper ingaan op de implementatie van CQRS met specifieke technologieën zoals Apache Kafka voor event streaming en Elasticsearch voor geoptimaliseerde query-modellen.

Heb je ervaring met CQRS in je projecten? Deel je ervaringen en uitdagingen in de reacties!

# Referenties

Reacties (0 )

Geen reacties beschikbaar.