The problem we will solve

A developer needs to invoice an order and reuses an internal billing class. The code compiles, but orders now depends on a detail that billing should be free to change. We will introduce that mistake and catch it with a test before deployment.

This example is deliberately small: no HTTP endpoints, database or actual invoice. UUIDs are demonstration identifiers. The goal is to verify package dependencies in an existing Spring Boot application.

1. Add the dependencies

Start with a Spring Initializr project that includes Spring Modulith and test support. Keep the Modulith BOM and the version compatible with your Spring Boot release generated by Initializr. With versions managed by that BOM, these are the relevant Maven dependencies; place the fragment inside <dependencies>.

pom.xml · dependencies
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-test</artifactId>
    <scope>test</scope>
</dependency>

2. Organise two business modules

Place the main class in io.reactor.shop. Under that root, orders and billing become modules. By convention, public types in each module’s root package form its API; subpackages such as billing.internal stay within its boundaries.

All files in this table are under src/main/java/io/reactor/shop/.

FileResponsibility
ShopApplication.javaApplication root
orders/OrderService.javaOrders module
orders/package-info.javaAllowed dependencies
billing/BillingService.javaPublic billing API
billing/internal/InvoiceNumberGenerator.javaInternal billing detail
ShopApplication.java
package io.reactor.shop;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

3. Expose the operation other modules need

Orders needs to request an invoice. Expose that operation through BillingService in the public billing package. The demonstration implementation returns an identifier; a real application would delegate invoice creation and persistence here.

billing/BillingService.java
package io.reactor.shop.billing;

import java.util.UUID;
import org.springframework.stereotype.Service;

@Service
public class BillingService {
    public UUID issueInvoice(UUID orderId) {
        // Demo: replace with invoice creation and persistence.
        return UUID.randomUUID();
    }
}

Add an internal class to reproduce the temptation to reuse an implementation detail. It does not need to be wired into the billing service to demonstrate the violation.

billing/internal/InvoiceNumberGenerator.java
package io.reactor.shop.billing.internal;

import java.util.UUID;
import org.springframework.stereotype.Component;

@Component
public class InvoiceNumberGenerator {
    public String next() {
        return "INV-" + UUID.randomUUID();
    }
}

4. Introduce a dependency Java permits

First create this incorrect version of OrderService. The internal class is public, so Java allows the import. Our architecture should still reject the dependency.

orders/OrderService.java · incorrect version
package io.reactor.shop.orders;

import io.reactor.shop.billing.internal.InvoiceNumberGenerator;
import org.springframework.stereotype.Service;

@Service
public class OrderService {
    private final InvoiceNumberGenerator numbers;

    public OrderService(InvoiceNumberGenerator numbers) {
        this.numbers = numbers;
    }

    public String placeOrder() {
        return numbers.next();
    }
}

5. Turn the boundary into a test

Create src/test/java/io/reactor/shop/ModularityTest.java. This check analyses code dependencies without starting the Spring context.

ModularityTest.java
package io.reactor.shop;

import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;

class ModularityTest {
    @Test
    void respectsModuleBoundaries() {
        ApplicationModules.of(ShopApplication.class).verify();
    }
}
Maven Wrapper
./mvnw -Dtest=ModularityTest test

On Windows use mvnw.cmd -Dtest=ModularityTest test. With the incorrect version, verify() should fail because orders accesses an internal billing package. Do not depend on the exact error wording: identify the source module and the referenced internal type.

6. Fix the code through the module API

Replace the contents of OrderService.java with this version. Keep only one definition of the class. Orders requests an invoice while numbering details remain inside billing.

orders/OrderService.java · corrected version
package io.reactor.shop.orders;

import java.util.UUID;
import io.reactor.shop.billing.BillingService;
import org.springframework.stereotype.Service;

@Service
public class OrderService {
    private final BillingService billing;

    public OrderService(BillingService billing) {
        this.billing = billing;
    }

    public UUID placeOrder() {
        UUID orderId = UUID.randomUUID();
        billing.issueInvoice(orderId);
        return orderId;
    }
}

Run the same command again. In this bounded example the check should pass: the dependency targets the billing API. You can now change invoice numbering without modifying orders.

7. Declare which dependencies you accept

To allow orders to depend only on billing, add this package-info.java. The identifier billing is the module name in this example. A future dependency from orders to another module will fail verification even if it uses that module’s public API.

orders/package-info.java
@org.springframework.modulith.ApplicationModule(
    allowedDependencies = "billing"
)
package io.reactor.shop.orders;

What this example gives you

The rule now lives in a test you can run on every change, as well as in code review. Spring Modulith also checks module cycles. Include this test in your pipeline and check the before-and-after behaviour by temporarily restoring the incorrect import.

This protection applies to static dependency analysis; it does not turn packages into runtime security boundaries. The example also leaves transactions, consistency and event delivery to be designed when the business workflow needs them.

The practical benefit: catch a dependency accepted by the Java compiler in a test, then fix it through a public contract between modules.
Reference documentation