Implementing CQRS with Spring Modulith
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 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 withreads
business logic - The
command
folder contains a CLOSED module withwrites
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 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 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:
- 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
- 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
- Resilience: Issues in one module donβt directly impact the other, improving overall system stability
- Event Auditing and Retry: Modulith automatically includes the functionality of event persistence and automatic retry for failed events
- Easy Testing: cool utilities make the testing of events communication trivial (which is not always the case)
- Optimized Data Models: Each side can use data models optimized for its specific use cases
- 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!