In modern enterprise applications, separating read and write concerns often becomes essential as systems grow in complexity. The Command Query Responsibility Segregation (CQRS) pattern addresses this need. When implemented well, this separation can dramatically improve scalability, performance, and maintainability.

Spring Modulith, a relatively new addition to the Spring ecosystem, provides excellent support for implementing CQRS in a clean, modular way. In this example, we are going to implement a simple application that handles a Product Catalog.

Let’s dive right in!

A plumbeous view.

A canal in Amsterdam (2018)

1. Project Structure

We need two main modules, one for reads named query and the other for writes named command.

Each module will be connected to a different database. (Note: It’s not a prerequisite of CQRS using different databases for read and writes - we could have used a different schema or even just a different table for simplest cases)

The folder structure of our application will look like this:

gae.piaz.modulith.cqrs/  
β”œβ”€β”€ command/  
β”‚   β”œβ”€β”€ api/  
β”‚   β”œβ”€β”€ config/  
β”‚   β”œβ”€β”€ domain/  
β”‚   β”œβ”€β”€ events/
β”‚   β”‚   β”œβ”€β”€ ProductCreatedEvent.java
β”‚   β”‚   β”œβ”€β”€ ProductUpdatedEvent.java
β”‚   β”‚   β”œβ”€β”€ ProductReviewEvent.java
β”‚   β”‚   └── package-info.java
β”‚   β”œβ”€β”€ service/  
β”‚   └── package-info.java  
β”œβ”€β”€ query/  
β”‚   β”œβ”€β”€ api/
β”‚   β”œβ”€β”€ application/  
β”‚   β”œβ”€β”€ config/  
β”‚   β”œβ”€β”€ domain/  
β”‚   └── package-info.java  
β”œβ”€β”€ shared/  
β”‚   β”œβ”€β”€ config/  
β”‚   └── package-info.java  
└── CQRSApplication.java  
  • The query folder contains a CLOSED module with reads business logic
  • The command folder contains a CLOSED module with writes business logic
  • The shared folder contains an OPEN module, with shared configuration

We need to consider CLOSED modules as completely decoupled applications. This means that it should not be possible for the command module to access any class of the query module and vice-versa.

The only point of contact is the classes inside the command/event folder.

We can explicit the type of module, what to expose, and what not, using the package-info.java file in each folder:

@ApplicationModule(type = ApplicationModule.Type.CLOSED)
package gae.piaz.modulith.cqrs.query;  
import org.springframework.modulith.ApplicationModule;
@ApplicationModule(type = ApplicationModule.Type.CLOSED)
package gae.piaz.modulith.cqrs.command;  
import org.springframework.modulith.ApplicationModule;
@ApplicationModule(type = ApplicationModule.Type.OPEN)
package gae.piaz.modulith.cqrs.shared;  
import org.springframework.modulith.ApplicationModule;
@NamedInterface("Events")  
package gae.piaz.modulith.cqrs.command.events;  
import org.springframework.modulith.NamedInterface;

The structure is well summarized by the UML generated automatically by the Modulith Documenter:

CQRS Component as generated by Modulith

CQRS Components UML generated by Modulith

2. Basic Application Config

We’ll now set up the basic configurations and scaffolding of the Application.

2.1. Dependencies

To implement CQRS with Spring Modulith, we need the following dependencies:

implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.modulith:spring-modulith-starter-core")
implementation("org.springframework.modulith:spring-modulith-events-api")
implementation("org.springframework.modulith:spring-modulith-starter-jpa")

These dependencies provide the standard JPA support and Web Capabilities for Rest Endpoints, other than Core Spring Modulith functionality.

2.2. Enabling Modulith

The main SpringBoot Application is decorated with the @Modulithic annotation to enable the Modulith structuring conventions:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.modulith.core.ApplicationModules;

@SpringBootApplication
@Modulithic
public class CQRSApplication {

    public static void main(String[] args) {
        SpringApplication.run(CQRSApplication.class, args);
    }
}

2.3. Databases Configurations

The shared module configuration here sets up two separate data sources and entity managers, one for each module.

This separation allows us to optimize each database for its specific purpose. In the application.yaml we can define the connection terminals:

spring:
  application:
    name: modulith.cqrs
  datasource:
    command:
      jdbc-url: jdbc:h2:mem:commanddb
      username: sa
      password:
      driver-class-name: org.h2.Driver
    query:
      jdbc-url: jdbc:h2:mem:querydb
      username: sa
      password:
      driver-class-name: org.h2.Driver

We then need separate JPA repository configurations to ensure each module uses its own transaction manager:

package gae.piaz.modulith.cqrs.query.config;

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    basePackages = "gae.piaz.modulith.cqrs.query.domain",
    entityManagerFactoryRef = "queryEntityManagerFactory",
    transactionManagerRef = "queryTransactionManager"
)
public class QueryJpaConfig {
}
package gae.piaz.modulith.cqrs.command.config;

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    basePackages = {"gae.piaz.modulith.cqrs.command.domain", "org.springframework.modulith.events.jpa"},
    entityManagerFactoryRef = "entityManagerFactory",
    transactionManagerRef = "transactionManager"
)
public class CommandJpaConfig {
}

As you can see, in the CommandJpaConfig we have also added the org.springframework.modulith.events.jpa package. This is to tell Spring to use the same transaction manager of the command module when handling the persistence of EVENTS. More details about this in the next chapter.

2.4. Event Persistence and Retry Logic

Spring Modulith provides robust event persistence through its JPA module. It’s enough to include the spring-modulith-starter-jpa library, and a simple configuration:

modulith:
    events:
    completion-mode: archive
    republish-outstanding-events-on-restart: true

This configuration creates a table to store the events and retry them if the application crashes. The archive completion mode ensures that events are stored in the database after they are processed, providing an audit trail of all domain events.

When the application restarts, any events that weren’t successfully processed will be republished automatically, ensuring data consistency between the command and query sides.

In the following image, we can see a series of ProductCreatedEvent that are stored in the EVENT_PUBBLICATION_ARCHIVE table in the command database after being correctly received by the query listener:

H2 console showing Events persisted

H2 Console with EVENT_PUBBLICATION table

3. Implementation

Let’s now first define the Command and Query domains and the business logic of our application.

3.1. Domain Entities

Let’s now define domain entities for the Command module. The Product Command domain object is optimized for write operations, and includes a OneToMany relationship with the Reviews Object:

package gae.piaz.modulith.cqrs.command.domain;

@Entity  
@Table(name = "product")  
public class Product {  
  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
    private String name;  
    private String description;  
    private BigDecimal price;  
    private Integer stock;  
    private String category;  
  
    @OneToMany(mappedBy = "product")
    private List<Review> reviews = new ArrayList<>();  
}

@Entity  
@Table(name = "review")  
@Getter  
@Setter  
@NoArgsConstructor  
public class Review {  
  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
    private Integer vote;  
    private String comment;  
}

The Query domain object instead is optimized for Read operations, hence it contains denormalized data in order to make specific reads easier:

package gae.piaz.modulith.cqrs.command.query;

@Entity
@Table(name = "product_views")  
@Getter  
@Setter  
@NoArgsConstructor  
public class ProductView {  
  
    // Replicates Command fields  
    @Id  
    private Long id;  
    private String name;  
    private String description;  
    private BigDecimal price;  
    private Integer stock;  
    private String category;  
      
    // Specific to Query (Denormalized data)  
    @Column(name = "average_rating")  
    private Double averageRating = 0.0;  
    @Column(name = "review_count")  
    private Integer reviewCount = 0;  
}

So we have included the reviewCount and the averageRating because we want to fetch products based on the review votes that it has received.

3.2. Event Records

The Event records are simple Java records that we need to use to keep the ProductView updated:

package gae.piaz.modulith.cqrs.command.events;

public record ProductCreatedEvent(Long id, String name, String description,
	BigDecimal price, Integer stock, String category) { }

public record ProductUpdatedEvent(Long id, String name, String description,
	BigDecimal price, Integer stock, String category) { }

public record ProductReviewEvent(Long productId, Long reviewId, Integer vote,
	String comment) { }

We need to update the Query ProductView domain object in 3 cases: when the product is created, when it’s updated, and when we receive a new review. Let’s now see how to trigger those events from the Command module.

3.3. Command API and Service

The APIs of the command module are pretty straightforward, you can check them here.

It’s the ProductCommandService where the magic happens and the interesting bit. In here, we’ll rely on the Spring ApplicationEventPublisher bean to trigger the events:

package gae.piaz.modulith.cqrs.command.service;

@Service  
@Transactional  
@AllArgsConstructor  
public class ProductCommandService {  
  
    private final ProductRepository productRepository;  
    private final ReviewRepository reviewRepository;  
    private final ApplicationEventPublisher eventPublisher;  
  
    public Long createProduct(...) {  
        Product product = ...
        Product saved = productRepository.save(product);  
  
        eventPublisher.publishEvent(new ProductCreatedEvent(...));  
            
        return saved.getId();  
    }  
  
    public void updateProduct(...) {  
        Product product = ...
        productRepository.save(product);  
  
        eventPublisher.publishEvent(new ProductUpdatedEvent(...));  
    }  
  
    public Long addReview(...) {  
        Review review = ...
        Review saved = reviewRepository.save(review);  
  
        eventPublisher.publishEvent(new ProductReviewEvent(...);  

        return saved.getId();  
    }  
}

Note: It’s also possible to leverage the entity lifecycle to trigger the events using @PostUpdate, @PostInsert etc, as we have shown in this article: Event Notification Pattern with Spring Data

3.4. Query Event Handler

The ProductEventHandler will then receive the events and update the ProductView accordingly:

package gae.piaz.modulith.cqrs.query.service;

@Service  
@AllArgsConstructor  
public class ProductEventHandler {

    private final ProductViewRepository viewRepository;  
  
    @ApplicationModuleListener  
    public void on(ProductCreatedEvent event) {  
        ProductView view = ...
        viewRepository.save(view);  
    }
    
    @ApplicationModuleListener  
    public void on(ProductUpdatedEvent event) {  
        viewRepository.findById(event.id()).ifPresent(view -> {  
            ...
            viewRepository.save(view);  
        });  
    }
    
    @ApplicationModuleListener  
    public void on(ProductReviewEvent event) {  
        viewRepository.findById(event.productId()).ifPresent(view -> {  

            double currentTotal = view.getAverageRating() * view.getReviewCount();  
            int newCount = view.getReviewCount() + 1;  
            double newAverage = (currentTotal + event.vote()) / newCount;  
              
            view.setReviewCount(newCount);  
            view.setAverageRating(newAverage);  
              
            viewRepository.save(view);  
        });  
    }
}

The annotation @ApplicationModuleListener:

β€œβ€¦specifies a AsyncΒ SpringΒ TransactionalEventListener that runs in a transaction itself. Thus, the annotation serves as syntactic sugar for the generally recommend setup to integrate application modules via events. The setup makes sure that an original business transaction completes successfully and the integration asynchronously runs in a transaction itself to decouple the integration as much as possible from the original unit of work.”

In practice, this annotation includes: @Async,@Transactional, and @TransactionalEventListener. More on the Transactional behavior of this solution in the following chapters.

3.5. Query API and Service

The Query Controller and Service, include the trivial code to fetch Products ordered by the average rating:

@RestController  
@RequestMapping("/api/products/queries")  
@AllArgsConstructor  
public class ProductQueryController {  
    private final ProductQueryService queryService;
    ...
	@GetMapping("/by-rating")  
	public List<ProductView> getProductsByRating() {  
	    return queryService.findAllOrderByRating();  
	}
}

@Service  
@Transactional(readOnly = true)  
public class ProductQueryService {  
    private final ProductViewRepository viewRepository;  
    ...
    public List<ProductView> findAllOrderByRating() {  
        return viewRepository.findAllOrderByRatingDesc();  
    }  
}

@Repository  
public interface ProductViewRepository extends JpaRepository<ProductView, Long> {  
    ...
    @Query("SELECT pv FROM ProductView pv WHERE pv.reviewCount > 0 
            ORDER BY pv.averageRating DESC")  
    List<ProductView> findAllOrderByRatingDesc();  
  
}

The denormalized data makes us able to fetch products ordered by the average rating in a simple and convenient way, without doing any joins with the Review entity.

4. Testing

Spring Modulith provides also some good utilities to test event publishing and modules external communication. It’s enough to annotate the test with @ApplicationModuleTest, and include the PublishedEvents bean as a method argument:

@ApplicationModuleTest  
class CommandModuleIntegrationTest {  
  
    @Autowired  
    private ProductCommandService commandService;  
  
    @Autowired  
    private ProductRepository productRepository;  
  
    @Test  
    void whenAddingReview_eventIsSent(PublishedEvents events) {  
  
        // Given  
        Product product = ...
        product = productRepository.save(product);  
  
        // When  
        Long productId = commandService.addReview(product.getId(), 5, "Test Review");  
  
        // Then  
        PublishedEvents.TypedPublishedEvents<ProductReviewEvent> matchingMapped = events.ofType(ProductReviewEvent.class);  
        Iterator<ProductReviewEvent> eventIterator = matchingMapped.iterator();  
        ProductReviewEvent event = eventIterator.next();  
        Assertions.assertEquals(productId, event.productId());  
        ... 
    }  
}

5. Transactional Behavior

One of the key benefits of the CQRS pattern with Spring Modulith is the transaction isolation between modules. If an exception occurs in the query module while processing an event, it won’t affect the command transaction that triggered the event.

For example, if a ProductCreatedEvent is published after successfully saving a product in the command module, but the query module fails to process this event (perhaps due to a validation error or database issue), the original command transaction will still be committed. The product will be created in the command database, and Spring Modulith will handle the retry of the event processing.

This behavior is particularly valuable in high-throughput systems where you don’t want read-side issues to impact write operations.


6. Conclusion

In this article, we’ve seen how to implement CQRS with Spring Modulith.

This approach offers several advantages:

  1. Clear Separation of Concerns: The command and query responsibilities are cleanly separated into different modules. The Modulith test ensures that we stay within the boundaries
  2. Independent Scalability: Each module can be optimized and scaled according to its specific needs. The events can be externalized automagically to an event broker like Kafka
  3. Resilience: Issues in one module don’t directly impact the other, improving overall system stability
  4. Event Auditing and Retry: Modulith automatically includes the functionality of event persistence and automatic retry for failed events
  5. Easy Testing: cool utilities make the testing of events communication trivial (which is not always the case)
  6. Optimized Data Models: Each side can use data models optimized for its specific use cases
  7. Event-Driven Architecture: The use of events for communication promotes loose coupling and extensibility

As always, all the code is available for you to copy/paste/critique as you like:

https://github.com/GaetanoPiazzolla/cqrs-spring-modulith/

Thank you very much for reading!


If you found this article helpful, consider supporting my work. Every little bit helps!