Skip to main content

In today’s world of software development, microservices have revolutionized how applications are built and scaled. However, managing and maintaining a complex ecosystem of independent services often leads to issues such as increased complexity, difficulty in maintaining clear code boundaries, and challenges in integration.

Spring Modulith offers a structured yet simple approach to building a modular monolith, reducing complexity without the overhead of microservices. It helps maintain clear boundaries, improves testability, and enables smooth scaling, allowing you to focus on delivery while keeping future growth in mind.

spring modulith

The Challenge with Microservices and Monoliths

When designing an application, adopting the microservice architecture might seems like the ideal solution. It promotes dividing an application into smaller, independent services that can be deployed and scaled independently. However, the challenges soon become apparent:

  • Complexity in Inter-Service Communication: Services need to interact with each other, often leading to intricate dependencies and communication overhead.
  • Deployment and Versioning: Each microservice has its own deployment lifecycle, which can lead to issues in keeping everything in sync.
  • Inconsistent Design: Microservices often become too granular, leading to repeated logic and code duplication across services.
  • Testing and Debugging: Testing microservices in isolation becomes difficult, and end-to-end integration tests across multiple services can be time-consuming.

On the flip side, traditional monolithic architectures offer simplicity, but as the application grows, they suffer from scalability and maintainability issues. It becomes difficult to break down features or services into isolated components, leading to a tightly coupled system that is hard to modify without affecting the entire application, often referred to as the big ball of mud.

On the flip side, traditional monolithic architectures offer simplicity at the start, but as the application grows, they face several challenges:

  • Limited Scalability: Scaling the entire application for just one resource-intensive feature becomes inefficient.
  • Reduced Maintainability: Changes in one part of the system can have unintended consequences in unrelated areas.
  • Tight Coupling: Features and services become interdependent, making it hard to isolate and update individual components.
  • Deployment Bottlenecks: A small change anywhere requires redeploying the entire application.
  • Architecture Degradation: Over time, the codebase can turn into a “big ball of mud” — a tangled, unstructured system that is hard to evolve.

This naturally leads to the question: Is there a middle ground?

The modular monolith

Yes, this is where the modular monolith comes in. It retains the simplicity of a monolith but enforces modularity, ensuring clear boundaries between different parts of the system. Unlike traditional monoliths, where everything is tightly coupled, a modular monolith structures the application into well-defined modules that communicate through APIs or events, reducing dependencies and improving maintainability.

What is Spring Modulith?

Spring Modulith is an opinionated toolkit to build domain-driven, modular applications with Spring Boot. It helps structure an application into clearly defined modules, each encapsulating a specific business domain or feature.

Advantages

  • Code organization and maintainability: Codebase is easier to understand, develop and maintain.
  • Simplified refactoring: Refactoring can be isolated on a module level. Refactoring one module should not affect the other modules’ code.
  • Relatively independent deployments: modules can be upgraded without affecting other modules
  • Take advantage of the benefits of both microservices and monoliths: we are on the middle ground.

Trade-Offs

Although the benefits are obvious, Modulith comes with its own set of trade-offs:

  • Potential for tight coupling: We are still using a monolith architecture, and even though the main focus is modularization and loose-coupling between modules, the risk is still there that some components might be tightly coupled
  • Scaling complexity: Unlike microservices, where each service can independently be scaled, we need to scale the entire application just because a part of the application is under heavy load.
  • Tech stack limitation: The entire application shares the same technology stack, which might be a limiting factor. Maybe parts of the application are more suitable for implementation with a different programming language using a different tech stack.
  • Single point of failure: We effectively have a single application. An issue in one module can potentially impact the entire application, whereas in a microservice environment the failures are isolated.
  • Readiness of the team to embrace the Modulith architecture: Adopting it may involve a learning curve and adjustments in the development practices, which could impact the team productivity.

Spring Modulith in Action

Installation

You can start a new Spring Boot project by using Spring initializr and specifying Spring Modulith as a dependency.

Spring Modulith Project structure

Spring Modulith is opinionated and expects the code to follow a specific structure. The top level packages, which are on the same level as the @SpringBootApplication are considered modules.

Let’s create a sample project with 3 modules: Product, Order and Inventory.

com.n47.example.modulith
	├── inventory
	│   └── InventoryService.java
	├── order
	|   ├── controller
	|   |   └── OrderController.java
	|   ├── events
	|   |   └── OrderCreated.java
	│   |── OrderService.java
	├── product
	│   ├── ProductDto.java
	│   └── ProductService.java
	└── ModulithApplication.java
	

The module encapsulation is achieved by restricting the visibility of types within a module. Only the types on the top level of a module are considered part of the module’s public API and are visible to other modules. This means that any sub-packages are considered private to the module and are not visible to the other modules unless they are explicitly specified as public.

Project structure validation

The structure is not enforced during compile time. Instead, it is verified by a test. Failing to follow the rules that Modulith imposes would result in test failure.

@SpringBootTest
class ModulithApplicationTests {

  ApplicationModules modules = ApplicationModules.of(ModulithApplication.class);

  @Test
  void verifiesModularStructure() {
    modules.verify();
  }
}

Additionally, Spring Modulith offers a built-in functionality that can generate component diagrams. Those could be quite useful for visualizing the services and the interactions between them. We can add the following test, which would generate the component diagrams.

  @Test
  void createModuleDocumentation() {
    new Documenter(modules).writeDocumentation();
  }

The generated diagrams can be found under target/spring-modulith-docs/components.puml. The file can be opened with PlantUML diagram viewer.

Module interaction

There are two main approaches to enabling module interaction:

  • Direct interaction: Achieved through dependency injection
  • Event-based communication: Modules can publish and subscribe to events

Direct interaction

Let’s consider the following scenario where the user wants to order a product. The user calls the OrderController, which invokes the OrderService. The OrderService needs some product information, so it requests it from the ProductService. This is possible as the ProductService is a top-level public service within the product module. The getProduct method returns a ProductDto. Again, this is possible as the ProductDto is defined as a public type on a top-level of the ProductsService.

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {

  private final ProductService productService;

  public void createOrder(UUID productId) {
    ProductDto productDto = productService.getProduct(productId);
    log.info("Creating order with product: {}", productDto);
  }
}
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductService {

  public ProductDto getProduct(UUID productId) {
    return new ProductDto(productId, "Product");
  }
}

Let’s run the tests and generate the component diagram.

Component diagram: Order, Product

We can observe the module interaction: The Order module uses the Product module to fetch some product details.

Event-Based communication

Let’s add the InventoryService to the mix. We want the OrderService to notify the InventoryService as soon as the order is created.

Up until now, the communication between our modules was synchronous. However, we can implement asynchronous communication by making use of the Spring application events. Spring offers the option of outputting events via an in-process event bus using the ApplicationEventPublisher interface.

Let’s modify the OrderService:

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {

  private final ProductService productService;
  private final ApplicationEventPublisher applicationEventPublisher;

  public void createOrder(UUID productId) {
    ProductDto productDto = productService.getProduct(productId);
    log.info("Creating order with product: {}", productDto);
    applicationEventPublisher.publishEvent(new OrderCreated(productId));
  }
}

Spring Modulith hooks onto this mechanism and extends it with transactional behavior as well as asynchrony, using the ApplicationModuleListener. This allows for events to be consumed and processed by the InventoryService.

@Slf4j
@Service
@RequiredArgsConstructor
public class InventoryService {

  private final ProductService productService;

  @ApplicationModuleListener
  void onOrderCreated(OrderCreated event) {
    ProductDto productDto = productService.getProduct(event.productId());
    log.info("Order created for product: {}", productDto);
  }
}

The InventoryService also needs to fetch some product info, so it additionally injects the ProductService.

Explicit module access

Let’s try to run the tests now. Oops… we got a failure.

The key message here is: Module 'inventory' depends on non-exposed type com.n47.example.modulith.order.events.OrderCreated within module 'order'!.
Indeed, we are trying to use OrderCreated type, but it’s located in a nested package, thus it’s considered private to the Order module. To explicitly expose this package, we need to add a package-info.java file in the events package:

@org.springframework.modulith.NamedInterface("order-events")
package com.n47.example.modulith.order.events;

If we run the test again, it now passes.

Let’s generate the component diagram again.

Component diagram: Order, Product, Inventory

We can observe the newly added listens to and uses relationships between the Inventory and the other two modules.

The dependencies between modules form a directed acyclic graph, which ensures a clear hierarchy, prevents cyclic dependencies, and allows for predictable module interactions.

Testing

The Modulith test dependency offers the @ApplicationModuleTest annotation, which ensures that only the relevant modules and its dependencies are loaded. This helps enforce the modular boundaries by preventing unneeded dependencies from being used in the test. Overall, it works similarly to the @SpringBootTest but with a focus on a specific module rather than the entire application.

Additionally, the Modulith test support provides the Scenario helper, which allows event simulation and simplifies testing that events were published.

@ApplicationModuleTest
@RequiredArgsConstructor
class OrderServiceTest {

  private final OrderService orderService;

  @MockBean
  ProductService productService;

  @Test
  void publishesOrderCreated(Scenario scenario) {
    scenario.stimulate(() -> orderService.createOrder(UUID.randomUUID()))
        .andWaitForEventOfType(OrderCreated.class)
        .toArrive();
  }

}

Should I go with Spring Modulith or Microservices on my next project?

As usual in engineering, it’s all about trade-offs, and the decision depends on your specific needs.

Spring Modulith is particularly well-suited for:

  • Small to Medium-Sized Applications: It would be more efficient to focus on building well-structured applications and avoid the unnecessary overhead that comes with microservices.
  • Projects with Limited Scaling Needs: Where scaling the entire application as a whole is sufficient.
  • Teams with Limited Resources: There is an operational burden of managing microservices. managing a single application would be simpler and cheaper.
  • Applications with Strong Consistency Requirements: Distributed transaction management would be overly complex if implemented with microservices.

As the application grows and evolves, you may identify the modules that would scale better independently and extract them as separate microservices. The modular approach, allows you to maintain the benefits of a monolithic architecture while also preparing for a smooth transition to microservices if such a need emerges.

Leave a Reply


The reCAPTCHA verification period has expired. Please reload the page.