CodeGym /Courses /Module 5. Spring /Lecture 218: Practice: designing a system with separation...

Lecture 218: Practice: designing a system with separation of commands and queries

Module 5. Spring
Level 14 , Lesson 7
Available

Today we're going to design a real system using the CQRS approach. We'll go through all the steps — from defining the task to implementing key components. Imagine we're building a simplified order management system so customers can place orders and managers can view the order list. Let's dive in!


Problem statement

We're building a microservice to manage orders for an online store. The requirements are:

  1. Users can create orders.
  2. Admins and managers can view the order list, filter orders by status, and get other reports.
  3. Data access should be as efficient as possible.

Requirements

  • There should be a clear separation between read and write operations.
  • Commands (data changes) and queries (data reads) shouldn't get in each other's way. For example, long-running report queries shouldn't block creating new orders.
  • The system should be ready to scale, able to handle a large number of users.

Design

CQRS Architecture

First, split responsibilities. We'll have two models:

  1. Command model (Write Model): handles changing the system state (e.g., creating and updating orders).
  2. Query model (Read Model): provides data in a read-optimized form (e.g., order lists, order details).

Here's a simplified architecture diagram:


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

Technology choices

  • Command Side (Write): we'll use Spring Boot with JPA/Hibernate to work with the database where orders are stored.
  • Query Side (Read): we'll optimize reads using structures suited for reading, possibly using separate tables or projections (e.g., Elasticsearch for Full-Text Search).

Data models

1. Command model:

Creating an order includes:

  • Order identifier
  • List of items
  • Creation date
  • Order status

Example model:


@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. Query model:

For queries, data is pre-optimized. For example, we create a flat table that already contains all order info so we don't need heavy joins.

Example model:


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
}

Implementation

Let's start with the write side. We'll use a controller to handle commands.


@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!");
    }
}

Service layer:


@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) for the request:


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

    // Getters and Setters
}

Queries are handled separately. We'll create a 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());
    }
}

Service layer:


@Service
public class OrderQueryService {

    private final OrderReadRepository orderReadRepository;

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

    public OrderReadModel getOrder(Long id) {
        // Use the repository to get data from the Read Database
        return orderReadRepository.findOrderById(id);
    }

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

Read repository:


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

Notes and improvements

  1. Data synchronization between Write and Read models:
    • When updating data in the Write Database we can use events (e.g., Kafka) to update the Read Database.
  2. Choosing the Read Database:
    • In simple scenarios you can use the same database (but with separate tables). In complex cases you might use NoSQL databases for queries, like Elasticsearch.
  3. Caching:
    • To speed up queries you can use a cache, for example Redis.
  4. Error handling:
    • Be sure to handle errors, e.g., data validation, database unavailability, etc.

So, thanks to CQRS, we've separated write and read operations, which improves the system's performance and scalability. In real projects CQRS helps deal with high loads and complex data processing, giving a more manageable architecture.

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