What a module boundary is and why it needs executable tests
A module boundary is three rules that hold at the same time:
- Public API versus internals. Each module exposes a small
apipart. Everything else isinternal, and no other module may import it. - Dependency direction. Modules depend on each other in one agreed direction, for example
orders → billing, never the reverse. - No cycles. No chain of imports leads back to its starting module, however indirect the chain is.
+-----------------+ +-----------------+ +-----------------+
| orders | | billing | | customers |
| api |------>| api |------>| api |
| internal | | internal | | internal |
+-----------------+ +-----------------+ +-----------------+
| ^
+----------- X forbidden: orders -> customers.internal
Allowed: orders -> billing.api, billing -> customers.api
Forbidden: any import of another module's internal; any reverse arrow
Boundaries that exist only in a diagram or wiki page erode because nothing checks them. A single shortcut import compiles, passes review because it looks harmless in a diff, and becomes a precedent. After a few months the diagram describes a system that no longer exists. Architecture tests turn each rule into a build failure, so a violation is caught in the pull request that introduces it.
Backend and platform engineers on a growing modular monolith get the most from this, especially when several teams commit to one repository. In that setting nobody holds the whole dependency graph in their head.
Write the rules first as a short list that reviewers can read in one pass, such as: "orders may use billing.api; nothing outside billing imports billing.internal; no cycles between top-level modules." Each line should map to exactly one test. When a rule changes, the list and its test change together in the same pull request.
+----------------+ +----------------+ +----------------+
| orders | | billing | | customers |
+----------------+ +----------------+ +----------------+
| api | +->| api | +->| api |
+----------------+ | +----------------+ | +----------------+
| internal |--+ | internal |--+ | internal |
+----------------+ +----------------+ +----------------+
| ^
+--------------------- X ----------------------+
X = forbidden: orders -> customers.internal
Allowed: orders -> billing.api
billing -> customers.api
Forbidden: any import of another module's internal
any reverse arrow (billing -> orders, customers -> billing)
any cycle, however indirect
Each module exposes an api part and hides an internal part: orders may call billing.api and billing may call customers.api, while the marked import from orders into customers.internal breaks the boundary and must fail the build.
Mapping modules to packages and folders the tools can match
Rules can only be as precise as the layout they match against. The simplest layout gives each module one top-level location and splits it into exactly two parts: api for what other modules may use, and internal for everything else.
On the JVM, that looks like:
com.example.billing.apicom.example.billing.internalcom.example.shipping.apicom.example.shipping.internal
In TypeScript, the same structure becomes folders:
src/modules/billing/api/src/modules/billing/internal/src/modules/shipping/api/src/modules/shipping/internal/
With a consistent shape, one generic pattern can cover every module. In ArchUnit, that pattern is ..internal... In dependency-cruiser, it is a regex such as ^src/modules/([^/]+)/internal/ paired with a back-reference to the same module name. A new module is covered as soon as it follows the convention.
Inconsistent naming breaks this. If one team uses impl, another uses internal, and a third leaves classes in the module root, each variant needs its own rule or exception. Those lists drift as code moves, and an unmatched package passes silently instead of failing.
TypeScript path aliases add a further trap. An import like @billing/internal/ledger is not a file path. dependency-cruiser matches rules against resolved file paths, so it has to understand your paths mapping. Point options.tsConfig.fileName in the dependency-cruiser config at the tsconfig.json that defines those aliases. Without it, aliased imports stay unresolved and never match a path rule, so violations go unreported.
// .dependency-cruiser.js
// Assumes tsconfig.json maps aliases to real folders, e.g.
// "paths": { "@billing/*": ["src/modules/billing/*"] }
module.exports = {
forbidden: [
{
name: 'no-cross-module-internal',
comment: 'Other modules may only import a module through its api/ folder.',
severity: 'error',
// Capture the importing module's name as $1.
from: { path: '^src/modules/([^/]+)/' },
to: {
// Any module's internal/ folder...
path: '^src/modules/[^/]+/internal/',
// ...unless it belongs to the same module as the importer.
pathNot: '^src/modules/$1/'
}
},
{
name: 'no-unresolvable',
comment: 'An import that cannot be resolved can never match a path rule, so fail loudly.',
severity: 'error',
from: {},
to: { couldNotResolve: true }
}
],
options: {
doNotFollow: { path: 'node_modules' },
// Lets dependency-cruiser turn '@billing/internal/ledger' into
// 'src/modules/billing/internal/ledger.ts' before rules are applied.
tsConfig: { fileName: 'tsconfig.json' },
// Also count type-only imports, which disappear after compilation.
tsPreCompilationDeps: true
}
};
A dependency-cruiser config in which one generic rule, using a back-reference to the importing module's name, blocks imports of other modules' internal folders, while the tsConfig reference resolves path aliases to real files so that the rule matches them.
Implementing the rules with ArchUnit on the JVM
On the JVM, add ArchUnit as a test-scoped dependency. For JUnit 5, use the archunit-junit5 artifact so rules run through the normal test engine. Annotate a test class with @AnalyzeClasses, point packages at your root package, and set importOptions = ImportOption.DoNotIncludeTests.class. Test fixtures often reach into module internals on purpose. If you import them, every rule starts failing for reasons nobody cares about.
Declare each rule as an @ArchTest field. To protect internals, write noClasses().that().resideOutsideOfPackage("..billing..").should().dependOnClassesThat().resideInAPackage("..billing.internal.."). For cycles between modules, use slices().matching("com.example.(*)..").should().beFreeOfCycles(). The capture group makes each top-level package one slice. Allowed access between layers belongs in layeredArchitecture().consideringAllDependencies(), where you name each layer with definedBy and restrict callers with whereLayer(...).mayOnlyBeAccessedByLayers(...). These are ordinary JUnit tests, so a violation fails mvn test or gradle test like any other assertion.
End every rule with .because(...) and state the fix, not the rule: "call BillingFacade instead of billing.internal classes." That text appears in the failure message, so a developer who has never seen the architecture document still knows what to change.
A rule can also pass vacuously. If someone renames billing to invoicing, ..billing.. matches nothing, and the rule stays green forever. Call .allowEmptyShould(false) on each rule, or set archRule.failOnEmptyShould=true in archunit.properties. Set it explicitly rather than relying on your version's default, so a rule that matches no classes fails instead of silently protecting nothing.
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
// Import production classes only; test fixtures may touch internals on purpose.
@AnalyzeClasses(packages = "com.example", importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleBoundaryTest {
// Only billing itself may use its internals. The reason states the fix.
@ArchTest
static final ArchRule billingInternalsArePrivate = noClasses()
.that().resideOutsideOfPackage("..billing..")
.should().dependOnClassesThat().resideInAPackage("..billing.internal..")
.because("call BillingFacade instead of billing.internal classes")
.allowEmptyShould(false); // fail if a rename leaves nothing to check
// Each top-level package under com.example is one slice; no cycles between them.
@ArchTest
static final ArchRule noModuleCycles = slices()
.matching("com.example.(*)..")
.should().beFreeOfCycles()
.because("move the shared type into a module both sides depend on")
.allowEmptyShould(false);
// Allowed access between layers, counting dependencies on outside classes too.
@ArchTest
static final ArchRule layers = layeredArchitecture().consideringAllDependencies()
.layer("Web").definedBy("..web..")
.layer("Service").definedBy("..service..")
.layer("Persistence").definedBy("..persistence..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Web")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service")
.because("route web calls through a service; never query repositories from controllers");
}
A JUnit 5 ArchUnit test that excludes test classes, protects billing internals, rejects cycles between top-level packages, and defines allowed layer access, with each rule giving a fix-oriented reason.
Implementing the same rules with dependency-cruiser for JavaScript and TypeScript
On the JavaScript and TypeScript side, install the tool with npm install --save-dev dependency-cruiser, then run npx depcruise --init. The init command asks a few questions about your project layout and writes a .dependency-cruiser.js file with sensible defaults. Keep those defaults, and add your boundary rules to the forbidden array.
Each rule takes a from and a to path regular expression. Three rules cover most monoliths:
- No reaching into another module's internals. Match
from: { path: "^src/modules/([^/]+)/" }andto: { path: "^src/modules/([^/]+)/internal/", pathNot: "^src/modules/$1/" }. The$1back-reference lets one rule cover every module, so a new module is protected without anyone editing the config. - No cycles. Set
to: { circular: true }. A cycle between modules means neither one can be extracted or tested on its own. - Restricted infrastructure. Only listed modules may import
src/infra/. Express this withpathNoton thefromside.
Give every rule a comment field that states its intent. When a rule fails in CI, dependency-cruiser prints the comment, so the developer sees why the rule exists, not just which rule fired.
Add "lint:deps": "depcruise src --config .dependency-cruiser.js" to package.json and run it in CI. Rules with error severity make the command exit non-zero, which fails the build. To see how the code is actually structured, use --output-type dot and pipe the result through Graphviz. This often turns up dependencies nobody knew were there.
| Boundary rule | ArchUnit | dependency-cruiser | Drift risk in polyglot monoliths |
|---|---|---|---|
| No access to internals | noClasses()…resideInAPackage("..internal..") |
forbidden with $1 back-reference |
Package and folder names diverge after renames |
| No cycles | slices().matching(…).should().beFreeOfCycles() |
to: { circular: true } |
Low; the semantics match |
| Restricted infrastructure | onlyBeAccessed().byAnyPackage(…) |
pathNot allow-list |
Allow-lists are updated in one config and not the other |
// .dependency-cruiser.js: boundary rules added to the forbidden array generated by `npx depcruise --init`
module.exports = {
forbidden: [
{
name: "no-foreign-internals",
comment: "A module may only use another module's public API. Import from its index, not its internal/ folder.",
severity: "error",
// $1 captures the importing module's name, so one rule covers every module, including new ones.
from: { path: "^src/modules/([^/]+)/" },
to: { path: "^src/modules/([^/]+)/internal/", pathNot: "^src/modules/$1/" },
},
{
name: "no-circular",
comment: "Cycles mean neither side can be tested or extracted on its own. Break them with an interface or an event.",
severity: "error",
from: {},
to: { circular: true },
},
{
name: "infra-only-from-allowed-modules",
comment: "Only billing and orders talk to infrastructure directly. Other modules go through their public APIs.",
severity: "error",
// Placeholder allow-list: replace billing|orders with your own modules. infra may import itself.
from: { path: "^src/", pathNot: "^src/(modules/(billing|orders)/|infra/)" },
to: { path: "^src/infra/" },
},
],
options: {
// Check only first-party code. Do not crawl into installed packages.
doNotFollow: { path: "node_modules" },
},
};
// package.json scripts:
// "lint:deps": "depcruise src --config .dependency-cruiser.js"
// To view the real dependency graph (needs Graphviz installed):
// npx depcruise src --config .dependency-cruiser.js --output-type dot | dot -T svg > deps.svg
A dependency-cruiser config with three commented forbidden rules: one blocks imports into another module's internal folder, one blocks circular dependencies, and one limits which modules may import src/infra. Below it are the CI script and the command that renders the dependency graph.
Rolling out rules on a codebase that already has violations
Introduce these tests before the boundaries decay further. Every week without them adds imports that will need untangling later. Don't encode the whole target architecture on day one. Start with two rules that are cheap to explain and expensive to break: no cycles between modules, and no access to another module's internals. Add finer-grained layering rules once those hold.
Baseline existing violations
A rule that fails on hundreds of existing violations will be disabled within a week. Record what exists today and fail the build only on new violations.
- ArchUnit: wrap the rule with
FreezingArchRule.freeze(...). The first run stores the current violations. Later runs report only violations that are not in the store. When someone fixes a stored violation, ArchUnit removes it from the store, so the baseline can only shrink. - dependency-cruiser: run
depcruise-baselineto write the known-violations file, then run the check with--ignore-knownin CI.
Commit both stores. That turns any change to the baseline into a diff someone has to review. Give the baseline a named owner, usually the team that owns the target module. Track its size over time; the line count of the store files is enough. A baseline that never shrinks is a suppression list with extra steps.
Make exceptions explicit
Don't regenerate the baseline to make a build pass. If a new dependency is genuinely needed, handle it in one of two ways:
- add it to the rule's allow-list with a comment explaining why, or
- open a pull request that changes the baseline and gets the same review as an API change.
Choose what to tighten next
Use the violation reports and dependency-cruiser's graph output to see where violations cluster. A few module pairs usually account for most of them. Those pairs show you which boundary to enforce next, or which one is drawn in the wrong place.
Limits, failure modes and when to avoid architecture tests
Both tools see only static references: imports, type usages and calls the compiler or parser can resolve. Anything wired at runtime is invisible to them. That includes a class loaded through reflection, an implementation injected by a DI container behind an interface, a dynamic import() with a computed path, and a handler resolved by a string key. Coupling with no import edge is also missed. Two modules that read and write the same database table are coupled, and so are a producer and consumer that depend on the exact shape of an event payload. A green build proves that the declared dependency rules hold. It does not prove the modules are decoupled.
Rules also decay in several ways:
- Renames break matching. A rule keyed to a package or folder pattern can quietly stop matching after a rename, and a rule that matches nothing usually passes. Check periodically that each rule still selects real code.
- Baselines become amnesty. A frozen list of known violations with no owner and no shrinking target turns into permanent permission.
- Strict rules feed a dumping ground. When rules are too tight, engineers move code into a catch-all
sharedmodule that everything depends on. The tangle comes back under a new name.
The tools enforce the decomposition you declared. They cannot tell you whether that decomposition is right. Boundaries inside a monolith also give you neither independent deployment nor runtime isolation. Everything still ships as one artifact, runs in one process and fails together.
Architecture tests are often not worth it in these cases:
- Small codebases, where the whole team can keep the structure in their heads.
- Short-lived code, such as prototypes and migration scaffolding.
- Unsettled boundaries. While you are still discovering the module seams, rules freeze guesses and add friction to every refactor that would improve them.
Alternatives and impact on cost and engineering time
Architecture tests are one of several ways to keep a dependency graph honest. They differ mainly in whether a violation fails a build, how much restructuring they need, and whether anything is isolated at runtime.
| Approach | Enforcement strength | Setup cost | Runtime isolation |
|---|---|---|---|
| Architecture tests (ArchUnit, dependency-cruiser) | Fails CI on any rule you can express | Low; no code moves | None |
| Build-level modules (Gradle/Maven modules, workspace packages) | Compiler or resolver rejects undeclared dependencies | Medium to high; code and build files move | None |
| Language visibility (package-private, Java modules) | Compiler-enforced, but coarse | Low for package-private, high for Java modules | Partial, through module encapsulation |
| Lint import restrictions | Fails on matched import paths only | Low | None |
| Code review conventions | Depends on reviewer attention | Near zero | None |
| Separate services | Network boundary | Very high | Full |
On the cost side, ArchUnit spends time importing classes and dependency-cruiser spends time resolving modules. You can reduce both by scanning only production code, excluding third-party dependencies, and running the checks as a separate CI job in parallel with unit tests. If your monolith spans the JVM and TypeScript, you also maintain rule sets in two toolchains, and those rule sets can drift apart.
The time savings come from where violations get caught. An architecture test flags a bad dependency at PR time, before other code builds on it. A good failure message names the forbidden edge, so the author can fix it without waiting on a review round-trip.
Illustrative estimate (assumed numbers, not a benchmark):
net engineer-minutes per week = (violations caught × minutes saved each) − rule maintenance minutes
With 3 violations caught × 40 minutes saved each − 45 minutes of maintenance, the net is 75 minutes per week. CI machine time is separate: 200 runs × 20 s comes to about 67 minutes of runner time.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to enforce module boundaries monolith, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.