A team can usually agree that ordering should use the payments API without knowing how payments is implemented. Keeping that agreement intact over several years of ordinary feature work takes a little more care. An import is easy to add, and the compiler has never attended the architecture meeting.
In an existing Java application, the awkward part of introducing architecture tests is often deciding what to do with the failures. Turning on every rule at once can leave the team with a substantial new backlog and a build nobody is keen to open.
I would start with one boundary whose responsibilities are clear enough to discuss. Here is a small example of how to do that, followed by the decisions I would make before putting the check into a shared build.
Agree on the dependency first
Suppose ordering needs to request a payment. Payments exposes an interface:
// src/main/java/com/shop/payments/api/PaymentGateway.java package com.shop.payments.api; public interface PaymentGateway { void pay(); } The implementation belongs inside payments:
// src/main/java/com/shop/payments/internal/PaymentGatewayImpl.java package com.shop.payments.internal; import com.shop.payments.api.PaymentGateway; public class PaymentGatewayImpl implements PaymentGateway { @Override public void pay() {} } The empty method keeps payment processing out of the example. We are interested in who depends on the implementation, so there is no need to introduce a payment provider just to give the build something to object to.
Ordering currently imports the implementation directly:
// src/main/java/com/shop/ordering/internal/OrderService.java package com.shop.ordering.internal; import com.shop.payments.internal.PaymentGatewayImpl; public class OrderService { private final PaymentGatewayImpl payments; public OrderService(PaymentGatewayImpl payments) { this.payments = payments; } public void placeOrder() { payments.pay(); } } All of this compiles. The class is public, and Java gives a package named internal no special protection. If the implementation needs to be public for use across packages within payments, another module can reach it too.
Before writing the test, agree on the restriction with the team: ordering may depend on the payments API, while payment implementation types remain private to the payments module. If that agreement is still contested, a failing build is a rather inconvenient place to hold the discussion.
Put one restriction into a test
The example uses Modulith Rules, an open-source library I maintain that provides pre-built ArchUnit rules. The core works without Spring and is added in test scope. You will need Java 17 or newer, with these dependencies:
<dependency> <groupId>io.modularmonolith</groupId> <artifactId>modulith-rules-core</artifactId> <version>0.2.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>6.1.2</version> <scope>test</scope> </dependency> Register the two modules and select the internal-package rule:
// src/test/java/com/shop/architecture/ArchitectureTest.java package com.shop.architecture; import com.tngtech.archunit.core.importer.ClassFileImporter; import io.modulith.rules.ModulithRules; import org.junit.jupiter.api.Test; class ArchitectureTest { @Test void modulesDoNotAccessEachOthersInternals() { var classes = new ClassFileImporter().importPackages("com.shop"); ModulithRules.forPackage("com.shop", "ordering", "payments") .boundaryRules() .internalsShouldNotBeAccessedFromOutside() .check(classes); } } The configuration derives com.shop.ordering and com.shop.payments from the root package and module names. The rule checks access across those registered modules, using the library's internal-package conventions.
With the direct implementation dependency, the test fails and identifies access from OrderService to PaymentGatewayImpl. That gives us a specific dependency to review, along with the code that introduced it.
Make the smallest useful change
Ordering already has an interface it can use, so change its import, field type and constructor parameter:
package com.shop.ordering.internal; import com.shop.payments.api.PaymentGateway; public class OrderService { private final PaymentGateway payments; public OrderService(PaymentGateway payments) { this.payments = payments; } public void placeOrder() { payments.pay(); } } The architecture test now passes. The application still needs to supply a payment implementation when it is assembled, through its composition root or dependency injection configuration. That wiring is a separate concern from the dependency we have just removed from ordering.
The complete example contains Maven projects for both versions. Run mvn test in boundary-example/before to see the violation, then in boundary-example/after to see the same test pass.
Decide what to do with existing violations
In a larger codebase, the first run may find several dependencies that cross this boundary. Read them before deciding how to respond. Some may be straightforward mistakes; others may support behaviour that will take more work to untangle.
I would record the dependencies that need a migration, identify who owns that work, and choose a scope the team can reasonably bring into compliance. A focused check is useful when everyone understands which part of the system it covers and which dependencies still need attention.
If a violation needs to remain temporarily, keep that decision explicit and reviewable. Quietly leaving a module out of the configuration can make the test pass, but it also removes the protection people may assume they have. A migration plan should make those gaps visible.
Once the selected boundary is in a state the team accepts, run the test in CI alongside the existing tests. Future changes can then bring the same dependency decision back into review while the code is still fresh in somebody's mind.
Give a green result some context
The test imports classes under com.shop, but these module rules govern the registered module packages. Classes outside them are ignored. Register the modules you intend to inspect, and check the package names carefully.
The selected rule also matters. The example protects internals; it does not apply every policy in the library. allRules() groups allowed dependencies, internal-package protection and cycle detection. allCoreRules() adds API-only access and communication-contract checks.
Some settings are deliberately permissive. An empty allowedDependencies list leaves a module unrestricted by that rule, and API-only access skips target modules with no configured API packages. The documentation describes these defaults, which deserve a read before a green build becomes evidence in a design discussion.
I would also introduce a deliberate violation before trusting the check. If a known bad dependency passes, investigate the registration and selected policy while the example is still small enough to understand.
Keep the wider design in view
Two modules can pass a Java dependency test while sharing a database table that forces them to change together. Static checks also tell us little about what happens when an event consumer crashes after performing part of its work. Data ownership and failure handling still need their own decisions and tests.
If you already use Spring Modulith, check its existing verification first. ApplicationModules.verify() already checks module cycles, internal access and explicitly allowed dependencies, and it can run during tests. An additional library should earn its place through a policy you need or a framework-independent core.
The Modulith Rules documentation covers configuration and rule behaviour, and the repository includes a wider example application.
A useful first check protects a decision the team understands and can maintain. Start there, verify that it catches a dependency you know should fail, and let the next boundary earn its place in the build.
