What a module boundary is and why it erodes

A module, for the purposes of this article, is a unit of code with three properties: a declared public API that other code may call, private internals that nothing outside the module may touch, and an explicit list of other modules it is allowed to depend on. If any of the three is missing, you have a folder, not a module.

This is written for teams running a growing monolith with several contributors, where at least one plausible future involves extracting a service. Boundaries are what keep that option cheap. Once billing code reaches directly into the internals of the user module, extraction stops being a refactor and becomes an archaeology project.

Most teams start with conventions: an internal subpackage, a naming prefix, a note in the contributing guide. Code review is meant to catch the rest. Both fail for the same reason. Each violation is locally reasonable. Someone under deadline pressure imports one helper class from another module because it already does what they need, and the reviewer, who is also busy, sees a small, working diff. Nobody approves erosion. It accumulates one defensible import at a time.

The real design decision is which enforcement layer catches which kind of violation. The compiler can block access to private members, the build system can reject undeclared dependencies between units, and architecture tests can check rules that neither of those can express.

Before adding tooling, map the definition onto what you already have: top-level packages, namespaces, or build subprojects. Usually the module candidates are already visible in that structure. What is missing is the rules.

Writing dependency rules as a reviewable artifact

Write the boundaries down before you enforce them. The rules belong in one small file in the repository, not in a wiki page or someone's memory. The file should be an allow-list. Each module declares which modules it may depend on, and everything not listed is forbidden. An allow-list fails closed: a new dependency has to be added on purpose, while a deny-list silently permits whatever nobody thought to ban.

A minimal domain-based rules file for an ordering system might declare:

  • web → may depend on orders, billing, catalog
  • orders → may depend on catalog, shared
  • billing → may depend on orders, shared
  • catalog → may depend on shared
  • persistence adapters → may depend on the domain module they serve, never the reverse
  • no cycles between any modules

The forbidden edges matter as much as the allowed ones. orders → web is forbidden, and so is catalog → orders. orders → billing would close a cycle with billing → orders, so it is forbidden too. Cycles are what turn two modules into one module that has two names.

Assign the file named owners through your code-owners mechanism. Then any boundary change requires an explicit review instead of slipping in alongside a feature.

Two warnings apply here. First, draw boundaries along business capabilities, not technical layers like "services" and "repositories". Wait until those capabilities are stable, because premature boundaries create churn. Second, shared is exempt from direction rules, so it attracts everything. Admit only stable, domain-free utilities to it, and require owner approval for each addition.

# module_rules.py: the reviewable allow-list. Give it owners via CODEOWNERS, e.g.
#   /module_rules.py  @example-org/architecture
# Any edge not listed here is forbidden (fail closed).
RULES = {
    "web": {"orders", "billing", "catalog"},
    "orders": {"catalog", "shared"},
    "billing": {"orders", "shared"},
    "catalog": {"shared"},
    "persistence.orders": {"orders", "shared"},  # adapter -> domain, never the reverse
    "shared": set(),  # stable, domain-free utilities only; depends on nothing
}

def find_cycle(rules):
    """Return a list of modules forming a cycle, or None."""
    visiting, done = set(), set()
    def visit(node, path):
        if node in visiting:
            return path[path.index(node):] + [node]
        if node in done:
            return None
        visiting.add(node)
        for dep in sorted(rules.get(node, ())):
            cycle = visit(dep, path + [node])
            if cycle:
                return cycle
        visiting.discard(node)
        done.add(node)
        return None
    return next((c for m in sorted(rules) if (c := visit(m, []))), None)

def check_edge(src, dst):
    """Is an observed import from module src to module dst allowed?"""
    return src == dst or dst in RULES.get(src, set())

if __name__ == "__main__":
    cycle = find_cycle(RULES)
    assert cycle is None, "rules contain a cycle: " + " -> ".join(cycle)
    assert check_edge("billing", "orders")
    assert not check_edge("orders", "billing")  # would close a cycle with billing -> orders
    assert not check_edge("catalog", "orders")  # not listed, so forbidden
    print("module rules OK")

A small allow-list of module dependencies, kept in a code-owned file, that rejects cycles and treats any unlisted edge, such as orders to billing, as forbidden.

How each enforcement layer works: visibility, build units, and architecture tests

The three enforcement layers differ mainly in when a violation surfaces and which kinds of rules each can express.

Package visibility

Package-private members in Java, unexported identifiers in Go, and internal in Kotlin or C# are checked by the compiler, so they cost nothing to run and cannot be skipped. Their weakness is scope. Most of these modifiers draw the line at a single package or a single compilation unit. A module that spans several packages therefore has to make its internals public, and once they are public, every other module can reach them. Go's internal directories and qualified exports in Java's module system come closer to expressing a sibling-only rule. Neither can express direction rules such as "billing may call identity, but identity may not call billing."

Separate build units

When each module is its own subproject or compilation target, it must declare its dependencies explicitly. An undeclared import does not resolve, so the build fails. No other layer is as strict. The cost is real, though: more build configuration, slower builds when caching is poorly tuned, and friction every time a boundary moves.

Architecture tests

Architecture tests run assertions against the import graph or the bytecode as part of CI. They can express layering, direction, cycle bans, and naming rules. They run after compilation, however, and they only see static references. Calls made through reflection, wiring done by a DI container, lookups by string, and coupling through serialized payloads all go undetected.

Layer Strictness Feedback Expressiveness Cost Blind spots
Visibility High, narrow Compile time Low Near zero Multi-package modules
Build units Highest Compile time Medium High Runtime wiring
Architecture tests Medium CI run High Low–medium Reflection, DI, strings, serialization

Implementing architecture tests in the normal test suite

Architecture tests are ordinary unit tests that read source or compiled output, build a graph, and assert on it. They run with every other test, so a violation fails the same pipeline a broken assertion would.

Build the graph once

Parse each source file's import statements, or read compiled dependencies, and map every file to its module by package prefix. That gives you two views. The file-level edges let you check package rules. The module-level edges come from collapsing files into modules, and those let you check cycles. Build the graph in a shared fixture so that each rule is a cheap query rather than a fresh scan.

Three core rules

  • No reaching into internals. Flag any edge where the source module differs from the target module and the target path contains the internal package of that module.
  • No cycles between modules. Run a depth-first search on the module graph and report the full cycle path, not just one edge.
  • Domain stays pure. The domain packages must have no edges into web or persistence packages.

Guard against empty rules

Before evaluating a rule, assert that its target package matches at least one file. A renamed directory otherwise makes the rule match nothing. It then passes forever and gives you false confidence.

Messages that teach

A failure should name three things: the rule, the offending edge, and the fix. For example: [no-internal-imports] billing/invoice.ts imports orders/internal/pricing; use orders/api.getQuote instead. A reviewer can act on that without opening the rules file.

// Tool-agnostic architecture test: plain Node + any test runner's test/expect.
import { buildGraph } from './arch/graph'; // returns { files: string[], edges: {from: string, to: string}[] }

const graph = buildGraph('src'); // build once, share across rules
const moduleOf = (p: string) => p.split('/')[1]; // src/<module>/...

function requireTarget(rule: string, prefix: string) {
  // Guard: a renamed package must fail loudly, not silently match nothing.
  if (!graph.files.some((f) => f.startsWith(prefix))) {
    throw new Error(`[${rule}] target '${prefix}' matches no files; update the rule after renames.`);
  }
}

test('no-internal-imports', () => {
  requireTarget('no-internal-imports', 'src/orders/internal/');
  const bad = graph.edges.filter(
    (e) => moduleOf(e.from) !== moduleOf(e.to) && e.to.includes(`/${moduleOf(e.to)}/internal/`),
  );
  const msgs = bad.map(
    (e) => `[no-internal-imports] ${e.from} imports ${e.to}; use src/${moduleOf(e.to)}/api instead.`,
  );
  expect(msgs).toEqual([]);
});

test('domain-is-pure', () => {
  requireTarget('domain-is-pure', 'src/billing/domain/');
  const bad = graph.edges.filter(
    (e) => e.from.includes('/domain/') && /\/(web|persistence)\//.test(e.to),
  );
  const msgs = bad.map(
    (e) => `[domain-is-pure] ${e.from} imports ${e.to}; depend on a port interface in domain/ instead.`,
  );
  expect(msgs).toEqual([]);
});

Architecture tests that query a shared import graph, fail when a rule's target package no longer exists, and print violations naming the rule, the offending edge, and the fix.

Designing the public API and data ownership of each module

Each module exposes one public package, an api facade, and everything else is internal. The facade accepts and returns transfer types: plain, immutable records defined in the API package. Internal entities, ORM models, and domain objects never cross the boundary. If billing holds a reference to an orders entity, it depends on that entity's fields, lazy-loading behavior, and invariants, and the boundary is gone even when the import rule passes.

When two modules need each other, break the cycle with events or callbacks. orders publishes OrderPlaced and billing subscribes, so the dependency points one way. The rest of the facade design has costs too. Mapping code between entities and transfer types is tedious, and near-identical types appear on both sides. Each call goes through an extra layer. Event-driven flow hides control flow: no call site shows that placing an order creates an invoice. Keep a registry of events and their subscribers so that edge stays reviewable.

Data ownership

Each module owns its tables, and other modules read that data only through the facade. Import checks can't see SQL, so enforce ownership in the database. Give each module its own schema and database role, and grant each role access only to its own schema. If you can't separate schemas, keep an ownership map from table to module and check table references in queries and migrations against it.

Some coupling evades both kinds of check. Shared cache keys, transactions that span modules, and long synchronous call chains all couple modules at runtime. Catch these in design review and in tracing.

-- One role per module. Each module's app connects only as its own role.
-- Placeholder passwords; load real ones from your secret store.
CREATE ROLE orders_module  LOGIN PASSWORD 'change-me-orders';
CREATE ROLE billing_module LOGIN PASSWORD 'change-me-billing';

-- Each module owns its schema. The owner can create and use objects in it.
-- Other roles get no privileges on a new schema unless they are granted.
CREATE SCHEMA orders  AUTHORIZATION orders_module;
CREATE SCHEMA billing AUTHORIZATION billing_module;

-- Unqualified table names resolve to the module's own schema.
ALTER ROLE orders_module  SET search_path = orders;
ALTER ROLE billing_module SET search_path = billing;

-- Stop modules from placing shared tables in public as a workaround.
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

-- Run migrations as the owning role so it owns its tables.
SET ROLE orders_module;
CREATE TABLE orders.orders (
  id          bigint PRIMARY KEY,
  customer_id bigint NOT NULL,
  total_cents bigint NOT NULL
);
RESET ROLE;

-- A billing query that reaches into orders now fails instead of
-- silently coupling the modules:
SET ROLE billing_module;
SELECT total_cents FROM orders.orders WHERE id = 42;
-- ERROR:  permission denied for schema orders
RESET ROLE;

-- Superusers and roles with BYPASSRLS-style broad grants skip these checks,
-- so never let application code connect as a superuser.

A PostgreSQL setup that gives each module its own schema and login role, so when one module queries another module's tables, the database rejects it with a permission error.

Adopting enforcement in a codebase that already violates the rules

Turning on enforcement in a codebase that already breaks the rules usually turns CI red everywhere at once. Teams then either disable the check or spend weeks fixing violations before they can merge anything else. A baseline avoids both. Run the checks once, write every current violation to a file in the repository, and from then on treat only violations missing from that file as failures. The rules apply in full to new code, and existing debt is recorded where everyone can see it.

A baseline-comparison step

The CI job runs the architecture checks and writes the violations it finds in a stable order. Each violation is identified by the source module, the target module and the symbol, not by line number, because line numbers change with unrelated edits. The job compares that list with the committed baseline:

  • A violation that isn't in the baseline fails the build.
  • A baseline entry that no longer occurs also fails the build, and the job asks the author to delete it. This way the file shrinks as fixes land.
  • The job prints the current count and how it changed, for example "baseline: 142 (−3)".

Keeping the baseline from becoming permanent

The biggest risk is organisational, not technical. If nobody owns the baseline, it becomes a permanent list of approved violations, and regenerating it whenever a build fails becomes the easy fix. Habits that help:

  • Name an owner for each module's entries, and review the count on a regular schedule.
  • Date every entry so long-standing violations are easy to spot and rank.
  • Protect the file with code-owner review, so no one can add to it in the same pull request that introduces a violation without someone else seeing it.
  • Show the count on every build so a stalled baseline stays visible.
#!/usr/bin/env bash
# CI step: fail on violations not in the baseline, and on stale baseline entries.
set -euo pipefail
export LC_ALL=C  # comm needs both inputs sorted with the same collation

BASELINE=architecture-baseline.tsv  # format: <date-added>\t<source> -> <target> :: <symbol>

# Placeholder for your boundary checker. It must print one violation per line
# as "<source> -> <target> :: <symbol>" (no line numbers, so entries stay stable).
./scripts/check-boundaries > current.txt

sort -u current.txt > current.sorted
cut -f2- "$BASELINE" | sort -u > baseline.sorted  # drop the date column before comparing

new=$(comm -13 baseline.sorted current.sorted)    # only in current: new debt
fixed=$(comm -23 baseline.sorted current.sorted)  # only in baseline: already fixed

base_count=$(wc -l < baseline.sorted)
cur_count=$(wc -l < current.sorted)
# Report the count on every build so a stalled baseline stays visible.
echo "baseline: ${base_count} entries, current: ${cur_count} ($((cur_count - base_count)))"

status=0
if [ -n "$new" ]; then
  echo "New boundary violations (fix them; do not add them to $BASELINE):"
  echo "$new"
  status=1
fi
if [ -n "$fixed" ]; then
  echo "Fixed violations still listed in $BASELINE (delete these lines):"
  echo "$fixed"
  status=1
fi
exit "$status"

A CI shell step that compares the current boundary violations with a dated baseline file. It fails on new violations and on stale entries, and prints the count on every build.

When to avoid strict boundaries, alternatives, and impact on cost and engineering time

Strict enforcement pays off only when the boundaries are right. Hold off, or keep it light, when the team is small enough that everyone reviews everything, when the product is early and the domain seams are still moving, or when the rules would lock in a design hypothesis nobody trusts yet. Enforcing a wrong boundary makes the wrong design expensive to undo.

Approach Upfront cost Guarantee
Conventions plus review Near zero Only as good as each reviewer's attention
Package visibility only Low Compiler blocks access to internals; does not limit which public APIs a module calls
Architecture tests Moderate Explicit dependency rules checked in CI
Full multi-module build High Build graph forbids undeclared dependencies
Separate services Highest Network isolation, but adds deployment, versioning and distributed failure modes

The costs are real. Many build units slow builds and complicate IDE setup. Rules need maintenance. When a legitimate boundary has to move, someone must change the rules, the build files and the baseline together.

The savings come from catching violations in CI instead of in review or after an incident. Explicit rules also settle review arguments, and clean seams make later refactors or service extraction cheaper.

Illustrative estimate (assumed numbers, not a benchmark). Monthly hours saved = violations caught × (hours to fix late − hours to fix in CI) − rule maintenance hours. Assume 6 violations, 3 hours to fix late, 0.5 hours to fix in CI, and 4 hours of maintenance: 6 × (3 − 0.5) − 4 = 11 hours. If the result is negative for your inputs, keep enforcement light for now.

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 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.