A developer needs one more field for an order confirmation. The inventory module already has it, so they import a convenient internal class. The code compiles. The feature works. The boundary on the architecture diagram has just disappeared.

That is a practical problem worth solving before splitting an application into more services. A modular monolith only stays modular when its boundaries survive everyday changes.

Spring Modulith makes those boundaries testable. In this article, we build a small Java example, verify its structure, then deliberately break it in three different ways. The useful result is a build that rejects changes the Java compiler accepts.

Why this deserves attention now

The September 2026 Spring news offers a useful reminder that maintainability includes the ability to change dependencies safely. On September 21, the Spring team announced a consolidated release train, with the next regular patch train scheduled for October 22 and a milestone-only train on September 24. That schedule is a release-management signal; teams still need to assess relevant advisories when they appear. Spring release announcement

Alongside that change, the Spring Modulith documentation currently identifies 2.1.1 as stable and 2.2.0-M2 as a preview. This example uses 2.1.1. The architecture verification shown here is an established capability, not a feature newly invented in the milestone. Spring Modulith version status

For a Java and Spring team, the interesting direction is executable architecture: keeping design decisions close enough to the code that a build can challenge them. A small structural test gives reviewers useful feedback whenever a feature or dependency update changes the codebase.

Start with a business boundary

Consider a deliberately small stock-reservation use case. Orders may ask inventory to reserve a quantity. Inventory owns the remaining stock. Pricing is a separate module whose API is not needed by this use case.

Our intended dependency is orders → inventory. Inventory must not call back into orders. Orders must not manipulate inventory's storage implementation. A future pricing dependency should require an explicit design decision.

One deployment contains orders, inventory and pricing. Orders may call the inventory API, but cannot access StockLedger or depend on pricing.

Figure 1. The allowed dependency belongs in code as well as in the diagram. Pricing is intentionally disconnected in this small example.

These names express responsibility, but they do not prove we have found the right domain model. Discuss what “reserve” means with the people who own the process. Does it expire? Can a customer cancel? When is payment taken? Does a confirmation mean stock is reserved or merely requested? Those answers shape the interface and the user experience.

Begin with one use case, capture its awkward cases, and refine the boundary as you learn. Keep the use-case model, interface, tests, quality requirements and architecture decision record aligned through each iteration.

Make the package structure express the decision

The example uses this arrangement beneath com.homannsoftware.shop:

ShopApplication.java
orders/
  package-info.java
  OrderService.java
inventory/
  package-info.java
  Inventory.java
  internal/StockLedger.java
pricing/
  package-info.java
  PriceQuote.java

With Spring Modulith's default detection, direct subpackages become application modules. A module's base package provides its API; nested packages are internal unless explicitly exposed. That matters because a Java public class in an internal package can be accessible to the compiler while remaining forbidden to other application modules. Named interfaces support more selective API exposure when the design grows. Module fundamentals

Here is the complete orders/package-info.java:

@org.springframework.modulith.ApplicationModule(allowedDependencies = "inventory")
package com.homannsoftware.shop.orders;

Inventory and pricing each declare allowedDependencies = {}. That closes their dependency list against other application modules; it is not a ban on using the JDK or ordinary libraries.

The calling code stays straightforward:

package com.homannsoftware.shop.orders;

import com.homannsoftware.shop.inventory.Inventory;
import java.util.Objects;

public final class OrderService {
    private final Inventory inventory;

    public OrderService(Inventory inventory) {
        this.inventory = Objects.requireNonNull(inventory);
    }

    public boolean place(int quantity) {
        return inventory.reserve(quantity);
    }
}

The Inventory facade delegates to an internal ledger. The ledger implements the small example's rule: accept a positive quantity only when enough stock remains.

package com.homannsoftware.shop.inventory.internal;

public final class StockLedger {
    private int available;

    public StockLedger(int available) {
        if (available < 0) throw new IllegalArgumentException("negative stock");
        this.available = available;
    }

    public synchronized boolean reserve(int quantity) {
        if (quantity <= 0) throw new IllegalArgumentException("non-positive quantity");
        if (quantity > available) return false;
        available -= quantity;
        return true;
    }

    public synchronized int remaining() {
        return available;
    }
}

The synchronized methods protect this one in-memory ledger instance. They do not coordinate multiple application instances or preserve stock after a restart. In a real application, inventory needs a persistence and concurrency strategy matched to the business invariant.

Likewise, OrderService.place is intentionally only a reservation call. It does not persist an order, charge a card, support cancellation or deduplicate repeated requests. Keeping the example small lets us inspect the boundary without pretending to have built a checkout system.

Put the architecture into the build

The project pins Spring Boot 4.1.0 and Spring Modulith 2.1.1, targets Java 21, and includes spring-modulith-api, the test-scoped spring-modulith-core, and Spring Boot's test starter. Download the complete Maven project and test logs (ZIP). These are the tested versions, not a recommendation to freeze production dependencies indefinitely.

This is the complete architecture test:

package com.homannsoftware;

import com.homannsoftware.shop.ShopApplication;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;

class ArchitectureTest {
    @Test
    void moduleBoundariesHold() {
        var modules = ApplicationModules.of(ShopApplication.class);
        assertEquals(Set.of("orders", "inventory", "pricing"),
            modules.stream().map(m -> m.getIdentifier().toString())
                .collect(Collectors.toSet()));
        modules.verify();
    }
}

The explicit module-name assertion is worth keeping. A passing verification is less reassuring if a package move accidentally means the expected modules are no longer detected.

For this closed-module arrangement, verify() checks cycles, access to internal packages and explicitly allowed dependencies. It reports violations by throwing an exception, which makes the test fail. Verification reference

The other eight tests cover ordinary behavior: successful reservation, exact depletion, insufficient stock without mutation, invalid quantities, invalid initial stock, a missing dependency, integer limits and concurrent reservations. In the concurrent case, 200 tasks request one item each from a ledger containing 50; exactly 50 reservations must succeed and the remainder must be zero.

Prove that the test can fail

A green architecture test is a starting point. We also want evidence that it catches the violations we care about.

The accompanying verify_mutations.py temporarily adds one forbidden field at a time, runs a clean build with the architecture test, records the output and restores the original sources. Each field is legal Java. The script checks for the expected architectural diagnostic and explicitly rejects a compilation error as evidence of success.

Deliberate change Why it matters Observed result
OrderService references inventory.internal.StockLedger Bypasses the inventory API Rejected: non-exposed type
OrderService references pricing.PriceQuote Uses a public API outside its allowed dependency list Rejected: allowed targets are inventory
Inventory references orders.OrderService Creates a cycle with the existing orders → inventory edge Rejected: cycle detected; also violates inventory's empty dependency list

Verification workflow: nine baseline tests pass, three legal-Java mutations are rejected by the architecture test, and all nine tests pass again after restoration.

Figure 2. Three targeted negative checks show that this test rejects the intended structural violations. They are not an exhaustive mutation-testing campaign.

All nine baseline tests passed on Eclipse Temurin 21.0.11+10, using Maven 3.9.11 on Windows. All three deliberately invalid variants were rejected. After restoration, all nine baseline tests passed again. The package includes the commands, Maven output and machine-readable mutation results.

From the package's examples directory, with JDK 21, Maven and Python 3 available:

mvn -B clean test
python verify_mutations.py

On Windows, use python verify_mutations.py mvn.cmd if Maven is installed as a command script. You can also pass an absolute Maven path. The first build needs access to Maven Central. The mutation runner edits the example sources temporarily, so run it in a disposable working copy and avoid concurrent edits. It restores the sources in a finally block during normal execution and exceptions; a forcibly terminated process may require manual restoration.

A module test is not a production readiness certificate

This example tests class dependencies and in-memory behavior. It does not start a Spring application context, exercise database transactions, verify HTTP endpoints or prove recovery after a crash. Its application class is a scanning anchor, and the objects are constructed directly in tests.

Several important failure modes remain outside this check. An SQL statement could write another module's table. Reflection could resolve a class name dynamically. An API could be structurally public yet expose the wrong business concepts. A green module graph does not validate authorization, latency, data ownership or the correctness of the chosen domain boundaries.

For a real stock reservation, add database-backed tests for competing updates, rollback and retry behavior. Specify how duplicate requests are recognized and what a user sees while a reservation is pending. Trace each guarantee to the test or operational evidence that actually supports it.

Choose synchronous calls and events deliberately

Our example uses a synchronous call because the caller needs the reservation outcome immediately. That is an explicit choice for this use case.

Events can make sense for follow-up work, such as sending a confirmation after an order is committed. Spring's default event publication is synchronous. Moving processing to an asynchronous transactional listener changes failure handling. Spring Modulith offers a persistent event publication registry that records listener publications with the originating transaction and tracks completion, with APIs and configuration for recovery. Event publication reference

That support does not make an external side effect automatically happen exactly once. A retryable email, payment or warehouse request still needs its own duplicate-handling and recovery design. The code in this article does not test the event registry; treat this as the next design discussion, not as a demonstrated property of the sample.

Extract a service when the requirements justify it

One deployment can be a sensible choice when a team benefits from local calls and a shared release process. Separate services can be appropriate when a capability needs independent scaling, a separate failure boundary or deployment autonomy. Neither choice removes the need to define ownership.

Decision guide: keep a module when shared deployment fits; consider a service for concrete independent scaling, failure isolation or release needs; verify contracts, data ownership and recovery before extraction.

Figure 3. Deployment topology should follow explicit requirements. A clean module boundary helps preparation but does not make service extraction automatic.

Before extracting inventory, ask concrete questions. Can orders tolerate a timeout while reserving? Who owns reservation expiry? What happens if the remote reservation succeeds but its response is lost? Can the team operate the new service and diagnose a failure across both systems?

Record the decision, including the evidence that would cause the team to revisit it. An independent deployment is useful when it solves a real constraint and its operating cost is understood.

A practical next step for your Spring application

Pick one frequently changed business capability. Describe its allowed callers and dependencies, add one architecture test, and deliberately introduce an internal-package access to confirm that the build rejects it. Then connect that structural check to tests for the actual use case.

That is a small, reviewable improvement a team can make without a platform rewrite. It also fits the way Homann Software approaches architecture: domain responsibilities, clear interfaces, explicit quality requirements and testable decisions, refined through implementation and feedback.

The next time someone reaches for a convenient internal class, the build can start the design conversation before that shortcut becomes a permanent dependency.

Research checked September 29, 2026. Version status is time-sensitive. All diagrams are original; the featured illustration was generated for this article. Test results describe only the supplied example and environment.