Enterprise Java

CQRS with Spring Modulith

CQRS (Command Query Responsibility Segregation) is a design pattern that separates the responsibilities of reading and writing data. When combined with Spring Modulith, which helps in building modular monoliths, you can achieve a clean architecture, better scalability, and maintainable code. Let us delve into understanding Spring Modulith and how it facilitates implementing CQRS.

1. Spring Modulith

Spring Modulith is a framework built on top of Spring Boot for building modular monolithic applications. It allows developers to define clear module boundaries within a single application, promoting decoupled components and well-defined interfaces. Each module can encapsulate its own business logic, data models, and services, while remaining part of the same monolithic deployment. This approach makes it easier to apply design patterns like CQRS without moving to a microservices architecture, reducing operational complexity while improving maintainability.

Some key features of Spring Modulith include:

  • Module discovery and enforcement of boundaries.
  • Support for Spring Beans and dependency injection across modules.
  • Event-driven communication between modules using Spring ApplicationEvents.
  • Integration with Spring Boot’s configuration management.
  • Support for documenting and testing module interactions.

1.1 CQRS

CQRS (Command Query Responsibility Segregation) is a design pattern that separates application logic into two distinct models: Commands for writes and Queries for reads. Commands handle operations that change the state of the system, while Queries are optimized to retrieve data efficiently without modifying state. This separation helps improve scalability, maintainability, and simplifies the reasoning about system behavior.

This approach provides several advantages:

  • Optimized read and write models, allowing each to evolve independently.
  • Clear separation of concerns, reducing the risk of unintended side effects.
  • Improved scalability, since reads and writes can be optimized and scaled differently.
  • Supports event-driven integration opportunities, where write operations can trigger events consumed by other parts of the system.

1.1.1 Trade-Offs

Implementing CQRS comes with both advantages and disadvantages:

  • Pros:
    • Separation of concerns, which makes code easier to maintain and reason about.
    • Optimized read and write paths, allowing better performance tuning for each side.
    • Easy scaling and maintainability, as read and write models can evolve independently.
  • Cons:
    • Increased complexity due to having multiple models for the same data.
    • More classes and services to manage, adding to development and maintenance effort.
    • Eventual consistency in distributed setups, which can complicate debugging and user expectations.

While CQRS provides architectural benefits, it is important to weigh the trade-offs and apply it where the separation of read and write operations adds significant value.

2. Code Example

Let’s create a simple Spring Boot + Spring Modulith example of a Task Management application using CQRS.

2.1 Dependencies (Maven)

Include the following Maven dependencies to set up Spring Boot, Spring Modulith, JPA, H2 database, and validation support.

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-starter-core</artifactId>
    </dependency>
</dependencies>

This configuration sets up Spring Boot with JPA for data access, H2 as an in-memory database for testing, validation for input, web support for REST endpoints, and Spring Modulith for modular boundaries and architecture enforcement.

2.2 Module Structure with Spring Modulith

Spring Modulith enforces module boundaries automatically based on package structure. Each module should be a top-level package containing its domain logic, services, and internal components. Classes in one module cannot reference internal classes of another module unless explicitly allowed via the Modulith configuration. For our Task Management example, we structure the project as follows:

  • com.example.task.command: contains all write-side logic, including Task entity, TaskCommandService, and related domain classes.
  • com.example.task.query: contains read-side logic, including TaskQueryService and DTOs for querying tasks.
  • com.example.task.api: contains REST controllers, exposing endpoints for commands and queries, but no domain logic.

This package-based separation allows Spring Modulith to scan the project and automatically enforce the rule that modules can only interact through public APIs or events, preventing accidental coupling of internal components.

2.3 Declaring Modules with @ApplicationModule

This section is critical because it marks each package as an independent module in Spring Modulith. These declarations tie directly to the logical structure defined in Section 2.2. The classes and services introduced in later sections (2.4 onward) will live inside these modules, and Modulith will enforce the boundaries automatically. For example, the TaskCommandService in Section 2.6 belongs to the Task Command Module declared here.

Task Command Module: This module encapsulates all write-side logic such as creating and completing tasks. It represents the command side of CQRS.

  
package com.example.task.command;  

import org.springframework.modulith.ApplicationModule;

@ApplicationModule(displayName = "Task Command Module")
class TaskCommandModule { }

Task Query Module: This module focuses on read-side logic such as fetching task details and listing tasks. It represents the query side of CQRS.

  
package com.example.task.query;  

import org.springframework.modulith.ApplicationModule;

@ApplicationModule(displayName = "Task Query Module")
class TaskQueryModule { }

Task API Module: This module exposes the REST controllers and acts as the entry point to interact with the system. It depends on the command and query modules but should not contain domain logic itself.

  
package com.example.task.api;  

import org.springframework.modulith.ApplicationModule;

@ApplicationModule(displayName = "Task API Module")
class TaskApiModule { }

With this structure, Spring Modulith enforces the following automatically:

  • Modules cannot reference internal classes of other modules directly.
  • Cross-module communication must happen via public services or domain events.
  • Boundaries are respected at compile-time, improving modularity and maintainability.

By following this pattern, developers can safely implement CQRS: the command module handles all writes, the query module handles all reads, and the API module exposes only the intended endpoints. Spring Modulith ensures that no module can inadvertently bypass these boundaries, effectively enforcing the architecture described.

2.4 Domain Model

Create a Task entity to represent tasks in the system.

package com.example.task.command;

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

@Entity
public class Task {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private boolean completed;

    // Getters and setters
}

2.5 Repository

Define a Spring Data JPA repository to access Task entities from the database.

package com.example.task.command;

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

@Repository
public interface TaskRepository extends JpaRepository<Task, Long> {}

2.6 Commands (Write Model)

Implement the command service to handle task creation and completion.

package com.example.task.command;

import org.springframework.stereotype.Service;

@Service
public class TaskCommandService {
    private final TaskRepository repository;

    public TaskCommandService(TaskRepository repository) {
        this.repository = repository;
    }

    public Task createTask(String title) {
        Task task = new Task();
        task.setTitle(title);
        task.setCompleted(false);
        return repository.save(task);
    }

    public Task completeTask(Long id) {
        Task task = repository.findById(id).orElseThrow(() -> new RuntimeException("Task not found"));
        task.setCompleted(true);
        return repository.save(task);
    }
}

2.7 Queries (Read Model)

Create a query service to retrieve task data without modifying it.

package com.example.task.query;

import org.springframework.stereotype.Service;
import java.util.List;

@Service
public class TaskQueryService {
    private final TaskRepository repository;

    public TaskQueryService(TaskRepository repository) {
        this.repository = repository;
    }

    public List<Task> getAllTasks() {
        return repository.findAll();
    }

    public Task getTaskById(Long id) {
        return repository.findById(id).orElseThrow(() -> new RuntimeException("Task not found"));
    }
}

2.8 Controller Layer

Expose REST endpoints for creating, completing, and retrieving tasks.

package com.example.task.api;

import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/tasks")
public class TaskController {
    private final TaskCommandService commandService;
    private final TaskQueryService queryService;

    public TaskController(TaskCommandService commandService, TaskQueryService queryService) {
        this.commandService = commandService;
        this.queryService = queryService;
    }

    @PostMapping
    public Task createTask(@RequestParam String title) {
        return commandService.createTask(title);
    }

    @PostMapping("/{id}/complete")
    public Task completeTask(@PathVariable Long id) {
        return commandService.completeTask(id);
    }

    @GetMapping
    public List<Task> getAllTasks() {
        return queryService.getAllTasks();
    }

    @GetMapping("/{id}")
    public Task getTask(@PathVariable Long id) {
        return queryService.getTaskById(id);
    }
}

2.9 Application Properties

Configure the H2 in-memory database and enable its console.

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true

2.10 Run the Application

Use the following REST requests to test the application:

# Create a task
POST http://localhost:8080/tasks?title=Write+CQRS+Article

# Complete a task
POST http://localhost:8080/tasks/1/complete

# Get all tasks
GET http://localhost:8080/tasks

# Get task by id
GET http://localhost:8080/tasks/1

Output:

[
    {
        "id": 1,
        "title": "Write CQRS Article",
        "completed": true
    }
]

This output shows that the task has been created and marked as completed, reflecting the separation of command and query responsibilities.

3. Conclusion

Using Spring Modulith to implement CQRS in a modular monolith allows developers to gain the benefits of scalable, maintainable, and well-structured applications without moving to a microservices architecture.

Yatin Batra

An experience full-stack engineer well versed with Core Java, Spring/Springboot, MVC, Security, AOP, Frontend (Angular & React), and cloud technologies (such as AWS, GCP, Jenkins, Docker, K8).
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button