Advanced
  • Spring
  • Software Architecture
  • Java

Modular monolith with Spring Modulith: architectural discipline without the microservices bill

How Spring Modulith turns architectural conventions into verifiable boundaries inside a Spring Boot monolith, and when that's worth more than distributing everything.

20 min read
Modular monolith with Spring Modulith: architectural discipline without the microservices bill

If you can't build a well-structured monolith, what makes you think microservices are the answer?

Simon Brown

Simon Brown sums up in one sentence the problem almost every team runs into. Architecture becomes a binary choice in people's heads: either a monolith, which over time degenerates into a big ball of mud where everything depends on everything, or microservices, which solve coupling by trading it for a mountain of operational complexity: network, serialization, eventual consistency, distributed observability. In practice, plenty of teams run from the first problem and dive headfirst into the second before they need to.

Spring Modulith lives in that middle ground. The idea is simple: you keep the simplicity of a single deployable, but with the boundary discipline you normally only see in distributed systems. It's a modular monolith.

This post closes an arc in the series. The previous ones modeled the domain from the inside: the tactical fundamentals of DDD, the anemic model that disguises itself as DDD, and primitive obsession. There we built rich objects: Money, an Order that protects its own invariants, the OrderSubmitted domain event. Now the question is at a different scale: how do you organize those objects into modules that don't tangle into each other inside the same monolith. The snippets are Java with Spring Boot, but the reasoning about boundaries holds for any stack.

What Spring Modulith is (and what it isn't)

First, an expectation check: Spring Modulith is not a heavy runtime framework. It doesn't change how your application boots, doesn't impose a new container, doesn't intercept your request cycle. At its core it's a set of conventions plus verification, testing, and documentation tooling built on top of the Spring Boot you already use.

Its value isn't in running extra code. It's in taking an architectural decision, the "these modules shouldn't reach into each other" kind, and pulling it out of a comment on a diagram nobody updates to turn it into a rule that breaks the build when someone violates it.

A module is a package

The first convention is the most important and the cheapest: by default, every direct subpackage of the application's main package is a module.

com.example.shop            <- root package (@SpringBootApplication)
com.example.shop.order      <- "order" module
com.example.shop.inventory  <- "inventory" module
com.example.shop.customer   <- "customer" module

Inside each module, Spring Modulith treats the classes at the package root level as the module's public API, what the others are allowed to use. Anything in subpackages is considered internal. And that's exactly why you want as little as possible at the root:

com.example.shop.order
├── OrderSubmittedIntegrationEvent.java  <- public: the async contract (event)
├── SubmitOrder.java                     <- public: the sync port (interface)
└── internal
    ├── application
    │   ├── SubmitOrderUseCase.java      <- internal: implements SubmitOrder, framework-free
    │   └── AddItemToOrderUseCase.java   <- internal: another use case
    ├── domain
    │   ├── Order.java                   <- internal: the aggregate, protected
    │   ├── OrderSubmitted.java          <- internal: the domain event, next to the model
    │   ├── OrderRepository.java         <- internal: outbound persistence port
    │   └── DomainEventPublisher.java    <- internal: outbound publishing port
    └── infrastructure
        ├── OrderConfiguration.java      <- internal: registers the use case beans
        ├── OrderController.java         <- internal: inbound adapter, calls the use cases
        ├── OrderEventsRelay.java        <- internal: translates domain event into public contract
        ├── JpaOrderRepository.java      <- internal: implements OrderRepository
        ├── SpringEventPublisher.java    <- internal: implements DomainEventPublisher
        └── OrderJpaEntity.java          <- internal

Look at what stays out: everything, except the two contracts. The Order aggregate, the use cases that orchestrate it, the repository and the JPA mapping all live under internal, organized in the same three layers from the fundamentals post (domain, application, infrastructure), only now hidden behind the module boundary. No other module can hold a reference to Order and start calling order.addItem(...) from outside, or depend on the shape of its table. Two things cross the boundary, one for each style of conversation: the integration event, for whoever wants to react to what happened, and the sync port, for whoever needs to ask for something and wait for the answer.

And notice there are two events, one on each side of the boundary. OrderSubmitted is the domain event: it lives in internal/domain, next to the model it belongs to, carries Value Objects (OrderId, Money) and evolves freely with the aggregate, because nobody outside sees it. OrderSubmittedIntegrationEvent is the integration event: the public contract, stable and minimal, that the other modules consume. OrderEventsRelay in the infrastructure translates one into the other. That separation exists because the two have different lifecycles: the domain event changes at the pace of the model, the public contract changes at the pace of its consumers, and tying both to the same type means refactoring the domain becomes a contract break.

If that feels like too much weight for your case, there's the simple version: one event only, at the module root, playing both roles. It works well in small moduliths, with few consumers, and it's less code to maintain. The cost is coupling the contract's shape to the domain's shape; when the model needs to change without breaking listeners, or when versioning shows up, you migrate to the two types. What matters is making the choice knowing the trade-off, not by accident.

Note too that there's no OrderService doing everything. Each operation is its own thin use case with a single responsibility, exactly like the closing of the fundamentals post. A single service doing submit, addItem, cancel and whatever else shows up is the fat-service-full-of-if we fought in the anemic model post; it doesn't get better just by sitting inside a module.

The sync port follows the same logic: narrow and specific, one interface per capability, never an umbrella interface with ten methods, which would be the same god object dressed up as a contract. It's the answer for when another module needs to call a use case synchronously without coupling to anything internal:

// order module root: the sync port
public interface SubmitOrder {
    void execute(String orderId);
}

The implementer is SubmitOrderUseCase, down in internal/application, as we'll see next. A checkout module that needs to trigger the submission injects SubmitOrder and calls execute. It knows the interface and nothing else: not the concrete use case, not the aggregate, not the Spring behind it. And notice the signature uses String, not OrderId: the Value Object is an internal domain type, and leaking it in the public port would drag internal along with it. Same edge rule we'll see with the events: simple types in the contract, unwrapped into Value Objects the moment the data comes in.

This is encapsulation at the module level, the same instinct we applied to the Aggregate. There, nobody outside touched the order items directly; everything went through the aggregate root. Here it's the same idea with the whole module. And it's a discipline you have to enforce: Spring Modulith's default leaves the root wide open, so it's easy to drop the aggregate there and, without noticing, undermine the very protection you came for. A public Order is an Order that another module will eventually mutate, and then the boundary is just decoration. The module is the boundary, and the root is the only door.

Other than deciding what lives in internal, you needed no annotation and no config to define the module. The package structure is the architecture.

The boundary that breaks the build

A convention without verification is just a suggestion. The feature that gives Spring Modulith teeth is this test:

class ModularityTests {
 
    @Test
    void verifiesModularStructure() {
        ApplicationModules.of(ShopApplication.class).verify();
    }
}

That verify() walks the whole application and fails if any module reaches into another's internal package, if there's a cyclic dependency between modules, or if someone references a type that isn't part of the public API. If the inventory module tries to import com.example.shop.order.internal.domain.OrderRepository, the test goes red in CI and the violation never reaches main. And notice what that means: the split between domain, application and infrastructure, which inside the module is your own convention, gets a wall around it. No other module reaches any layer, because they're all behind internal.

This is where things change. Architecture stops being a verbal agreement, the "folks, don't touch other people's internals" kind, and becomes an executable invariant. Same philosophy as the previous posts, where the domain itself protected its rules. Here it's the test suite that protects the boundaries.

Rule of thumb: if your architectural decision doesn't have a test verifying it, it isn't an architectural decision. It's a hope.

Need to expose a specific subpackage without opening the whole module? You can mark a named interface and keep controlling what leaks:

// package-info.java in com.example.shop.order.spi
@org.springframework.modulith.NamedInterface("spi")
package com.example.shop.order.spi;

spi becomes an explicit public door into the order module, while the rest of the internals stay closed. It's the granular version of the same decision: expose what you want, and only that, when the public surface doesn't fit in a single class at the root. A module publishing several integration events is the typical case: an order.events package with @NamedInterface gives them an address of their own without opening anything else.

Modules talk through events, not calls

This is where the previous posts and Spring Modulith click together.

If the order module calls inventoryService.reduceStock(...) directly, you've created tight coupling, and it charges you on three fronts:

  • Compilation: order no longer compiles without inventory. The two became one block.
  • Change: any signature change over there ripples over here. The boundary you drew doesn't exist in practice.
  • Future extraction: the day you want to split inventory out, you'll be hunting down every direct call scattered across the system.

The alternative is the same one we saw with Domain Events: the module publishes a fact and moves on. Remember OrderSubmitted? The Order aggregate already recorded that event when it was submitted. The flow has four ends: the aggregate records the domain event, the use case publishes it through the port right after save, the relay translates it into the public contract, and the other module's listener reacts to that contract:

// order's internal/application: implements the public port, framework-free
class SubmitOrderUseCase implements SubmitOrder {
 
    private final OrderRepository orders;
    private final DomainEventPublisher events;
 
    SubmitOrderUseCase(OrderRepository orders, DomainEventPublisher events) {
        this.orders = orders;
        this.events = events;
    }
 
    @Override
    public void execute(String rawOrderId) {
        OrderId orderId = new OrderId(rawOrderId);   // the edge uses String; in here, Value Object
        Order order = orders.find(orderId)
                .orElseThrow(() -> new OrderNotFoundException(orderId));
        order.submit();                              // validates invariants, records OrderSubmitted
        orders.save(order);                          // persists; the repository only persists
        events.publish(order.pullDomainEvents());    // publishing is the use case's call
    }
}

Look at what the use case injects: two ports, OrderRepository and DomainEventPublisher, both interfaces owned by the domain. There's no @Component and no Spring import: the application layer stays framework-free, following the fundamentals rule that dependencies point inward. The bean is registered by a @Configuration in the infrastructure, the only place the framework gets in:

// order's internal/infrastructure: the framework only shows up here
@Configuration
class OrderConfiguration {
 
    @Bean
    SubmitOrder submitOrder(OrderRepository orders, DomainEventPublisher events) {
        return new SubmitOrderUseCase(orders, events);
    }
}

And note the injected publisher isn't Spring's, it's a port owned by the domain, because recording is not publishing and each responsibility lives in a layer: the domain records, the application decides when, the infrastructure knows how. The recording happens in the domain, in two pieces. The first is the AggregateRoot base from the fundamentals, which gives any aggregate a place to keep the facts it produces (in practice it lives in a tiny shared package, since every module uses it):

// the base from the fundamentals: the field and the domain event methods
public abstract class AggregateRoot<ID> {
 
    private final List<Object> domainEvents = new ArrayList<>();
 
    protected void addDomainEvent(Object event) {
        domainEvents.add(event);
    }
 
    public List<Object> pullDomainEvents() {
        List<Object> events = List.copyOf(domainEvents);
        domainEvents.clear();
        return events;
    }
}

Notice the pair: addDomainEvent accumulates, pullDomainEvents drains and clears. That pull is what the use case called right after save. The second piece is the aggregate itself, which uses the base when it changes state:

// order's internal/domain: the aggregate records the fact, it doesn't publish it
class Order extends AggregateRoot<OrderId> {
 
    private final OrderId id;
    private final CustomerId customerId;
    private final List<OrderItem> items = new ArrayList<>();
    private OrderStatus status = OrderStatus.DRAFT;
 
    Order(OrderId id, CustomerId customerId) {
        this.id = id;
        this.customerId = customerId;
    }
 
    void submit() {
        if (status != OrderStatus.DRAFT) {
            throw new OrderNotSubmittableException(id);
        }
        this.status = OrderStatus.SUBMITTED;
        addDomainEvent(new OrderSubmitted(id, customerId, totalAmount()));
    }
 
    Money totalAmount() {
        return items.stream()
                .map(OrderItem::subtotal)
                .reduce(Money.ZERO, Money::add);
    }
}

The aggregate knows the fact, but knows nothing about Spring, nothing about listeners, nothing about transactions. It just keeps it. Who decides when to publish is the application layer: it's the use case that, right after save, drains the events and hands them to the port. Publishing is part of the orchestration, and orchestration is its job. Hiding it inside the repository would give save a side effect that doesn't show up in the signature, and a repository that publishes events is a repository with two jobs.

The port is an interface owned by the domain, and the adapter wiring it to Spring lives in the infrastructure:

// order's internal/domain: the outbound port for publishing facts
public interface DomainEventPublisher {
    void publish(List<Object> events);
}
// order's internal/infrastructure: the adapter wiring the port to Spring
@Component
class SpringEventPublisher implements DomainEventPublisher {
 
    private final ApplicationEventPublisher spring;
 
    SpringEventPublisher(ApplicationEventPublisher spring) {
        this.spring = spring;
    }
 
    @Override
    public void publish(List<Object> events) {
        events.forEach(spring::publishEvent);
    }
}

The events.publish(order.pullDomainEvents()) line in the use case is the answer to "where does the domain event get published": in the application layer, right after the write, inside the same transaction. If it rolls back, neither the order nor the events survive. The two live and die together. And notice the design is identical to OrderRepository's: interface in the domain, implementation in the infrastructure, the use case seeing only the contract. It's the same DomainEventPublisher from the fundamentals post, now living inside a module.

The price of that explicitness is discipline: every use case that changes an aggregate has to remember to drain and publish. There is the automatic version, extending Spring Data's AbstractAggregateRoot (or annotating a method with @DomainEvents) and letting save publish everything for free; the cost just moves, becoming a framework annotation inside the domain and a publication nobody sees happen. Pick which cost you'd rather pay. The full wiring of that publisher, with delivery guarantees and retry, is worth its own post in this series. For now, what matters is the design: the use case fetches, delegates to the domain, saves and publishes; and still has no idea who's listening.

From the publication onward, Spring Modulith takes over:

// order's internal/infrastructure: translates the domain event into the public contract
@Component
class OrderEventsRelay {
 
    private final ApplicationEventPublisher events;
 
    OrderEventsRelay(ApplicationEventPublisher events) {
        this.events = events;
    }
 
    @EventListener   // synchronous, in the use case's own transaction
    void on(OrderSubmitted event) {
        events.publishEvent(new OrderSubmittedIntegrationEvent(
                event.orderId().value(),
                event.customerId().value(),
                event.totalAmount().value()));
    }
}
// inventory's internal/infrastructure: inbound adapter, same role as a controller
@Component
class OrderSubmittedListener {
 
    private final ReduceStockUseCase reduceStock;
 
    OrderSubmittedListener(ReduceStockUseCase reduceStock) {
        this.reduceStock = reduceStock;
    }
 
    @ApplicationModuleListener
    void on(OrderSubmittedIntegrationEvent event) {
        reduceStock.forOrder(event.orderId());   // the adapter only delegates to the use case
    }
}

Look at the shape: order does not know inventory. No import, no injection, no compile-time dependency from one to the other. The domain event never leaves the module; all that crosses the boundary is OrderSubmittedIntegrationEvent, and it's the only thing both sides know. Note each adapter's role too: the relay uses a plain @EventListener, synchronous, precisely so it runs inside the use case's transaction, which means the public contract is only published if the order really was persisted. And inventory's listener is a thin adapter that just delegates to its own use case, the way a controller would. It's the pub/sub pattern applied inside the process, the same concept that showed up as Observer in the fundamentals. When you do need to cross processes, it becomes a Kafka message without the domain noticing.

The @ApplicationModuleListener annotation isn't just a prettier @EventListener. It ships with three sensible decisions baked in: processing is asynchronous, runs in a new transaction, and only fires after the publisher's transaction has committed. In other words: stock is only reduced if the order really was persisted. No hacks, no stray @Async.

"But I already do this with ArchUnit"

Here's the honest part, because there's legitimate skepticism on the other side.

You don't need Modulith to have boundaries. You can write an ArchUnit test saying "no internal package may be accessed from outside", and there you go, you have the verification. If that's all you want, preventing cyclic dependencies and package leaks in a system that isn't event-driven, ArchUnit alone is lighter and does the job. In that case, bringing in Modulith is extra weight.

What Modulith adds, and ArchUnit doesn't, is everything around event-based communication: transactional delivery via @ApplicationModuleListener, the publication registry we'll see next, tests sliced by module and documentation generated from the code. ArchUnit gives you the wall. Modulith gives you the wall plus the door the modules talk through safely. And a modular monolith worth the name communicates through events, so that's where it pays for itself.

The event can't get lost: the Event Publication Registry

Asynchronous processing raises an uncomfortable question: what if the application goes down between the order's commit and the event's delivery to inventory? The order is saved, but stock was never reduced. Silent inconsistency between modules, the kind of bug that only shows up in production, at three in the morning.

Spring Modulith solves this with the Event Publication Registry. By adding the event persistence starter:

implementation 'org.springframework.modulith:spring-modulith-starter-jpa'

...it starts writing every event to a table (event_publication) inside the same transaction that produced the event, and only marks it complete once the listener has processed it successfully. If the process dies midway, the event is still there, pending, and gets reprocessed when the application comes back up.

If you've heard of the transactional outbox pattern, this is exactly it, except free: no hand-written table and no polling loop. What would be a side project in a distributed system is a dependency and a convention here.

And when one of those modules finally does need to become a real service, the same contract can be externalized to a broker with one annotation:

@Externalized("shop.orders::#{#this.orderId()}")
public record OrderSubmittedIntegrationEvent(
        String orderId, String customerId, BigDecimal total) { }

Notice the contract carries simple, serializable types, never the aggregate or the internal Value Objects. And before that looks like it contradicts the primitive obsession post: it doesn't. Primitives are a problem inside the domain, where they scatter rules and blind the compiler. At the edge, the contract is a serialization format, like an API DTO; each side unwraps into its own Value Objects the moment data comes in. The event that travels in memory today travels over Kafka tomorrow. The domain code doesn't change. Only the edge does. It's the fundamentals promise ("the Domain Event is a domain concept, not an infrastructure one") turned into configuration.

Free of charge: per-module tests and documentation that doesn't lie

Two courtesies that show up once the boundaries exist.

If each module has a clear boundary, testing one of them shouldn't require booting the whole application. And it doesn't: @ApplicationModuleTest boots only the module in question and its required dependencies. Add the Scenario API to that, built for the event-driven style: you trigger an action and wait for the right event to show up, with no Thread.sleep and no flakiness.

And documentation, the kind that's born out of date on every project? Modulith generates its own from the code itself:

@Test
void writeDocumentation() {
    var modules = ApplicationModules.of(ShopApplication.class);
    new Documenter(modules)
            .writeModulesAsPlantUml()   // component diagram (C4 style)
            .writeModuleCanvases();     // one "canvas" per module
}

It produces C4/PlantUML diagrams of the dependencies between modules and a module canvas describing, per module, what it exposes, what it depends on, and which events it publishes and consumes. Since it comes from the code, it's always right: if the architecture changes, the documentation changes with it on the next build.

The files land in build/spring-modulith-docs (Gradle) or target/spring-modulith-docs (Maven), since the generator runs as a test: a components.puml with the overall diagram and a module-<name>.adoc per canvas. Two practical warnings: the .puml is PlantUML text, and turning it into an image is on you (IDE plugin or CLI); and you can change the destination with Documenter.Options.defaults().withOutputFolder(...) if you'd rather write straight into a versioned docs folder in the repo.

Signs your monolith would ask for a Modulith

Not every system needs this. Some practical signs that yours already does:

  • Packages that turned into dumping grounds. A service that imports classes from half the system, a util everyone uses. The boundaries are already blurring.
  • Subdomains living in the same deployable. Order, inventory, payment, customer, all collaborating when they shouldn't know each other's guts.
  • Fear of touching things. A small change in one corner breaks something three modules away, because everything reaches everything.
  • The recurring "let's break this into microservices" conversation before the team knows the domain's limits well. A sign that you want boundaries, not necessarily separate processes.

And if you follow the series, you've seen the caveat below close every post, and you'll keep seeing it. Insisting on it sounds repetitive on purpose, because it's the most important one of all: overengineering costs as much as the problem it's trying to avoid. If your system is an honest CRUD, with a handful of entities and little business logic, putting Spring Modulith on top of it is ceremony with no return. Better to write the CRUD and move on.

The exit to microservices, when it comes

This is the argument that ties it all together. The "monolith or microservices?" question is almost always asked too early, when you don't yet know the domain well enough to know where the seams should run. As I argued in the post on a bounded context not being a microservice, Spring Modulith lets you postpone that decision without paying interest on it.

Because a well-behaved module, with verified boundaries, event-based communication and encapsulated internals, is already a service that hasn't been extracted yet. When (and if) the time comes to promote it, you won't be untangling dependencies scattered across the system. You'll swap the in-memory publisher for a Kafka one, boot the module in its own process, and the rest of the system will barely notice. The extraction becomes a deploy, not a refactoring project.

And often the honest conclusion is that the extraction never needs to happen. The modular monolith already solved the real problem, coupling, without charging the operational price of distributing. Which, in the end, was Simon Brown's point up top.

What sticks

Spring Modulith doesn't invent a new architecture. It takes principles the whole series has been discussing (Bounded Contexts, Domain Events, encapsulation, dependencies pointing the right way) and turns them from intent into enforceable constraint. Modules are packages. Boundaries break the build. Modules talk through events. Events don't get lost. Documentation comes out of the code. It's little tooling for a lot of return, as long as there's real complexity to justify it.

In the next posts I want to show this with real code: a small application structured end to end with Spring Modulith, the Event Publication Registry under load, and the frank discussion of when extracting to microservices actually pays off, and when it's just architect anxiety.


Got a story, an opinion, or a disagreement? A monolith that turned into a big ball of mud for lack of boundaries, or a microservices case that only brought pain? Reach out on LinkedIn, let's talk!