What are monolith module ownership architecture tests?
Monolith module ownership architecture tests answer two separate questions about every file in a shared codebase:
- Who owns this code? A mapping from paths or packages to teams.
- What may this code depend on? A rule set evaluated over that mapping.
Review-routing files answer only the first question. They make sure the right team is asked to approve a change, but they have no opinion about what the change imports. If the payments team reaches into orders.internal.pricing, the orders team may be pulled into review. Whether that call survives then depends on how busy the reviewer is and how persuasive the author sounds. Over time, those negotiated exceptions become the architecture.
Architecture tests close that gap. Tools like ArchUnit (for JVM bytecode) and dependency-cruiser (for JavaScript and TypeScript import graphs) run in CI and fail the build when a forbidden dependency appears. The boundary is decided once, in code, instead of being reargued in every pull request.
| Concern | Review routing | Architecture tests |
|---|---|---|
| Question answered | Who must approve? | Is this dependency allowed? |
| When it acts | At review time | At build time, on every change |
| Failure mode | Reviewer misses or waives it | Build goes red |
| Input | Path-to-team mapping | Same mapping plus rules |
The connecting piece is a single module manifest. Each entry names a module, its owning team, and its root path. Each module exposes one API package, and everything else in the module is internal. Review routing is generated from the manifest's team field. The dependency rules are generated from the same entries: code outside a module may import only that module's API package. Because both come from one source, ownership and enforced boundaries cannot drift apart.
+---------------------------------------------+
| module manifest (one source of truth) |
| orders team-orders orders/ |
| payments team-payments payments/ |
| each module: one API package, rest internal|
+----------------------+----------------------+
+--------------------+------------------+
v v
+------------------------------+ +------------------------------+
| review routing file | | dependency rules |
| Q1: who must approve? | | Q2: what may this import? |
| team field -> reviewers | | outside code -> API only |
+------------------------------+ +--------------+---------------+
|
v
+------------------------------+
| architecture tests in CI |
| violation => build fails |
+------------------------------+
+--------------------+ +-------------------------------+
| payments module | | orders module (team-orders) |
| | ok | +-----------------------+ |
| payments code ----+--------+--->| orders.api | |
| | | | +-----------------------+ |
| | | FAIL | +-----------------------+ |
| +-----------+---X----+--->| orders.internal.* | |
| | | +-----------------------+ |
+--------------------+ +-------------------------------+
One module manifest generates both review routing (who must approve) and the dependency rules that CI architecture tests enforce (what may be imported), so a call from payments into orders.api passes while a call into orders.internal fails the build.
Why and when should teams enforce boundaries in a monolith?
Boundary rules pay off when the same argument keeps happening in code review. In a monolith shared by several teams, a common pattern is that someone imports a repository class from another team's module because it is the shortest path to the data. A reviewer notices, explains the convention, and suggests the public API instead. A month later a different engineer does the same thing. Each instance costs a review round-trip and some goodwill. The convention lives in reviewers' heads, so it is applied unevenly.
An executable rule settles the question before review. The build fails on the offending import. Because the rule is generated from the ownership manifest, the failure message can name the owning team and point to the sanctioned entry point, for example "billing.internal is owned by Payments; use billing.api.InvoiceQueries." The developer fixes it locally. The reviewer no longer has to argue about it and can spend the time on design.
The same manifest answers "who owns this?" when that is unclear. That matters for incident routing and for deciding who approves changes. It also helps when a module is a candidate for later extraction into a separate service. Driving its inbound dependencies down to the API package first makes the eventual split a smaller, more predictable change.
Hold off if one small team owns everything, since shared context already does this job. Also wait while module boundaries are still moving week to week. Rules written against an unstable structure mostly generate churn in the rule files.
How do you draw the initial module map and ownership manifest?
Start from the directory or package structure you already have, because that is what developers navigate and what the tools will match against. Treat it as a first guess. Then generate the real import graph, using a dependency-cruiser report for JavaScript or TypeScript or a package dependency analysis for Java, and compare the two. Where the folders say "billing" but half the imports come from "orders", the graph is telling you where the actual seams are.
Assign every top-level module to exactly one owning team. Joint ownership turns into no ownership as soon as a failing rule needs someone to act on it. Code that nobody claims should be listed as orphaned, with a named team holding it temporarily. Leaving it unassigned only hides the problem.
Cross-cutting code such as auth, logging and domain primitives (money, IDs, time) belongs in an explicit platform category with its own owner. Keep that category narrow. If "platform" becomes the place for anything awkward, it turns into a second monolith that every team depends on.
For each module, the manifest records the owning team, the paths it covers, its public entry point (an api package or a single index file), and the modules it may depend on. Everything outside the entry point counts as internal. Keep the manifest in the repository as the single source of truth, and have CI either generate the review-routing file and the architecture rules from it or fail when they disagree with it. If you maintain ownership in three places by hand, they will drift apart.
# modules.yaml: single source of truth for module ownership.
# CI generates the review-routing file and the architecture rules from this file,
# or fails if either one disagrees with it.
modules:
billing:
owner: team-payments
paths: [src/billing/**]
public: src/billing/api/index.ts # the only importable entry point
may_depend_on: [orders, platform-money, platform-auth]
orders:
owner: team-checkout
paths: [src/orders/**]
public: src/orders/api/index.ts
may_depend_on: [platform-money, platform-ids, platform-auth]
# Platform modules are cross-cutting and owned by one team.
# Keep them narrow: primitives and infrastructure only, no business rules.
platform-auth:
owner: team-platform
category: platform
paths: [src/shared/auth/**]
public: src/shared/auth/index.ts
may_depend_on: [platform-ids]
platform-money:
owner: team-platform
category: platform
paths: [src/shared/money/**]
public: src/shared/money/index.ts
may_depend_on: []
# Nobody claimed this code. It is recorded as orphaned with a temporary
# holder so failing rules still reach a real team.
legacy-reports:
owner: team-checkout
status: orphaned
paths: [src/reports/**]
public: src/reports/index.ts
may_depend_on: [orders, billing]
An ownership manifest kept in the repository that gives each module one owning team, its paths, a single public entry point and its allowed dependencies, and that marks platform and orphaned code explicitly so review routing and architecture rules can be generated from it.
How do you write ownership rules with ArchUnit and dependency-cruiser?
JVM: ArchUnit rules as unit tests
ArchUnit rules run with the normal test task, so a boundary violation fails the build like any broken assertion. A rule such as classes().that().resideInAPackage("..billing..") combined with .should().onlyBeAccessed().byAnyPackage("..billing..") locks a module down completely. The more useful form for ownership is noClasses().that().resideOutsideOfPackage("..billing..") with .should().dependOnClassesThat().resideInAPackage("..billing.internal.."). It still lets other teams use billing.api. Add slices().matching("..modules.(*)..").should().beFreeOfCycles() so modules cannot become entangled through each other.
Use .because(...) on every rule. ArchUnit puts that text in the failure output, so write it for the person who just broke the build, for example: "billing.internal is owned by team-payments; call billing.api.InvoiceService instead."
JS/TS: dependency-cruiser in CI
In .dependency-cruiser.js, each forbidden rule has a name, a severity, a comment, and from/to path regexes. To block imports of another module's internals, match from with pathNot: "^src/billing/" and to with path: "^src/billing/internal/". The rule name is printed with every violation, so encode the owner in it, as in payments-billing-internal-only, and put the sanctioned entry point in comment. Run depcruise as its own CI step. Periodically render the actual graph with --output-type dot and compare it to the module map, because the code often has edges nobody drew.
| Aspect | ArchUnit | dependency-cruiser |
|---|---|---|
| Runs as | Unit test | CLI step in CI |
| Boundary key | Package patterns | File path regexes |
| Owner and alternative shown via | .because() text |
Rule name and comment |
In a polyglot monolith, keep one rule set per ecosystem and generate both from the shared manifest. Otherwise they will drift apart.
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;
// Runs with the normal test task (JUnit 5), so a boundary violation fails the build
// like any other failing assertion. Production classes only; test code is excluded.
@AnalyzeClasses(packages = "com.example", importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleOwnershipTest {
// Field name doubles as the rule name in reports: owner first, then intent.
// Other modules may use billing.api; only billing itself may touch billing.internal.
@ArchTest
static final ArchRule payments_billing_internal_only =
noClasses().that().resideOutsideOfPackage("..modules.billing..")
.should().dependOnClassesThat().resideInAPackage("..modules.billing.internal..")
.because("billing.internal is owned by team-payments; "
+ "call billing.api.InvoiceService instead");
// Each direct subpackage of modules is a slice; no module may reach
// back into another through a chain of dependencies.
@ArchTest
static final ArchRule modules_free_of_cycles =
slices().matching("com.example.modules.(*)..")
.should().beFreeOfCycles()
.because("module cycles blur ownership; break the cycle via an api "
+ "interface or an event owned by one team");
}
An ArchUnit JUnit 5 test class that lets other modules use billing.api while blocking billing.internal, forbids cycles between modules, and uses the rule names and because() text to name the owning team and the approved alternative.
How do you adopt architecture tests on a codebase that already violates them?
Rules that fail on day one get disabled by day two. The fix is a baseline: record every existing violation, fail the build only on new ones, and make the recorded list shrink over time.
ArchUnit supports this by wrapping a rule with FreezingArchRule.freeze(...). On the first run, it writes current violations to a violation store, which by default is plain files you commit next to the tests. Later runs fail only on violations that are not in the store. When someone fixes a stored violation, ArchUnit removes the entry, so the store only shrinks unless you explicitly allow it to grow. dependency-cruiser works the same way. You generate a known-violations file with the baseline output type and pass it back with --ignore-known. Known violations are then reported separately from new ones and do not fail the run.
The risk is a baseline nobody owns. It becomes permanent, and the CI check looks green while hundreds of cross-team calls stay in place. Assign the baseline file to a named owner, usually the platform team. Report its entry count per module in CI output, and review that number on a fixed schedule.
Governance matters as much as tooling. Protect the manifest, rule files, and baseline files with your review tool's path-ownership rules so that only designated reviewers can merge changes to them. A new exception should need approval from both the team that owns the calling module and the team that owns the called module. Record every exception as dated debt with an owner and a target removal date.
#!/usr/bin/env bash
# CI check for dependency-cruiser with a known-violations baseline.
set -euo pipefail
BASELINE=.dependency-cruiser-known-violations.json
# One-time setup, run locally and committed by the baseline owner:
# npx depcruise src --config .dependency-cruiser.js \
# --output-type baseline --output-to "$BASELINE"
# Fail only on violations that are not in the baseline.
# Known violations are still reported, but they do not fail the run.
npx depcruise src --config .dependency-cruiser.js --ignore-known "$BASELINE"
# Print the baseline size per top-level module so the trend shows in CI logs.
echo "Baseline entries per module:"
jq -r '.[].from' "$BASELINE" | cut -d/ -f2 | sort | uniq -c | sort -rn
# The baseline may shrink but never grow without review.
# Compare its size against the main branch.
current=$(jq 'length' "$BASELINE")
previous=$(git show origin/main:"$BASELINE" | jq 'length')
echo "Baseline size: $previous on main, $current on this branch"
if [ "$current" -gt "$previous" ]; then
echo "Baseline grew. Adding an exception needs approval from both affected teams," >&2
echo "plus an owner and a target removal date in the exception record." >&2
exit 1
fi
A CI script that fails only on new dependency-cruiser violations, prints the baseline size per module, and blocks any change that makes the known-violations file grow.
When should you avoid relying on architecture tests, and what do they miss?
Architecture tests check only the edges the tool can resolve from source or bytecode. Several kinds of coupling cross boundaries without ever failing a build:
| Coupling path | Visible to static rules? | Supplement with |
|---|---|---|
| Direct imports and calls | Yes | The rules themselves |
| Reflection, string-based lookups | No | Code review, runtime tracing |
| DI container wiring | Partially, depending on configuration | Container startup assertions |
| Dynamic imports | Only with literal paths | Lint bans on computed paths |
| Shared database tables | No | Per-module schema ownership, query logs |
Strict rules also change how people write code. When a direct call is blocked, developers tend to copy the logic, move it into common or utils, or widen an API package until it exposes internals. A falling violation count can hide this. Track the size and churn of shared modules and public API packages alongside the count.
The rules also encode the current org chart. After a reorg, an ownership manifest that still names the old teams is worse than having none. Tests keep passing, so the manifest looks authoritative, and incidents get routed to people who no longer know the code. Some violations mean the module split itself is wrong. Changing the map should be a small reviewed edit, not a migration project.
Run a scheduled job that compares the declared map with the resolved dependency graph and with commit authorship per module. If most recent commits to a module come from a team other than its declared owner, flag the module for a decision. Either update the manifest or move the work back to the owning team.
What are the alternatives, and what do architecture tests cost?
Architecture tests sit between purely social enforcement and structural enforcement.
| Approach | Enforcement strength | Setup effort | Runtime coupling |
|---|---|---|---|
| Review-routing files alone | Weak: routes reviews, never blocks a forbidden import | Low | Unchanged |
| Conventions plus code review | Weak: depends on reviewer attention | Low | Unchanged |
| Architecture tests | Medium: fail CI, but anyone can edit the rule file | Medium | Unchanged |
| Build modules or packages with enforced visibility | Strong: the compiler or build rejects undeclared dependencies | High: cycles must be broken first | Unchanged, one deployable |
| Separate services | Strong at the code level | Very high | Adds network calls, partial failure and versioned contracts |
Architecture tests are often a stepping stone. A rule that has held for months shows that a boundary is clean enough to become a build module.
The costs fall into four areas:
- Initial mapping. Most of this effort goes into deciding who owns ambiguous shared code, and those decisions need agreement between teams.
- Rule maintenance. Each rule needs an owner, or exceptions build up until nobody trusts the rule.
- CI time. Both tools analyse the whole codebase. On large repositories, run them in a separate parallel job.
- Reorganizations. When teams split or merge, the manifest has to change in the same pull request as the reorg.
Scope rules to the boundaries that carry real risk, such as access to another team's persistence layer or internals. Do not try to model every package relationship. Each extra rule adds maintenance without adding much protection.
Illustrative estimate (assumed numbers, not a benchmark). Annual cost = (rules × hours per rule per year) + (reorgs × modules affected × hours per module). Assume 15 rules × 2 h, plus 2 reorgs × 10 modules × 0.5 h. That gives 30 + 10 = 40 hours per year.
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 monolith module ownership architecture tests, 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.