What a module boundary is and why only a failing build keeps it
In a modular monolith, a module is a top-level package or directory that maps to one bounded context, such as billing, catalog or identity. Each module exposes a small public entry point: an api package on the JVM, or an index.ts barrel in TypeScript. Everything else in the module is internal. Other modules may call the entry point. They may not reach into repositories, entities or helper classes behind it.
Language visibility rarely holds this line by itself. Java's package-private scope stops at the package, so internal subpackages have to be public to talk to each other. TypeScript lets any file import any path. An import that skips the public API compiles cleanly, passes review because the diff looks small, and makes the deadline. Each one is a reasonable local decision. Taken together, they turn the module graph into a mesh over time.
Documentation and review checklists don't stop this, because nobody reads them at the moment the import is written. A failing build does stop it. Once the rule is in CI, a boundary violation becomes a red pipeline instead of a judgment call. That moves the conversation to the right place: either change the allow-list on purpose, or use the public API.
This approach suits backend and platform teams that share a monolith or a polyglot repository. It pays off in three situations:
- Several teams commit to the same codebase.
- A module is expected to be extracted into a separate service later.
- Coupling is already visible, for example when a change in one context keeps breaking tests in another.
+------------------------------+ +------------------------------+
| catalog module | | billing module |
| | | |
| +------------------------+ | OK | +------------------------+ |
| | service code |--+---------+->| public API | |
| +-----------+------------+ | | | api pkg / index.ts | |
| | | | +-----------+------------+ |
| | | | | |
| | | | v |
| | | BLOCKED | +------------------------+ |
| +---------------+---X-----+->| internal | |
| | by CI | | repositories, entities | |
| | | | helpers | |
| | | +------------------------+ |
+------------------------------+ +------------------------------+
-----> allowed: other modules call only the public entry point
--X--> forbidden: deep import into another module's internals;
an ArchUnit or ESLint rule turns it into a failing CI build
Each bounded-context module exposes a small public API and hides everything else, so a call from catalog into billing's API passes while a direct import of billing's internals fails the build.
Writing the dependency allow-list as one source of truth
Before writing any rule, decide which module may depend on which, and write that down as an allow-list. Each module declares the modules it is permitted to import, plus a shared or kernel module if you have one. Every dependency not listed is forbidden.
Most teams start with a deny-list such as "billing must not import orders internals" because it mirrors the violation they just found. A deny-list only covers the cases someone thought of. A new module arrives with no rules, so it can import anything. An allow-list starts from "nothing is permitted", so a new module cannot depend on anything until someone adds an entry, and that entry goes through review.
| Aspect | Deny-list | Allow-list |
|---|---|---|
| New module added | Unrestricted until someone writes rules | Isolated until edges are declared |
| New kind of violation | Passes silently | Fails by default |
| Rule count as modules grow | Grows with every bad edge you find | Grows with every legitimate edge |
| What review shows | Edges someone banned | The full intended dependency graph |
Keep the map in one small file at the repository root, such as YAML or JSON. List one module per entry with its allowed dependencies, and add a comment explaining why each edge exists. Give the file an owner through your code-review tooling so that adding an edge needs approval from someone accountable for the architecture.
Polyglot repositories add a drift risk. ArchUnit tests and ESLint configuration are separate artifacts, so a person can loosen one and forget the other. The safest setup generates both rule sets from the shared file. If you cannot generate them, add a CI check that fails when either rule set allows an edge the map does not list.
# modules.yaml: single source of truth for allowed module dependencies.
# Any edge not listed here is forbidden. Both the ArchUnit tests (JVM)
# and the ESLint boundary config (TypeScript) are generated from, or
# checked against, this file in CI.
# Ownership: assign this file to the architecture reviewers in your
# code-review tooling so that new edges require their approval.
shared:
- kernel # cross-cutting types (Money, Clock, IDs) every module may use
modules:
kernel:
allow: [] # the kernel depends on nothing
catalog:
allow: [] # owns product data and reads nothing else
orders:
allow:
- catalog # orders look up product prices at checkout
billing:
allow:
- orders # invoices are created from the public API of completed orders
notifications:
allow:
- orders # sends order confirmation emails
- billing # sends payment receipts
# A new module added here with an empty allow-list is isolated by default.
# Adding an edge is a one-line diff that shows up clearly in review.
A root-level YAML allow-list that maps each module to the modules it may depend on, with a comment justifying each edge, so the ArchUnit and ESLint rule sets can be generated from it or checked against it.
How to implement architecture tests with ArchUnit on the JVM
ArchUnit rules are ordinary JUnit tests, so they run wherever your unit tests already run and fail the same build. With the JUnit 5 integration, annotate a test class with @AnalyzeClasses, point it at your root package, and pass ImportOption.DoNotIncludeTests in importOptions. Excluding test classes matters: test fixtures often reach into several modules on purpose, and including them produces violations nobody intends to fix.
Each rule is a static final ArchRule field marked @ArchTest. To keep billing out of the orders module's internals, write noClasses().that().resideInAPackage("..billing..").should().dependOnClassesThat().resideInAPackage("..orders.internal.."). Generate one such rule per forbidden edge from the shared allow-list rather than hand-writing them.
Then add slices().matching("com.example.(*)..").should().beFreeOfCycles(). The capture group turns each top-level package into a slice, so the check covers every module, including ones added after you wrote the rule. Cycle freedom matters more than any single rule. Once two modules depend on each other, neither can be changed, tested or extracted alone, and the allow-list stops describing a layered system. Individual rules catch the edges you thought of. The cycle check catches the shapes you didn't.
Finish every rule with .because(...), since ArchUnit prints that text with the violation. Give it the owning team and the fix: "orders.internal is owned by the Orders team; call OrdersApi instead, or ask in their channel for a new method." A failure that says who to talk to gets fixed. A bare package-name mismatch gets suppressed.
package com.example.architecture;
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.dependencies.SlicesRuleDefinition.slices;
// Import only production code. Test fixtures deliberately cross modules
// and would produce violations nobody intends to fix.
@AnalyzeClasses(
packages = "com.example",
importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleBoundaryTest {
// One forbidden edge: billing must not reach into orders' internals.
// The because() text is printed with the violation, so name the owner and the fix.
@ArchTest
static final ArchRule billingDoesNotUseOrdersInternals =
noClasses().that().resideInAPackage("..billing..")
.should().dependOnClassesThat().resideInAPackage("..orders.internal..")
.because("orders.internal is owned by the Orders team; call OrdersApi instead, "
+ "or ask in their channel for a new method");
// Each top-level package under com.example becomes a slice via the capture group,
// so modules added later are covered without editing this rule.
@ArchTest
static final ArchRule modulesAreFreeOfCycles =
slices().matching("com.example.(*)..")
.should().beFreeOfCycles()
.because("mutually dependent modules cannot be changed, tested or extracted alone; "
+ "move the shared type into one module's public API or a new shared module");
}
A JUnit 5 ArchUnit test class that imports only production classes, forbids billing from depending on orders.internal, and requires all top-level modules to be free of cycles. Each rule carries a because() reason that names the owning team and the fix.
How to enforce the same boundaries in TypeScript with ESLint
The same allow-list translates to TypeScript, but the tooling checks import strings rather than compiled bytecode, and that changes what can go wrong.
Two ways to express the rule
The built-in no-restricted-imports rule is enough for a first pass. Its patterns option accepts glob-style groups, so you can forbid anything matching @app/orders/* while still permitting @app/orders itself, which resolves to the module's index.ts. Each pattern takes a custom message, so carry over the ownership hint from the JVM rules. The limitation is that the rule only looks at the literal specifier. A relative path like ../../orders/internal/pricing does not match an alias pattern, so you need patterns for both forms or a rule that bans parent-relative imports across module roots.
A boundaries-style plugin goes further. It assigns each file to an element type based on its resolved path, then checks imports against an allow-list of element pairs. This maps more directly to the allow-list from earlier, and it catches relative and aliased imports the same way.
Resolver configuration is the failure point
Path-based plugins depend on an import resolver that understands your tsconfig paths and your workspace package mappings. If the resolver cannot resolve @app/orders, the plugin usually treats the target as unknown or external and skips it. The lint run stays green and enforces nothing.
To guard against this, add a fixture file that deliberately imports another module's internals. In CI, assert that linting this fixture fails, so a broken resolver breaks the build too.
// eslint.config.js (flat config)
import tseslint from "typescript-eslint";
export default [
{
files: ["src/**/*.ts", "test/boundary-fixtures/**/*.ts"],
languageOptions: { parser: tseslint.parser },
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
// "@app/orders" resolves to src/modules/orders/index.ts and is allowed.
// "@app/orders/internal/pricing" has two segments after the scope and is blocked.
group: ["@app/*/*"],
message:
"Import from the module's public entry point (e.g. '@app/orders'). Need more? Ask the owning team to export it from index.ts.",
},
],
},
],
},
},
];
// This rule only matches the literal specifier. A relative import such as
// "../../orders/internal/pricing" is not covered by the alias pattern above.
// You need extra patterns for relative forms, or a path-resolving boundaries plugin.
//
// Sanity check: test/boundary-fixtures/deep-import.ts contains a deliberate violation:
// import { computePrice } from "@app/orders/internal/pricing";
// CI must see ESLint fail on that file. The leading "!" inverts the exit code,
// so the step passes only when lint reports an error:
// ! npx eslint test/boundary-fixtures/deep-import.ts
// The fixture is outside src/, so normal lint runs over src/ stay green.
A flat ESLint config that uses the built-in no-restricted-imports rule to allow '@app/' but block deep alias imports into another module's internals, with a deliberate-violation fixture that CI must see fail.
Adopting rules in an existing codebase with a frozen baseline
A legacy codebase will fail most new rules on the first run, and fixing hundreds of violations before merging the rule is rarely realistic. ArchUnit handles this with FreezingArchRule. You wrap an existing rule with freeze, and on the first run it records every current violation in a violation store, which is plain text files by default. Later runs fail only on violations that are not in the store. When someone fixes a recorded violation, ArchUnit removes it from the store, so the baseline can only shrink unless someone deliberately regenerates it. Commit the store to the repository so that every change to it appears in a diff.
That diff is your main defense against permanent debt. Treat any pull request that adds lines to the store as a failed check that needs explicit justification. Track the baseline's size over time and set a target for reducing it. A baseline that never shrinks is just an exception list.
Run architecture tests as their own CI job on every pull request so that a boundary failure is reported separately from unit test failures. Keep the job fast by narrowing the class import. Restrict @AnalyzeClasses to your application's root packages and exclude test classes and dependency JARs with import options. The full classpath is usually far larger than the code you own.
Finally, put the rule files and the violation store under code owners. Without that, a single commit could loosen a rule or add a violation to the baseline, and nobody responsible for the architecture would need to review it.
What static rules cannot see and when to avoid them
ArchUnit and ESLint inspect compile-time references between source files. Any coupling that never shows up as an import passes these checks, and that is often where the most serious coupling lives.
| Coupling path | Why import checks miss it | Pairing convention |
|---|---|---|
| Shared database tables | Two modules use the same table through SQL, with no code reference | Schema-per-module, with database grants limited to the owning module |
| Reflection | Classes are loaded from strings at runtime | Ban reflective access outside framework code |
| DI wiring by name | Beans or tokens are resolved from identifiers, not types | Wire only types from the module's public API |
| Events and messages | The payload shape is the contract, but nothing imports it | Versioned event schemas owned by the publisher |
| String-based configuration | Routes, topics and class names live in YAML or env files | Validate config against a declared integration contract |
Schema-per-module matters most here. Two modules joining across each other's tables are coupled whether or not the code is clean. Enforce the separation with database permissions, because a code rule can't block a query.
When to avoid or limit the approach
- The domain decomposition is still changing. Enforced boundaries slow down the refactoring you need to discover the right modules. Use coarse rules until the boundaries hold steady for a while.
- The rules are too fine-grained. Per-package or per-class allow-lists produce frequent false positives. Developers respond with suppression comments, and the rules lose their meaning. Review suppression counts the same way you review the baseline.
- Public API packages become dumping grounds. The build stays green when everything moves into
api. Size limits and code review of what each module exports catch this. The dependency rules alone won't.
Alternatives and impact on cost and engineering time
Architecture tests are one option among several, and they combine well with the others.
| Approach | What enforces the boundary | Main cost | Main gap |
|---|---|---|---|
| Review conventions only | Reviewer attention | Recurring review time | Misses violations when reviewers are busy or new |
| Build-tool modules (Gradle/Maven modules, workspace packages) | Compile-time dependency graph | Build configuration, slower restructuring | Coarse: cannot express "only the api package" |
Language visibility (package-private, JPMS, Kotlin internal, exports in package.json) |
Compiler or resolver | Forces package layout choices | Uneven across languages, easy to widen |
| ArchUnit + ESLint rules | CI test or lint failure | Two rule systems, CI minutes | Static only (see previous section) |
| Separate services | Network and deployment | Ops, latency, distributed data | Expensive to undo a wrong cut |
Build-tool modules and language visibility give the strongest guarantees because the code will not compile. Use them where boundaries are stable, and use rules for the finer-grained cases they cannot express. Splitting into services enforces boundaries most strongly, but it also adds the most cost, so it fits only once the boundary has already held up inside the monolith.
The main ongoing costs of rules are keeping the Java and TypeScript rule sets in sync, CI time, and the indirection of public api packages. Whether that pays off depends on how expensive a late fix is compared with a fix at pull request time.
Illustrative estimate (assumed numbers, not a benchmark): net hours/month = V × (L − P) − M
Assume V = 4 violations caught per month, L = 6 hours to untangle each one later, P = 0.5 hours to fix it at PR time, and M = 8 hours per month of rule maintenance plus CI wait. That gives 4 × 5.5 − 8 = 14 hours saved. If V drops to 1, the result is negative, which means the rules cost more than they save.
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 modular 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.