Posts

What is Spring Modulith? Introduction to modular monoliths

Jun 20, 2024
Pasha Finkelshteyn
15.1

In our previous article, we compared monoliths to microservices and decided that a good intermediate solution would be to build a modular application. It is still a monolith with its advantages and drawbacks, but the code base is a lot more structured and prepared to be divided into microservices if the need arises. 

This article starts a series of hands-on tutorials on building robust modular applications with Spring Modulith, a Spring project aimed at helping the developers build, test, and maintain modular Spring Boot apps.

By the way, if you deploy Spring Boot services to the cloud, check out Alpaquita Containers tailor-made for Spring Boot: they can help you save up to 30% RAM!

What is Spring Modulith

Spring Modulith is a relatively new Spring project that equips the developers with a toolkit for building modular Spring Boot applications. Spring Modulith doesn’t build the module structure for you. Instead, it provides opinionated guidance on how to arrange the code into loosely coupled modules within a single project. This guidance takes the form of warnings when running the tests aimed to verify the correct module structure.

In addition, Spring Modulith provides support for module integration testing, observability, and asynchronous communication.

How to set up application module structure

Whether you are breaking up your legacy monolith or starting a modular application from scratch, you have to think the application structure through first.

Modular monoliths are quite close to microservices, meaning that modules should be as independent as possible from each other and communicate through the API exposed to other modules.

In a modular application, there are

  • A main package, which contains the class used to run the application,
  • Application modules, which are direct sub-packages of the main package.

Subpackages contained in application modules are considered internal and should not be referenced by code from other modules (although it is possible to open them up, which we’ll demonstrate below). In addition, there should not be any circular dependencies, meaning that if module A references module B, module b cannot reference module A.

That being said, let’s look at the possible module structure for a delivery service application we are going to work with in this series.

The cornerstone modules of the application include:

  • Shipment module: contains all information about shipment and handles shipment order processing,
  • Customer module: serves as a gateway, providing a user interface for requesting a quote and placing the order, 
  • Calculator module: calculates the price of a shipment based on the input data and business logic,
  • Admin module: performs administrative tasks and updates shipment delivery status.  

In addition, we have additional modules:

  • DTO module: contains DTOs for all entities. These DTOs can be safely transferred between modules without violating the Spring Modulith rules.
  • Global Exception module: includes a unified exception handler.

The resulting workflow is as follows:

  • A user sends a new shipment request through the customer module. The customer module asks the calculator API to validate the incoming price through the custom validator built within the customer module.
  • The calculator returns the correct price to the customer module.
  • If the price is correct, the customer module asks the shipment API to create a new order. If the calculated price doesn’t match the given price, the customer returns an error message to the.
  • The shipment module creates the order. The ApplicationEventPublisher used in this module creates the event upon order creation.
  • The customer module listens to the events from the shipment module. Upon receiving a notification about the order creation, it sends a message to the user through email or an SMS (in our case, it just logs the event for the sake of simplicity).
  • The admin module also has a web interface accessible to administrators. The administrator can update the shipment delivery status through the admin module by calling the shipment API.
  • The shipment module updates the order status and creates the event, which is listened to by the customer module.

The processes described above can be depicted on the sequence diagram as follows.

Sequence diagram for a modular delivery service app

Spring Modulith demo application: source code and key business logic

The code for the demo application we use is available on GitHub. The repository contains a fully-functional application that you can run on your machine.

We tried to preserve the balance between a barebone demo application and a full-blown enterprise project, so some business logic is simplified for the sake of clarity, and some features usually absent in demo applications were added to shed light on scenarios you may encounter in a real-world project.

The application is a Maven project based on Java 21 and Spring Boot 3.3, the latest version available at the moment of writing this article. Our Java runtime of choice was Liberica JDK recommended by Spring. You can download Liberica JDK 21 for your platform from the site or get it through IntelliJ IDEA.

Spring Modulith dependencies include modulith-starter-core, modulith-starter-jpa (JDBC is also available), modulith-actuator, modulith-docs, modulith-observability, modulith-started-test, and modulith-bom: they all are provided in bulk when you choose the Spring Modulith dependency in Spring Initializr.

We also added additional dependencies required for the project. The first is MapStruct, a library that allows for a convenient mapping of entities to DTO and vice versa. You can read more about integrating MapStruct into your Spring Boot project in this article. The second is libphonenumber, which is Google's library for parsing and validating phone numbers.

You can browse the complete pom.xml here.

The core structure of the project is depicted below. We have three classes in the main application package: one for running the application and two for creating application events, ShipmentCreateEvent and ShipmentStatusChangeEvent. We also have six modules: admin, calculator, customer, dto, globalexceptions, and shipment

src/main/java
└── dev
    └── cat
        └── modular.monolith
            ├── ShipmentCreateEvent.java
            ├── ShipmentStatusChangeEvent.java
            ├── BeelineApplication.java
            ├── admin
            ├── calculator
            ├── customer
            └── dto
            └── globalexceptions
            └── shipment

Shipment module

The foundation of your project is the Shipment entity residing in shipment/model subpackage:

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "shipment")
public class Shipment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long customerId;
    private double weight;
    private String addressFrom;
    private String addressTo;
    private DeliveryStatus deliveryStatus;

    private double price;
//equals and hashCode
}

The relation to customer is preserved in the form of customerId. This is not very JPA-ish, but we cannot have a nested entity from another module in this scenario. The addresses are saved as a String for simplicity's sake because preserving an address in the DB is an exciting, but different subject. DeliveryStatus, however, resides in the same package and can be safely referred to.

The DTOs, ShipmentRequest and ShipmentResponse, go to the dto module:

public record ShipmentResponse(Long id,
                               Long customerId,
                               double weight,
                               String addressFrom,
                               String addressTo,
                               double price,
                               String deliveryStatus) {
}

public record ShipmentRequest(@NotNull double weight,
                              @NotBlank String addressFrom,
                              @NotBlank String addressTo,
                              @NotNull double price) {
}

The mappers for Shipment and DeliveryStatus go to the shipment/mapper subpackage:

@Mapper(unmappedTargetPolicy = org.mapstruct.ReportingPolicy.IGNORE)
public interface StatusMapper {
    String mapToStatusName(DeliveryStatus status);
    DeliveryStatus mapToStatus(String status);
}

@Mapper(unmappedTargetPolicy = org.mapstruct.ReportingPolicy.IGNORE,
        uses = {StatusMapper.class})
public interface ShipmentMapper {
    ShipmentMapper INSTANCE = Mappers.getMapper(ShipmentMapper.class);

    Shipment mapToShipment(ShipmentRequest request);
    ShipmentResponse mapToShipmentResponse(Shipment shipment);
}

If you would like to know more about creating mappers with MapStruct, refer to this article.

The repository class extends JpaRepository and is quite basic:

public interface ShipmentRepository extends JpaRepository<Shipment, Long> {
    List<Shipment> findByCustomerId(Long customerId);
}

Finally, the ShipmentService is responsible for all CRUD operations with the shipment:

@Service
@RequiredArgsConstructor
public class ShipmentService {

    private final ShipmentRepository shipmentRepository;

    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);
        return ShipmentMapper.INSTANCE.mapToShipmentResponse(newShipment);

    }

    public List<ShipmentResponse> findOrdersByCustomerId(Long id) {
        List<Shipment> shipments = shipmentRepository.findByCustomerId(id);
        return shipments.stream().map(ShipmentMapper.INSTANCE::mapToShipmentResponse).toList();
    }

    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);
        } else throw new EntityNotFoundException("Couldn't find shipment with id " + id);
    }

}

Note that we will enhance this class in the next article.

Customer module

The Customer entity is also simple:

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "customer")
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String firstName;
    private String lastName;
    private String phoneNumber;
    private String email;
    private String country;

//equals and hashCode
}

There are also record classes for CustomerResponse and CustomerRequest in the dto module:

public record CustomerRequest(@NotBlank String firstName,
                              @NotBlank String lastName,
                              @NotBlank String country,
                              @NotBlank String phoneNumber,
                              @NotBlank @Email String email) {
}

public record CustomerResponse(Long id,
                               String firstName,
                               String lastName,
                               String country,
                               String phoneNumber,
                               String email) {
}

The corresponding mapper resides in the customer/mapper subpackage.

CustomerService is responsible for CRUD operations with the Customer entity (similarly to ShipmentService, we will enhance it later):

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomerService {

    private final CustomerRepository repository;

    public CustomerResponse saveCustomer(CustomerRequest request) {
        Customer customer = CustomerMapper.INSTANCE.mapToCustomer(request);
        return CustomerMapper.INSTANCE.mapToCustomerResponse(repository.save(customer));
    }
   
    public CustomerResponse findCustomerById(Long id) {
        Optional<Customer> customerOpt = repository.findById(id);
        if (customerOpt.isPresent()) {
            return CustomerMapper.INSTANCE.mapToCustomerResponse(customerOpt.get());
        } else throw new EntityNotFoundException("Couldn't find customer with id " + id);
    }
    
}

Also, as the customer module serves as a gateway, it has a CustomerController:

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
@Validated
public class CustomerController {

    private final CustomerService service;

    @PostMapping("/customers")
    public ResponseEntity<CustomerResponse> createCustomer(
            @NotNull
            @Valid
            @RequestBody
            @CorrectPhoneNumber
            @UniquePhoneNumber
            @UniqueEmail
            CustomerRequest request) {
        return ResponseEntity.ofNullable(service.saveCustomer(request));
    }

}

Right now, it’s almost empty, but don’t worry, we will implement the required method in the next article, the Spring Modulith style.

Note the peculiar @CorrectPhoneNumber, @UniquePhoneNumber,etc. annotations. These are our custom-made validators aimed at verifying application-specific conditions. In our case, the check whether the provided phone number is valid and absent in the database; the email is also checked on uniqueness. Custom validators in Spring Boot are powerful tools that deserve a separate article, which is already in the makings!

Calculator module

As for the calculator, we have a DTO in the dto package:

public record CalculatorRequest(@NotBlank double weight,
                                @NotBlank String addressFrom,
                                @NotBlank String addressTo) {
}

And a single @Service class for calculating the shipment price:

@Service
public class PriceCalculator {
    
    public double calculatePrice(CalculatorRequest request) {

        //that's pure placeholder for the simplicity sake :)
        return 10.0 * request.weight();

    }
}

Note that we didn’t implement any heavyweight logic for calculating the shipment price as this is not the goal of this article.

Admin module

The admin module contains a single @RestController class for the administrator to work with the application:

@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
@Validated
public class AdminController {
}

What’s next: inter-module communication and verification with Spring Modulith

What we explained here is a carcass that has nothing to do with Spring Modulith yet. It could be another standard monolithic application, albeit with a different structure. 

However, Spring Modulith will fill our plain project with bright colors! Follow this link to know how to

  • Verify application module structure,
  • Establish communication between modules via external APIs,
  • Set up asynchronous communication,
  • Document application modules.

See you there!

Subcribe to our newsletter

figure

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

Further reading