John Roest

CQRS with Spring Boot and Java: The Why Behind the Pattern

CQRS with Spring Boot and Java: The Why Behind the Pattern

What is CQRS?#

CQRS—Command Query Responsibility Segregation—separates the way you handle reading data from the way you handle writing data. Rather than using a single model for both operations, CQRS splits them into two distinct models:

  • Command Model: Handles write operations. It processes commands and updates the state of the application.
  • Query Model: Handles read operations. It is optimized for querying and retrieving data efficiently.

The result is two sides to your system: one tuned for writes, one tuned for reads.

Why CQRS Exists#

Traditional CRUD architectures apply the same data model to both reads and writes. This works at small scale. As the application grows, the tradeoffs become concrete:

  1. Separation of concerns: A single model serving both reads and writes carries conflicting pressures—normalization for integrity on the write side, denormalization for performance on the read side. CQRS resolves this by giving each side its own model.

  2. Performance: Read operations are typically frequent and latency-sensitive. Write operations involve validation, business rules, and consistency guarantees. Separating the two allows each to be optimized independently.

  3. Scalability: With CQRS, the read and write sides scale independently. High read volume does not require scaling the write infrastructure, and vice versa.

  4. Storage flexibility: The write model and read model can use different storage technologies. A relational database for writes and a document store or search index for reads is a common combination.

  5. Auditability: Commands represent explicit intent. Maintaining a log of commands gives you a clear audit trail of every state transition in the system.

Implementing CQRS with Spring Boot and Java#

Project Setup#

Create a new Spring Boot project with Spring Web, Spring Data JPA, and an H2 database.

Domain Model#

User.java

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;

    // Getters and Setters
}

Command Model#

Commands represent the intent to change state.

CreateUserCommand.java

public class CreateUserCommand {
    private String name;
    private String email;

    // Constructor, Getters, and Setters
}

UserCommandHandler.java

import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;

@Service
public class UserCommandHandler {

    @Autowired
    private UserRepository userRepository;

    public void handleCreateUser(CreateUserCommand command) {
        User user = new User();
        user.setName(command.getName());
        user.setEmail(command.getEmail());
        userRepository.save(user);
    }
}

Query Model#

Queries retrieve state without modifying it.

GetUserByIdQuery.java

public class GetUserByIdQuery {
    private Long id;

    // Constructor, Getters, and Setters
}

UserQueryHandler.java

import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;

@Service
public class UserQueryHandler {

    @Autowired
    private UserRepository userRepository;

    public User handleGetUserById(GetUserByIdQuery query) {
        return userRepository.findById(query.getId()).orElse(null);
    }
}

Repository#

UserRepository.java

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
}

Controllers#

UserCommandController.java

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserCommandController {

    private final UserCommandHandler commandHandler;

    public UserCommandController(UserCommandHandler commandHandler) {
        this.commandHandler = commandHandler;
    }

    @PostMapping
    public void createUser(@RequestBody CreateUserCommand command) {
        commandHandler.handleCreateUser(command);
    }
}

UserQueryController.java

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserQueryController {

    private final UserQueryHandler queryHandler;

    public UserQueryController(UserQueryHandler queryHandler) {
        this.queryHandler = queryHandler;
    }

    @GetMapping("/{id}")
    public User getUserById(@PathVariable Long id) {
        GetUserByIdQuery query = new GetUserByIdQuery(id);
        return queryHandler.handleGetUserById(query);
    }
}

Summary#

The implementation above cleanly separates read and write responsibilities:

  1. Domain Model: User entity.
  2. Command Model: CreateUserCommand and UserCommandHandler handle writes.
  3. Query Model: GetUserByIdQuery and UserQueryHandler handle reads.
  4. Repository: UserRepository abstracts database access.
  5. Controllers: Separate controllers for command and query operations.

Write operations pass through the command model. Read operations use the query model. This separation becomes increasingly valuable as the application grows in complexity and load—the read and write sides can evolve independently without contaminating each other.

CQRS introduces overhead that is not justified for every system. Apply it where the read/write asymmetry is real and the domain is sufficiently complex to benefit from the separation.