adesso Blog

Microservices have established themselves as the dominant trend in software architecture in recent years. They are often touted as a universal solution for complex applications, enabling large systems to be broken down into independent, clearly defined units. Microservices undoubtedly offer advantages, but are not always useful or necessary.

Before fully committing to this architectural approach, it is worth considering alternative approaches. In particular, a modularised monolith can serve as a starting point if the boundaries and relationships between microservices are fuzzy and likely to change. In this blog post, I will look at modularisation concepts in the Java ecosystem, focusing in particular on Spring Modulith.

Modularisation

The division and encapsulation of technically complex contexts primarily pursues the goal of creating systems that are particularly easy to extend and modify. In order to achieve this goal, developers should pay particular attention to the loosest possible coupling and high cohesion of the modules and their relationships. This means that dependencies must be consciously balanced and, among other things, there should be no cycles between the modules.

Cohesion and loose coupling

Cohesion refers to the principle that things that belong together should also be structured coherently. A module should have a clearly defined task or responsibility and contain all the elements required to fulfil this task. High cohesion leads to modules that are easy to understand and maintain.

Loose coupling means that changes in one module should entail as few or no changes as possible in other modules. This makes it easier to maintain and expand the system, as the modules can be developed, tested and updated independently of each other.

Cognitive complexity

A key element of modularisation is the reduction of cognitive complexity: content should be meaningfully encapsulated within a module without details leaking out, thereby limiting the amount of information that needs to be grasped and understood at first glance.

Bounded contexts

A tried and tested method for defining modules is the bounded context approach from domain-driven design (DDD). A bounded context is a delimited area within a domain in which a standardised and consistent ubiquitous language - i.e. a common language that is understood and used by developers and subject matter experts alike - is used; within a bounded context, models, entities and services can be clearly defined and separated from one another.

By using bounded contexts, developers can ensure that each module encapsulates a specific business logic and can be developed and maintained independently of other modules. This leads to a clear delineation of responsibilities and promotes both cohesion within the modules and loose coupling between them.

Microservices

Microservices can be chosen as an approach to implementing modules and offer numerous advantages, particularly due to the hard physical separation. This separation enables better isolation in terms of technology selection and error handling. The teams can thus develop separately and independently of each other.

However, this approach also has considerable disadvantages. These include increased infrastructure costs for deployment, monitoring, tracing, logging and, in event-driven architectures, the need for an event infrastructure. The integration between services and the management of data storage are also more complex. In addition, the flexibility in shifting boundaries between services is limited.

Modular monolith

A modularised monolith offers an attractive alternative to the classic monolith or microservices architecture. Even in a monolith, it is possible to achieve a clear separation of components and at the same time enable a high initial development speed without neglecting clean structuring.

There are several ways to achieve such modularisation in the Java ecosystem, including:

  • Packages: Structural separation of classes and interfaces within a project.
  • ArchUnit: Validating and ensuring compliance with architecture rules.
  • Java Module System: Introduction of modules at language level since Java 9 in order to explicitly define dependencies and access rights.
  • Multi-JAR projects: Splitting the application into multiple JAR files to achieve physical separation of modules.

Spring Modulith

Spring Modulith is an ‘opinionated’ Java framework, i.e. it provides developers with a specific procedure, conventions and standards that promote the clear structuring and modularisation of applications.

It enables structural validation of the application, supports event-based communication and offers integrated testability of individual modules.

Installation

To use Spring Modulith in a Spring Boot Gradle project, the following dependencies are required:

	
	dependencyManagement {
		    imports {
		        mavenBom 'org.springframework.modulith:spring-modulith-bom:1.2.2'
		    }
		}
		dependencies {
		    implementation 'org.springframework.modulith:spring-modulith-starter-core'
		    testImplementation 'org.springframework.modulith:spring-modulith-test'
		}
	

Verification through tests

The following code examples refer to Lombok and are incomplete for reasons of readability - Spring Modulith offers special test support to check the module structure and compliance with the rules:

	
	@SpringBootTest
		class ModulithTest {
		    @Test
		    void verifyModularStructure() {
		        Modulith modulith = Modulith.of(Application.class);
		        modulith.verify();
		    }
		}
	

If the Modulith rules are violated, the test fails and provides detailed information on the type of violation.

Structural validation

In Spring Module, packages are considered modules at the level of the class annotated with @SpringBootApplication. These modules can only communicate with each other via the first level of the respective module, which is the module's public API.

To demonstrate how Spring Modulith works, let's look at a scenario with the two modules Order and Product with the following package structure:

	
	de.adesso.monolith
		├── order
		|   |
		|   ├── internal
		|   |   └── OrderRepository.java
		│   |── OrderService.java
		|   |── Order.java
		|   └── LineItem.java
		├── product
		│   ├── Product.java
		│   ├── ProductService.java
		│   └── internal
		|       └── ProductRepository.java
		└── App.java
	

Let us now imagine that the product module should be able to place orders. A resourceful developer could now come up with the idea of using the OrderRepository in the ProductService.

	
	package de.adesso.monolith.product;
		@Service
		@RequiredArgsConstructor
		public class ProductService {
		    private final ProductRepository productRepository;
		    private final OrderRepository orderRepository;
		    public List<Product> getProducts() {
		        return productRepository.findAll();
		    }
		    public void createProduct(Product product) {
		        productRepository.save(product);
		    }
		    public void orderProductWithoutCheckout(Product product) {
		        orderRepository.save(               
		            new Order("Customer-1", List.of(
		                new .LineItem("SomeProduct", BigDecimal.valueOf(10), 1))));
		    }
		}
	
  • Direct access to package second module level.

If we now run the test for module verification, we receive the following test error:

	
	- Module 'product' depends on non-exposed type de.adesso.monolith.order.internal.OrderRepository within module 'order'!
		ProductService declares constructor ProductService(ProductRepository, OrderRepository) in (ProductService.java:0)
	

Access to classes below the first level of the module is prohibited, even if they are not package-private. Correctly, an order should be placed via the OrderService, which is also on the first level of the Order module:

	
	 public void orderProductWithoutCheckout(Product product) {
		    orderService.createOrder("Customer-1", List.of(
		            new LineItem("SomeProduct", BigDecimal.valueOf(10), 1)));
		 }
	

Cycle error

In the next use case, the order module should inform the product module that the stock of ordered products needs to be reduced.

	
	package de.adesso.monolith.order;
		@Service
		@RequiredArgsConstructor
		public class OrderService {
		    private final OrderRepository orderRepository;
		    private final LineItemMapper lineItemMapper;
		    private final ProductService productService;
		    @Transactional
		    public void createOrder(String customerId, List<LineItem> lineItems) {
		        var order = new Order(customerId, lineItems);
		        orderRepository.save(order);
		        lineItems.forEach(l ->
		            productService.decreaseStockCount(new Product.ProductId(l.productId()),
		                l.amount()));
		    }
		}
	

If we now run the module verification again, we receive the following error message:

	
	- Cycle detected: Slice order ->
		                Slice product ->
		                Slice order
	

A circular dependency has arisen between the modules. This can have a significant impact on the modifiability of the application and is therefore not permitted.

Invert dependencies - application events

The circular dependency problem can be easily solved in this case. Instead of a direct dependency between the modules, an alternative approach can be chosen. Since Spring 1.0, Spring has offered the option of outputting events via an in-process event bus with the ApplicationEventPublisher.

	
	private final ApplicationEventPublisher applicationEventPublisher;
		@Transactional
		public void createOrder(String customerId, List<LineItemDTO> lineItems) {
		    var order = new Order(customerId, lineItemMapper.map(lineItems));
		    var storedOrder = orderRepository.save(order);
		    applicationEventPublisher.publishEvent(new OrderCreated(storedOrder.orderId(), lineItems));
		}
	

Spring Modulith docks onto this mechanism and extends it with persistence and asynchrony using the ApplicationModuleListener annotation. Events can thus be consumed and processed in the Product module in a minimally invasive way. The functional logic for reducing the inventory thus moves to the product:

	
	package de.adesso.monolith.product.internal;
		@Slf4j
		@Component
		@RequiredArgsConstructor
		class OrderListener {
		    private final ProductService productService;
		    @ApplicationModuleListener
		    public void handle(OrderCreated orderCreated) {
		        log.info("received order event {}", orderCreated);
		        orderCreated.lineItems().forEach(l -> productService.decreaseStockCount(
		                new Product.ProductId(l.productId()), l.amount()));
		    }
		}
	

In order to be able to use the persistent event mechanism in Spring Modulith, an additional Gradle dependency must be added. Spring Modulith offers various starter POMs for this purpose, which can be selected depending on the persistence technology used. There is a choice of solutions for JPA, JDBC, MongoDB and neo4j, depending on which database technology is used in the application.

Externalised events

Spring Modulith offers an elegant solution for sending events to external messaging services. Currently Kafka, AMQP Broker and JMS are supported. The @Externalised annotation is the key to this functionality:

	
	@Externalized("order-created::#{#this.orderId()}")
		public record OrderCreated(int orderId, List<LineItemDTO> lineItems) {
		}
	

The value of the annotation is made up of the routing target and the message key, whereby the latter depends on the target system. By default, the event is serialised to JSON using ObjectMapper.

For advanced configurations, a programmatic EventExternalizationConfiguration can be implemented to adapt the serialisation process or add additional metadata.

Integration tests

A key aspect of modularisation in software development is the ability to perform independent tests for individual modules. Spring Modulith supports this concept with the annotation @ApplicationModuleTest, which makes it possible to implement integration tests for individual modules.

These integration tests are similar in their functionality to the tests annotated with @SpringBootTest, with one decisive difference: the Spring ApplicationContext is limited to the exact scope of the module to be tested.

	
	package de.adesso.monolith.product; 
		@ApplicationModuleTest
		class ProductIntegrationTest {
		    @MockBean 
		    OrderService orderService;
		    @Autowired
		    ProductService productService;
		    Copy@Test
		    void testOrderProductWithoutCheckout() {
		        var product = mock(Product.class);
		        productService.orderProductWithoutCheckout(product);
		        verify(orderService).createOrder(any(), any());
		    }
		}
	
  • The package defines the module context for the test.
  • @MockBean enables the mocking of dependencies outside the tested module.

Test event dispatch

To check the functionality of event-based communication, Spring Modulith offers the option of carrying out scenario-based tests. In the following example, the dispatch of the OrderCreated event is checked:

	
	@Test
		void testOrderEmission(Scenario scenario) {
		    scenario
		            .stimulate(() -> orderService.createOrder("customerId",                   
		                List.of(mock(LineItem.class))))
		            .andWaitForEventOfType(OrderCreated.class)                                
		            .matching(orderCreated -> orderCreated.customerId().equals("customerId")) 
		            .toArrive();
		}
	
  • Triggering the action to be tested: An order is created here with a Mock-LineItem.
  • Waiting for an event of type OrderCreated.
  • Check whether the received event has the expected properties, in this case the correct customerId.

Test event reception

Scenario-based tests in Spring Modulith also offer the possibility to check the state that changes in response to an event. This makes it possible to test the processing of events and their effects on the module to be tested.

	
	@Test
		void testStockReduction(Scenario scenario) {
		    var productId = new Product.ProductId("productId");
		    productService.createProduct(new Product(productId, "Some Product",
		        BigDecimal.valueOf(10), 2, null));
		    scenario.publish(new OrderCreated("customerId", 1,                
		        List.of(new LineItemDTO("productId", "Some Product",
		            BigDecimal.valueOf(10), 2))))
		        .andWaitForStateChange(
		            () -> productService.getProduct(productId).stockCount(),  
		            newStockCount -> newStockCount != 2)                      
		        .andVerify(stockCount -> assertEquals(0, stockCount));        
		}
	
  • Create a test product with an initial stock of 2.
  • Publish an OrderCreated event that simulates the purchase of 2 units of the product.
  • Waiting for a change in the product stock. Alternatively, you can also wait for an event here, see andWaitForEventOfType in the previous example.
  • Check whether the new stock is no longer 2 (i.e. has been changed).
  • Verify that the new stock is 0, which corresponds to the expected reduction.
Generation of documentation

Spring Modulith also offers the option of automatically generating documentation via the Documenter abstraction. This is based on the application module model generated by ApplicationModules and can be integrated into the Asciidoc development documentation.

Two types of documentation snippets are supported: C4 and UML component diagrams, which show the relationships between modules, and an Application Module Canvas, which provides a tabular overview of each module and its key elements (such as Spring beans, aggregate roots, published and subscribed events and configuration properties).

	
	@Test
		void documentation() {
		    var modules = ApplicationModules.of(App.class).verify();
		    new Documenter(modules)
		            .writeModulesAsPlantUml()           
		            .writeIndividualModulesAsPlantUml() 
		            .writeModuleCanvases();             
		}
	
  • Creates a PlantUML diagram that shows all modules and their relationships to each other.
  • Generates separate PlantUML diagrams for each individual module.
  • Creates tabular ‘Module Canvases’ with detailed information about each module.

Generated module overview using the current example

Conclusion

The key to adaptable and maintainable architectures lies in effective modularisation and the careful design of the relationships between modules - not necessarily in their physical isolation. Our goal should be to create understandable and controllable applications that can flexibly adapt to changing requirements.

Spring Modulith proves to be a valuable addition to Spring Boot projects and is not only suitable as an initial proof of concept for projects with complex functionalities. It enables clear structuring through loosely coupled and independently testable modules, which increases both maintainability and comprehensibility.

The ability to externalise events means that parts of the modular monolith can be transferred to microservices with manageable effort if required. In addition, the conventions and rules support the development of long-term maintainable applications by promoting and enforcing a clean architecture from the outset.

Would you like to find out more about exciting topics from the world of adesso? Then take a look at our previous blog posts.

Picture Chrisian Ortiz

Author Chrisian Ortiz

Chrisian Ortiz is a Senior Software Architect at adesso and works in the Automotive & Transportation business line as an architect and developer in the cloud environment.


Our blog posts at a glance

Our tech blog invites you to dive deep into the exciting dimensions of technology. Here we offer you insights not only into our vision and expertise, but also into the latest trends, developments and ideas shaping the tech world.

Our blog is your platform for inspiring stories, informative articles and practical insights. Whether you are a tech lover, an entrepreneur looking for innovative solutions or just curious - we have something for everyone.

To the blog posts

Save this page. Remove this page.