ArchUnit Boundary Tests That Survive the Deadline
ArchUnit Boundary Tests That Survive the Deadline
The Smell
The logistics platform team agreed in a team meeting: “The billing package should only depend on shipment through shared events.” The decision was written in a Confluence page. Two sprints later, a developer under deadline pressure adds a direct import from billing to shipment.ShipmentRepository to fix a production bug. The code review is rushed. The Confluence page is not checked. The boundary is violated. The violation creates a precedent, and two more developers add similar imports over the next month.
The team meeting achieved consensus. It did not achieve enforcement. Consensus without enforcement is a suggestion that expires under pressure.
The Cognitive Cost
Every unenforced architectural rule creates a tax on code reviewers. The reviewer must hold the rule in working memory, notice when it is violated, and decide whether to block the pull request over it. Under deadline pressure, the reviewer’s working memory is already full with the feature’s business logic. The architectural rule gets dropped. The violation gets approved.
Automated enforcement removes this burden entirely. The reviewer does not need to remember the rule because the CI pipeline enforces it. If the rule is violated, the build fails. The reviewer’s working memory is freed for design-level feedback that automation cannot provide.
The Before
// HARD TO READ: Architectural rules exist only in documentation.
// No automated enforcement. Violations are caught inconsistently in code review.
// From a Confluence page titled "Architecture Rules" (last updated 8 months ago):
// - Billing should not directly depend on Shipment internals
// - Carrier adapters should not be used outside the carrier package
// - Warehouse should not depend on Billing
//
// These rules are correct. They are also unenforceable.
// A developer who has never read this Confluence page will violate them
// without knowing a rule exists.
package com.logitrack.billing;
import com.logitrack.shipment.ShipmentRepository; // Violates the rule
import com.logitrack.shipment.Shipment; // Violates the rule
import com.logitrack.carrier.UpsAdapter; // Violates the rule
public class InvoiceReconciler {
private final ShipmentRepository shipments; // Direct dependency on shipment internals
private final UpsAdapter upsAdapter; // Direct dependency on carrier internals
public void reconcile(Long invoiceId) {
// Reads shipment data directly instead of using events or a public API
Shipment shipment = shipments.findById(/* ... */);
// Calls carrier adapter directly instead of going through CarrierGateway
var tracking = upsAdapter.getTracking(/* ... */);
// ...
}
}
The Fix
// READABLE: Every architectural rule is an executable test.
// The CI pipeline enforces what the Confluence page could not.
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
class ModuleBoundaryTest {
private final JavaClasses classes = new ClassFileImporter()
.importPackages("com.logitrack");
@Test
void billing_does_not_depend_on_shipment_internals() {
noClasses()
.that().resideInAPackage("..billing..")
.should().dependOnClassesThat()
.resideInAPackage("..shipment..")
.as("Billing must not depend on Shipment internals. " +
"Use shared events from com.logitrack.shared.event instead.")
.check(classes);
}
@Test
void carrier_adapters_are_not_accessed_outside_carrier_package() {
classes()
.that().haveSimpleNameEndingWith("Adapter")
.and().resideInAPackage("..carrier..")
.should().notBePublic()
.as("Carrier adapters are internal to the carrier package. " +
"Use CarrierGateway as the public API.")
.check(classes);
}
@Test
void warehouse_does_not_depend_on_billing() {
noClasses()
.that().resideInAPackage("..warehouse..")
.should().dependOnClassesThat()
.resideInAPackage("..billing..")
.as("Warehouse and Billing are independent modules. " +
"If warehouse needs billing data, use a shared read model.")
.check(classes);
}
@Test
void only_gateway_classes_are_public_in_feature_packages() {
classes()
.that().resideInAnyPackage(
"..carrier..", "..billing..", "..warehouse..")
.and().arePublic()
.should().haveSimpleNameEndingWith("Gateway")
.orShould().haveSimpleNameEndingWith("Event")
.orShould().haveSimpleNameEndingWith("Service")
.orShould().beInterfaces()
.as("Only Gateway, Event, Service classes, and interfaces " +
"should be public in feature packages.")
.check(classes);
}
}
Each ArchUnit test has a custom .as() message that explains why the rule exists and what the developer should do instead. When the test fails, the developer sees:
Rule 'Billing must not depend on Shipment internals.
Use shared events from com.logitrack.shared.event instead.'
was violated (1 times):
Class com.logitrack.billing.InvoiceReconciler depends on
com.logitrack.shipment.ShipmentRepository
The failure message is a review comment that writes itself. The developer knows what they did wrong and how to fix it without asking anyone.
For migrating an existing codebase with many violations, use ArchUnit’s freeze feature:
@ArchTest
void billing_boundary_no_new_violations(JavaClasses classes) {
FreezingArchRule.freeze(
noClasses()
.that().resideInAPackage("..billing..")
.should().dependOnClassesThat()
.resideInAPackage("..shipment..")
).check(classes);
// Existing violations are recorded in a baseline file.
// The test passes for existing violations but fails for new ones.
// The baseline file shrinks as violations are fixed.
}
The freeze approach allows the team to enforce boundaries immediately without fixing every existing violation first. New code must respect the boundary. Existing violations are tracked in a file that shrinks over time. Progress is visible and measurable.
The Rule
Every architectural decision that was made in a meeting should have a corresponding ArchUnit test added in the same sprint. If the decision is important enough to discuss as a team, it is important enough to enforce automatically. The ArchUnit test replaces the Confluence page. It is always up to date because it runs on every commit. It catches violations before code review because it runs in CI. It provides a clear fix message because the .as() clause tells the developer what to do instead. If a boundary rule exists only in documentation, it will be violated the next time someone is under deadline pressure.