In the previous article, we discussed how to prepare for building a modular monolith with Spring Modulith by setting up the right application module structure. We also summarized the key business logic of a demo Spring Modulith application we use in this tutorial series and set up a module verification test.
The application code is available on Github. A quick reminder: this is a basic, yet fully-functioning delivery service application based on Java 21 and Spring boot 3.3 and integrating Spring Modulith.
In this tutorial, we will:
- Establish synchronous communication between modules via APIs,
- Set up asynchronous communication supported out-of-the-box by Spring Modulith,
- Discuss how to use internal module subpackages without violating Spring Modulith constraints,
- Generate documentation for application modules.
Sounds like a Spring Modulith crash course? In that case, buckle up, we’re setting off!
Table of Contents
Application module structure: summary
For those of you who have just joined us: we are building a modular monolith with the following structure:
src/main/java
└── dev
└── cat
└── modular.monolith
├── ShipmentCreateEvent.java
├── ShipmentStatusChangeEvent.java
├── BeelineApplication.java
├── admin
├── calculator
├── customer
├── dto
├── globalexceptions
└── shipment
Here, we have four core modules:
- shipment for processing shipment data,
- customer for providing a gateway and handling customer data,
- calculator for calculating the shipment price,
- admin for updating the order status.
We also have two auxiliary modules for handling exceptions (globalexceptions) and DTOs (dto).
In addition, main application package contains three classes:
BeelineApplication
is the main class annotated with@SpringBootApplication
,ShipmentCreateEvent
andShipmentStatusChangeEvent
are record classes responsible for creating application events (we will discuss them in detail below).
Set up module verification with Spring Modulith
If you haven’t studied or experimented with Spring Modulith before, you may be wondering: how exactly does it help with building a modular application?
Spring Modulith creates an application module model and analyzes the dependencies between them. ApplicationModules
can be pointed to the main application class to derive a module structure. This structure can be used in a JUnit test to analyze the interconnection between modules:
public class SpringModulithTests {
ApplicationModules modules = ApplicationModules.of(BeelineApplication.class);
@Test
void shouldBeCompliant() {
modules.verify();
}
If any of the Spring Modulith rules are violated, the test will fail, and we will see a detailed error message, which will allow us to set the dependencies straight.
Set up external APIs for modules
The modules that we described in the previous article exist in a vacuum. But now, the fun part begins: we need to establish communication between them.
In a traditional monolith, we could simply inject a necessary bean wherever we need in our business logic. Even a modular application without Spring Modulith would allow for that, especially considering that all classes in module subpackages are public. However, this is what we aim to get away from. We want to preserve module independence, leaving only one communication channel in the form of API.
For instance, let’s try to inject the PriceCalculator
bean directly into CustomerController
like that:
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
@Validated
public class CustomerController {
private final PriceCalculator calculator;
@PostMapping("/quote")
public ResponseEntity<Double> calculatePrice(
@NotNull
@Valid
@RequestBody
CalculatorRequest request) {
Double price = calculator.calculatePrice(request);
return new ResponseEntity<>(price, HttpStatus.OK);
}
}
Now, if we run the module verification test, we will see a nasty error message similar to the one below:
org.springframework.modulith.core.Violations: - Module 'customer' depends on non-exposed type dev.cat.modular.monolith.calculator.service.PriceCalculator within module 'calculator'!
CustomerController declares constructor CustomerController(PriceCalculator) in (CustomerController.java:0)
- Module 'customer' depends on non-exposed type dev.cat.modular.monolith.calculator.service.PriceCalculator within module 'calculator'!
Method <dev.cat.modular.monolith.customer.web.CustomerController.calculatePrice(dev.cat.modular.monolith.dto.calculator.CalculatorRequest)> calls method <dev.cat.modular.monolith.calculator.service.PriceCalculator.calculatePrice(dev.cat.modular.monolith.dto.calculator.CalculatorRequest)> in (CustomerController.java:68)
The test fails because we violated the Spring Modulith rule and injected the non-exposed dependency.
How do we establish the communication between modules comme il faut?
We don’t want to directly expose the @Service
classes by placing them into the base module package, so the best solution is to create an external API interface that will serve as a bridge between modules’ inner kitchen and other stakeholders.
Let’s start with the calculator module. We have a CalculatorAPI
interface in the base module package with only one method:
public interface CalculatorAPI {
double calculatePrice(CalculatorRequest request);
}
The PriceCalculator
class needs to implement this interface:
@Service
public class PriceCalculator implements CalculatorAPI {
@Override
public double calculatePrice(CalculatorRequest request) {
//that's pure placeholder for the simplicity sake :)
return 10.0 * request.weight();
}
}
Now, instead of referencing the PriceCalculator
service class, our CustomerController
will depend on CalculatorAPI
, which is open to other modules due to its location in the base package:
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
@Validated
public class CustomerController {
private final CalculatorAPI calculatorAPI;
private final CustomerService service;
@PostMapping("/quote")
public ResponseEntity<Double> calculatePrice(
@NotNull
@Valid
@RequestBody
CalculatorRequest request) {
Double price = calculatorAPI.calculatePrice(request);
return new ResponseEntity<>(price, HttpStatus.OK);
}
}
We can also inject CalculatorAPI
into our custom price validator, which resides in the customer/validation/price package:
@Constraint(validatedBy = PriceConstraintValidator.class)
@Target( { ElementType.PARAMETER, ElementType.FIELD } )
@Retention(RetentionPolicy.RUNTIME)
public @interface CorrectShipmentPrice {
String message() default "Shipment price doesn't match the requested price.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@RequiredArgsConstructor
public class PriceConstraintValidator implements ConstraintValidator<CorrectShipmentPrice, ShipmentRequest> {
private final CalculatorAPI calculatorAPI;
@Override
public boolean isValid(ShipmentRequest request, ConstraintValidatorContext constraintValidatorContext) {
double price;
if (request == null) return true;
price = calculatorAPI.calculatePrice(
new CalculatorRequest(request.weight(), request.addressFrom(), request.addressTo()));
return request.price() == price;
}
}
The interfaces for other modules are created in a similar fashion.
The ShipmentAPI
interface:
public interface ShipmentAPI {
ShipmentResponse createOrder(ShipmentRequest request, Long customerId);
void updateShipmentStatus(Long id, String status);
List<ShipmentResponse> findOrdersByCustomerId(Long id);
}
The CustomerAPI
interface:
public interface CustomerAPI {
CustomerResponse findCustomerById(Long id);
}
ShipmentService
and CustomerService
should implement ShipmentAPI
and CustomerAPI
, respectively. The admin module won’t expose an API because in our case, no module depends on it.
We can now inject ShipmentAPI
into CustomerController
and write a method for dealing with shipments:
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
@Validated
public class CustomerController {
private final ShipmentAPI shipmentAPI;
private final CalculatorAPI calculatorAPI;
private final CustomerService service;
@PostMapping("/customers")
public ResponseEntity<CustomerResponse> createCustomer(
@NotNull
@Valid
@RequestBody
@CorrectPhoneNumber
@UniquePhoneNumber
@UniqueEmail
CustomerRequest request) {
return ResponseEntity.ofNullable(service.saveCustomer(request));
}
@GetMapping("/customers/{id}/shipments")
public List<ShipmentResponse> getAllOrdersForCustomer(
@NotNull
@PathVariable
@ExistingCustomer
long id) {
return shipmentAPI.findOrdersByCustomerId(id);
}
@PostMapping("/quote")
public ResponseEntity<Double> calculatePrice(
@NotNull
@Valid
@RequestBody
CalculatorRequest request) {
Double price = calculatorAPI.calculatePrice(request);
return new ResponseEntity<>(price, HttpStatus.OK);
}
@PostMapping("/customers/{id}/shipment")
public ResponseEntity<ShipmentResponse> createShipmentOrder(
@NotNull
@Valid
@RequestBody
@CorrectShipmentPrice
@UniqueAddress
ShipmentRequest request,
@NotNull
@PathVariable
@ExistingCustomer
long id
) {
return ResponseEntity.ofNullable(shipmentAPI.createOrder(request, id));
}
}
Let’s also inject CustomerAPI
and ShipmentAPI
into AdminController
for the administrator to be able to change the shipment status and browse data on customers:
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
@Validated
public class AdminController {
private final ShipmentAPI shipmentAPI;
private final CustomerAPI customerAPI;
@PostMapping("/shipments/{id}")
@ResponseStatus(HttpStatus.OK)
public void updateShipmentStatus(
@NotNull
@PathVariable
long id,
@NotNull
@RequestBody
String status) {
shipmentAPI.updateShipmentStatus(id, status);
}
@GetMapping("/customers/{id}")
@ResponseStatus(HttpStatus.OK)
public ResponseEntity<CustomerResponse> getCustomerById(
@NotNull
@PathVariable
long id) {
return ResponseEntity.ofNullable(customerAPI.findCustomerById(id));
}
}
Expose necessary packages with Named Interfaces
So far, we managed to establish communication between modules via API without opening up private module packages. But remember that we have several DTOs in the dto module. We could place them into the base module package, so that other modules can freely refer to them, but this is not a very graceful solution. It would be better to keep the DTOs organized in subpackages.
But if you simply create the calculator, shipment, and customer subpackages in the dto module and place the corresponding DTOs there, the verification tests should fail with the following message:
org.springframework.modulith.core.Violations: - Module 'calculator' depends on non-exposed type dev.cat.modular.monolith.dto.calculator.CalculatorRequest within module 'dto'!
CalculatorRequest declares parameter CalculatorRequest.calculatePrice(CalculatorRequest) in (CalculatorAPI.java:0)
- Module 'calculator' depends on non-exposed type dev.cat.modular.monolith.dto.calculator.CalculatorRequest within module 'dto'!
Method <dev.cat.modular.monolith.calculator.CalculatorAPI.calculatePrice(dev.cat.modular.monolith.dto.calculator.CalculatorRequest)> has parameter of type <dev.cat.modular.monolith.dto.calculator.CalculatorRequest> in (CalculatorAPI.java:0)
- Module 'shipment' depends on non-exposed type dev.cat.modular.monolith.dto.shipment.ShipmentResponse within module 'dto'!
ShipmentResponse declares return type ShipmentResponse.createOrder(ShipmentRequest, Long) in (ShipmentAPI.java:0)
...
How do we solve this problem?
We can make private module subpackages accessible to other modules with the help of the @NamedInterface
annotation. For that purpose, we placed package-info.java files in each of the dto subpackages:
dto
└── calculator
├── CalculatorRequest.java
└── package-info.java
└── customer
├── CustomerRequest.java
├── CustomerResponse.java
└── package-info.java
└── shipment
├── package-info.java
├── ShipmentRequest.java
└── ShipmentResponse.java
These files are annotated accordingly.
package-info.java in dto/calculator:
@org.springframework.modulith.NamedInterface("dto-calculator")
package dev.cat.modular.monolith.dto.calculator;
package-info.java in dto/customer:
@org.springframework.modulith.NamedInterface("dto-customer")
package dev.cat.modular.monolith.dto.customer;
package-info.java in dto/shipment:
@org.springframework.modulith.NamedInterface("dto-shipment")
package dev.cat.modular.monolith.dto.shipment;
Now, other modules are allowed to refer to these named interfaces, and the Modulith verification tests should pass without issues.
Configure asynchronous modules communication
Up until now, the communication between our modules was strictly synchronous. However, we can also implement asynchronous communication with the help of the ApplicationEventPublisher
interface provided by the Spring Framework.
As a result, certain actors will be able to emit events upon certain actions, and listeners from other modules (one or however many) can process these events.
Our application has two types of events: ShipmentCreateEvent
, created when a new shipment is saved to the database, and ShipmentStatusChangeEvent
for when the status of a shipment changes.
The events themselves are record classes located in the main application package. They contain nothing but an id:
public record ShipmentCreateEvent(Long orderId) {
}
public record ShipmentStatusChangeEvent(Long orderId) {
}
As these events are emitted upon some changes performed to the shipment, they should be generated within the shipment module, in the ShipmentService
class:
@Service
@RequiredArgsConstructor
public class ShipmentService implements ShipmentAPI {
private final ShipmentRepository shipmentRepository;
private final ApplicationEventPublisher events;
@Override
@Transactional
public ShipmentResponse createOrder(ShipmentRequest request, Long customerId) {
Shipment shipment = ShipmentMapper.INSTANCE.mapToShipment(request);
shipment.setCustomerId(customerId);
shipment.setDeliveryStatus(DeliveryStatus.NEW);
Shipment newShipment = shipmentRepository.save(shipment);
events.publishEvent(new ShipmentCreateEvent(newShipment.getId()));
return ShipmentMapper.INSTANCE.mapToShipmentResponse(newShipment);
}
@Override
public List<ShipmentResponse> findOrdersByCustomerId(Long id) {
List<Shipment> shipments = shipmentRepository.findByCustomerId(id);
return shipments.stream().map(ShipmentMapper.INSTANCE::mapToShipmentResponse).toList();
}
@Override
@Transactional
public void updateShipmentStatus(Long id, String status) {
Optional<Shipment> shipmentOpt = shipmentRepository.findById(id);
if (shipmentOpt.isPresent()) {
Shipment shipment = shipmentOpt.get();
shipment.setDeliveryStatus(DeliveryStatus.valueOf(status));
shipmentRepository.save(shipment);
events.publishEvent(new ShipmentStatusChangeEvent(id));
} else throw new EntityNotFoundException("Couldn't find shipment with id " + id);
}
}
Here, we injected the ApplicationEventPublisher
bean, which is responsible for publishing the events. We also updated the createOrder
and updateShipment
methods, adding the event publication functionality. The newly created events contain the shipment id. Note that we avoid interaction with other application modules completely: the ApplicationEventPublisher
doesn't care who or how many event listeners there are, thus enabling efficient module decoupling.
According to the business logic, the customer module listens to the shipment-related events and forwards the information to the user. This task is laid upon CustomerService
:
@ApplicationModuleListener
void onUpdateShipmentStatusEvent(ShipmentStatusChangeEvent event) {
log.info("Changed status of shipment: {}", event.orderId());
}
@ApplicationModuleListener
void onShipmentCreateEvent(ShipmentCreateEvent event) {
log.info("Created shipment: {}", event.orderId());
}
Note the @ApplicationModuleListener
annotation provided by Spring Modulith: it serves as a shortcut to integrating application modules via events.
Document application modules
Spring Modulith provides a convenient way to generate documentation for the modular app in the form of C4 or UML diagrams or Application Module Canvas.
We can use the modules generated earlier with ApplicationModules
and pass them into the Documenter
class:
@Test
void writeDocumentationSnippets() {
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
}
Here, we create the UML diagram of the whole module system and each module separately. The diagrams will be generated in the target/spring-modulith-docs directory. This is how the general diagram look like as a result:
Module structure diagram
Coming up: testing application modules
Excellent! We hope that our explanations will help you to build a robust modular application using the power of Spring Modulith.
But Spring Modulith has a lot more in stock: it exposes application structure as actuator endpoints, provides observability support, and supports module integration testing.
So if you would like to read more articles dedicated to Spring Modulith or other mighty Spring capabilities, subscribe to our newsletter and be the first to know about them!