posts

How to build a modular application with Spring Modulith

Jun 20, 2024
Catherine Edelveis
15.5

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!

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 and ShipmentStatusChangeEvent 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!

 

Subcribe to our newsletter

figure

Read the industry news, receive solutions to your problems, and find the ways to save money.

Further reading