A package called orders does not become a bounded context just because its name sounds like the business. And an event called OrderConfirmed does not make an integration reliable just because a listener receives it on your laptop.

The interesting direction in Spring Modulith is the connection between these two problems: giving business boundaries an enforceable shape, then making the interactions across those boundaries testable. For a team building a Spring Boot application, that is a practical way to apply Domain-Driven Design without immediately taking on separate deployments and network protocols.

This article builds a small Orders–Billing example. We will keep the models separate, expose one event contract, verify the dependency rule, and deliberately break invoice persistence to see what survives. It extends our earlier article, Your Modular Monolith Needs an Architecture Test, with the DDD and transaction decisions behind the structure.

What is Spring Modulith? A beginner's introduction

Spring Modulith is a toolkit for organizing a Spring Boot application into clearly defined business modules. A module groups code that serves one responsibility, such as taking orders or creating invoices. The application can still start, run and be deployed as one unit. Spring Modulith helps check its structure, test modules separately and generate documentation from the code. Official project overview.

Think of a company with several departments in one building. Sales and Accounting have different responsibilities, but colleagues can work together through agreed channels. A modular monolith applies that idea to software: one application contains distinct areas, and each area controls which capabilities it exposes to the others.

The word monolith describes the application's deployment shape. It does not tell you whether the code inside is well organized. Modular describes the internal boundaries. Spring Modulith provides tools to make those boundaries explicit and check them as the application changes.

Three simplified architecture sketches: an entangled single application, a modular application with Orders and Billing inside one process, and separate Orders and Billing services communicating over a network

Figure 1. A modular monolith keeps business areas separate inside one application. Separately deployed services introduce a network boundary. These sketches illustrate deployment and code organization; they do not prescribe a database layout.

Four terms make the rest of this article easier to follow:

Term Plain-English meaning in our example
Business domain The area of work the software supports: selling goods and invoicing them.
Module The code responsible for one capability, such as Orders or Billing.
Public API An agreed entry point that other code may use. Here this can be a Java method or a published event type; it does not require an HTTP endpoint.
Event A fact that has happened, such as “this order was confirmed.” A listener is code that reacts to that fact.

Each module has a public side and an internal side. Other modules use its public contract instead of reaching into its repositories or modifying its private business objects. A boundary check can catch an import that crosses into another module's internal package, even when Java itself would allow the code to compile.

Modules can collaborate in two useful ways. A direct API call asks another module to perform an operation and waits for its result. An event tells interested modules that something happened. Neither style is automatically the right choice for every interaction.

Two beginner collaboration patterns: a direct Java API call with an immediate response, and an OrderConfirmed event followed by Billing listener work; repositories and domain objects remain internal

Figure 2. Choose the interaction from the business requirement. In the event-based example developed below, Billing runs asynchronously after the order transaction commits. Events in general do not automatically imply asynchronous or durable delivery.

Spring Modulith is a set of libraries used with Spring Boot, rather than a separate server or a new Java language feature. You select the capabilities you need. It does not automatically turn modules into microservices, and it does not make every event recoverable without the appropriate persistence and transaction setup.

DDD adds the business reasoning: which responsibility belongs where, what its words mean, and which rules must always hold. Spring Modulith helps express and verify parts of that design in code. In the example below, that means Orders decides when an order is confirmed, while Billing reacts to the published fact and maintains its own invoice representation.

What is current, and why this topic matters

Research checked on October 9, 2026. Spring's project page identifies Spring Modulith 2.1.1 as stable. The documentation navigation lists 2.2.0-M2 as a preview; preview documentation remains explicitly marked unstable. The August 26 release announcement describes the 2.2 line moving toward Boot 4.2 and Framework 7.1. These are separate tracks, not interchangeable production baselines. Project page, preview documentation, release announcement.

Three developments are particularly relevant to architecture work:

Development Practical consequence
Stable 2.1 and the next 2.2 preview track Keep experiments with the next platform separate from a reproducible stable example.
Publication lifecycle introduced in 2.0 Failed and in-progress listener work can be distinguished and recovery policies made explicit.
Spring Tools support documented in September 2026 Developers can see module structure and references to internals while coding; the tooling also exposes architecture context to AI assistants.

The event lifecycle is described in the official event reference. The Spring Tools documentation also explains its activation requirement: spring-modulith-core must be on the compile or runtime classpath; a test-only dependency is insufficient.

My assessment is that executable DDD boundaries with recoverable module interactions are the strongest topic for Homann Software's architecture and Java engineering audience. That is an editorial choice based on these capabilities, not a claim about market adoption or a measured industry ranking.

DDD defines the boundary; Modulith checks its technical expression

A bounded context is the boundary within which a particular model and its language apply. The same business object can have different meanings in different contexts. A sales order and an invoice should not automatically share one entity model because both contain a customer and an amount. Martin Fowler's bounded-context explanation.

In our example, Orders owns the decision to confirm an order. Billing owns the decision to create its invoice representation. Billing receives identifiers and a confirmed amount; it does not load or modify the Orders aggregate.

That is a modeling decision. Spring Modulith does not discover the correct business language, identify an aggregate's invariants, or decide whether invoice creation may lag behind confirmation. Those questions need conversations with domain experts and explicit use cases.

Nor is there a universal one-to-one mapping between a bounded context and an application module. A larger context can contain several modules. Conversely, splitting every entity into its own module can scatter a single business invariant across unnecessary boundaries.

Context map showing Orders as upstream and Billing as downstream, separate data ownership, business-event flow and reverse Java dependency

Figure 3. Design intent for this example. The business fact travels toward Billing; the Java type dependency points back to the event contract owned by Orders.

For this particular use case, invoice creation may follow order confirmation. If the business instead says “confirmation is invalid unless stock is reserved now,” an asynchronous notification alone cannot enforce that rule. Model a synchronous decision, a reservation protocol, or an explicit pending state and compensation workflow. Choose the consistency rule before choosing an annotation.

Give the public language a narrow API

Spring Modulith's default module arrangement uses direct subpackages beneath the application's root package. Public types in a module's base package form its default API; subpackages remain internal unless explicitly exposed through a named interface. An allowed-dependencies declaration can narrow which interfaces another module may reference. Module fundamentals.

Our package structure is deliberately small:

com.homannsoftware.shop
├── ShopApplication
├── orders
│   ├── OrderManagement
│   ├── events                 @NamedInterface("events")
│   │   └── OrderConfirmed
│   └── internal
│       └── Order
└── billing                    allowedDependencies = "orders::events"
    └── internal
        └── InvoiceListener

The two package declarations make the integration contract explicit:

// orders/events/package-info.java
@org.springframework.modulith.NamedInterface("events")
package com.homannsoftware.shop.orders.events;

// billing/package-info.java
@org.springframework.modulith.ApplicationModule(
    allowedDependencies = "orders::events")
package com.homannsoftware.shop.billing;

Billing can depend on OrderConfirmed, but not on OrderManagement or orders.internal.Order. The Orders module separately declares an empty allowed-dependencies list. These annotations complement Java visibility: a public class in an internal package can still compile when imported elsewhere, so the architectural test must run in CI.

The contract carries an immutable business fact:

package com.homannsoftware.shop.orders.events;
import java.util.Objects;
import java.util.UUID;
public record OrderConfirmed(UUID eventId, UUID orderId, long totalCents) {
    public OrderConfirmed {
        Objects.requireNonNull(eventId);
        Objects.requireNonNull(orderId);
        if (totalCents <= 0) throw new IllegalArgumentException("Positive total required");
    }
}

The example assumes one currency, EUR, and uses integer cents to avoid floating-point money calculations. A real multi-currency contract needs an explicit currency and a documented rounding policy. Here eventId identifies the fact, while orderId identifies the business object. A retry must preserve the original event identity.

Treat this as a published integration contract. Avoid exposing a mutable JPA entity, lazy relationship, or a consumer-specific command such as CreateBillingInvoiceNow. Billing should decide how to translate the fact into its own model. For long-lived persisted events, also plan compatible payload evolution: renaming a Java record or changing its fields can affect recovery of historical publications.

Keep the aggregate rule local, then publish within the transaction

The small Order aggregate rejects non-positive totals and a second confirmation of the same in-memory instance. It produces OrderConfirmed; it knows nothing about Billing, JDBC, or an asynchronous executor. The application service coordinates persistence and publication:

package com.homannsoftware.shop.orders;
import com.homannsoftware.shop.orders.events.OrderConfirmed;
import com.homannsoftware.shop.orders.internal.Order;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
@Service
public class OrderManagement {
    private final JdbcClient jdbc;
    private final ApplicationEventPublisher events;
    public OrderManagement(JdbcClient jdbc, ApplicationEventPublisher events) {
        this.jdbc = jdbc;
        this.events = events;
    }
    @Transactional
    public OrderConfirmed confirm(UUID id, long totalCents) {
        var event = new Order(id, totalCents).confirm();
        jdbc.sql("INSERT INTO orders (id, total_cents) VALUES (?, ?)")
            .params(id, totalCents).update();
        events.publishEvent(event);
        return event;
    }
}

This is a deliberately limited “create and confirm” command. It does not implement loading an existing order, an entire persistent order lifecycle, optimistic locking, or HTTP command idempotency. The order primary key prevents a second successful insert with the same order ID, but a production API must define what a client retry returns after an uncertain response. Consumer deduplication and command idempotency solve different problems.

The application enables Spring asynchronous execution with @EnableAsync. Its dependencies include spring-boot-starter-jdbc and spring-modulith-starter-jdbc, which supplies the persistent event registry integration. The sample enables registry schema initialization in an H2 database; production schema changes belong in reviewed migrations.

There are two separate transaction boundaries. With a transactional publisher and the JDBC registry, the publication is recorded in the original transaction. @ApplicationModuleListener combines asynchronous delivery, a transactional event listener, and a new transaction for the receiving work. Completion is recorded after successful listener execution. Listener API, event registry reference.

UML sequence diagram showing order and publication in transaction A, followed by asynchronous invoice persistence in transaction B

Figure 4. One illustrative successful interleaving. After commit, the caller's return and asynchronous listener execution can race. The registry and database lifeline groups infrastructure for readability; this is an in-process application, not a broker exchange.

The consequence is visible to the user: “order confirmed” can be true while invoice creation is still pending. The UI and support workflow should represent that state honestly. An invoice persistence failure must not silently change the meaning of a committed order confirmation.

Recoverable delivery still needs a duplicate-safe consumer

The Billing listener writes a minimal invoice representation:

package com.homannsoftware.shop.billing.internal;
import com.homannsoftware.shop.orders.events.OrderConfirmed;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
public class InvoiceListener {
    private final JdbcClient jdbc;
    public InvoiceListener(JdbcClient jdbc) { this.jdbc = jdbc; }
    @ApplicationModuleListener
    public void on(OrderConfirmed event) {
        // H2-specific upsert: stable event ID and immutable payload required.
        jdbc.sql("MERGE INTO invoices (event_id, order_id, total_cents) KEY(event_id) VALUES (?, ?, ?)")
            .params(event.eventId(), event.orderId(), event.totalCents()).update();
    }
}

The H2-specific MERGE uses event_id as its key. Replaying the same immutable event updates the same row instead of inserting a second invoice. The schema also makes order_id unique, reflecting the sample's rule of one invoice per order.

That is a limited, demonstrated form of idempotency. It is not an exactly-once delivery guarantee. MERGE would overwrite a row if someone reused an event ID with a different payload, and a different event ID for the same order would hit the unique constraint. A production consumer should reject conflicting identity/payload combinations and define whether an invoice is immutable. On another database, choose its native conflict-handling strategy and test concurrent deliveries there.

This listener performs one database write. If Billing also sends an email or calls a payment service, the database transaction cannot roll back the remote side effect. Give that effect its own stable idempotency key and recovery protocol. A publication marked completed is evidence of successful listener execution, not independent proof that every downstream business effect settled correctly.

The current registry API exposes failed-publication resubmission and options for selecting work. Operators still need a policy for retry age, attempt limits, backoff, stale work, alerts, and retention. Publication management reference.

Our test explicitly calls FailedEventPublications.resubmit(ResubmissionOptions.defaults()) after removing the simulated database failure. It does not configure an automatic retry loop. The application's restart-republication setting is disabled for this demonstration so that recovery is an explicit test action.

Test the meaning of the boundary and the failure behavior

The downloadable project pins Spring Boot 4.1.0, Spring Modulith 2.1.1, and Java 21. These are the tested coordinates, not a claim that Boot 4.1.0 is the newest patch. Execution used Temurin 21.0.11+10, Maven 3.9.11, and H2 2.4.240 on Windows.

Download the tested Maven project and failure tests. Unzip it and run the full project from examples:

mvn -B clean test
python verify_mutations.py /absolute/path/to/mvn
# Windows: supply the absolute path to mvn.cmd instead.

The baseline completed with 12 tests, zero failures, zero errors, zero skips. The tests cover:

Evidence What was actually checked
Six domain/contract tests Valid confirmation, repeated confirmation, zero/negative totals, missing identity, invalid event amount.
One architecture/documentation test Both modules are detected; the arrangement passes verify(); PlantUML and module canvases are generated.
One isolated Billing test A Scenario publishes the public fact and waits for the invoice; the Orders service bean is absent.
Four application integration tests Successful commit, publisher rollback, duplicate delivery, database failure followed by explicit resubmission.

The structural check and generated documentation use the same discovered model:

var modules = ApplicationModules.of(ShopApplication.class);
modules.verify();
new Documenter(modules)
    .writeModulesAsPlantUml()
    .writeModuleCanvases();

Spring Modulith provides both structural verification and generated documentation. The generated component diagram reflects code dependencies, not proof that the business context map is correct. Review the two together.

For module behavior, @ApplicationModuleTest and Scenario support a stimulus followed by an awaited state or event. The isolated test in this project publishes OrderConfirmed without bootstrapping the Orders service. Scenario stimuli commit their own transaction, so test data requires explicit cleanup or an isolated database rather than relying on a surrounding rollback. Module testing reference.

The failure test adds a temporary database check constraint that rejects the selected invoice amount. Order confirmation still commits, invoice insertion fails, and the test waits until the registry reports FAILED. After removing the constraint and resubmitting, it verifies the invoice, COMPLETED, and two completion attempts. This exercises actual Spring proxies, asynchronous delivery, JDBC persistence, and registry state transitions.

The rollback test verifies that neither the order row nor its serialized publication remains. The duplicate test verifies both a single invoice row and two completed publications. Checking only the row count would miss a consumer that kept failing on every duplicate.

Evidence infographic separating domain and architecture tests, module and integration tests, negative controls, and remaining production work

Figure 5. What the test package establishes, and which production questions remain open.

Three deliberate mutations were also rejected: a Billing dependency on OrderManagement, removal of the named event interface, and replacement of the duplicate-safe MERGE with a plain INSERT. The restored baseline passed again. These negative controls demonstrate that the suite notices the selected regressions; they do not establish exhaustive mutation coverage.

The example uses an in-memory H2 database. It does not prove recovery after process restart, multi-instance behavior, concurrent delivery, external broker operation, or performance. Before relying on durability in production, repeat the failure cases with your persistent database and add process-kill/restart tests, migration checks, payload-compatibility checks, and recovery observability.

Bring it into an existing system one use case at a time

Start with a business capability whose responsibility is clear. Describe its language, invariants, owner, exposed decisions, and acceptable delay. Keep that description beside the use-case model and record consequential consistency choices in an ADR.

Then group code around the capability, hide repositories and domain internals, and expose a small API. Add verify() to CI before the next cross-module shortcut arrives. For notifications that may lag, introduce a stable event contract and a failure test. For decisions that must hold immediately, keep the required coordination explicit.

Avoid a shared common package that gradually becomes the real domain model. Shared technical utilities are one thing; a supposedly universal Customer or Order entity is a business coupling decision. Likewise, an open module can help during migration, but should have an owner and a plan rather than becoming a permanent route around the boundary rules.

The review loop should include domain experts as well as developers. A green dependency graph cannot tell you whether the system reflects the business. A useful release review asks whether the use case still matches the model, whether the UI communicates intermediate states, whether the quality requirements hold, and whether the tests demonstrate the behavior the team is promising.

Spring Modulith gives that work a concrete place in a Spring Boot codebase. Its value comes from connecting the model, the API contract, the transaction decision, and the evidence—not from the number of modules or events in the application.