Skip to main content

Enforcing Module Boundaries in Code

What This Concept Is

Module boundaries that are not enforced do not exist. A rule that says "checkout should not import from catalog.domain" and is checked only in code review will be violated within six months. The rule must be a fitness function -- an automated check in CI that fails the build on violation.

Three levels of boundary enforcement, weakest to strongest:

  1. Naming and folder discipline. catalog/api/ vs catalog/internal/. Good as documentation, worthless as enforcement on its own.
  2. Language-level access control. Java package-private, Rust pub(crate), Go lowercase names, TypeScript package.json exports fields. Limits what can be imported. Strong when the language supports it cleanly.
  3. Architecture fitness functions. Rules in CI that check the dependency graph: ArchUnit (Java), NetArchTest (C#), Packwerk (Ruby), import-linter or custom (Python), dependency-cruiser or ESLint no-restricted-imports (TypeScript), go list + custom (Go). Catch every violation on every PR.

You need at least level 2, and for any non-trivial modular monolith, level 3.

Why It Matters Here

Concept 4 described the modular monolith. Concept 5 described how to measure module quality. This concept is how you prevent the structure from rotting on contact with a deadline. Without enforcement:

  • a hotfix skips the boundary "just this once"
  • a junior engineer imports from the wrong module because it was easier
  • a refactor gets reviewed by someone who does not remember the rule
  • a year later, the module graph is a plate of spaghetti and the modular monolith is a monolith again

Enforcement is cheap -- minutes per rule, added once. The cost of not having it is measured in months of untangling.

Concrete Example

ArchUnit (Java), boundary rule for OrderFlow

@AnalyzeClasses(packages = "com.orderflow")
public class ArchitectureTest {

@ArchTest
static final ArchRule modules_only_talk_through_api =
classes().that().resideInAPackage("..checkout.domain..")
.should().onlyDependOnClassesThat().resideInAnyPackage(
"..checkout..",
"..platform..",
"..catalog.api..",
"..fulfillment.api..",
"java..",
"org.springframework.."
);

@ArchTest
static final ArchRule no_cycles_between_modules =
slices().matching("com.orderflow.(*)..").should().beFreeOfCycles();

@ArchTest
static final ArchRule api_packages_must_be_stable =
classes().that().resideInAPackage("..api..")
.should().onlyBeAccessed().byAnyPackage("..");
}

Reading this: checkout's domain can talk to its own internals, to shared platform, and to other modules' APIs only. It cannot touch another module's domain or persistence. Any commit that violates fails CI.

Packwerk (Ruby), Shopify's rule format

# checkout/package.yml
enforce_dependencies: true
enforce_privacy: true

dependencies:
- catalog
- fulfillment
- platform

public_path: app/public/

enforce_dependencies: true means this package can only call into its declared dependencies. enforce_privacy: true means callers can only touch files under public_path/. Shopify ships this in CI for their core monolith; it is how a Rails app that would normally dissolve into spaghetti stays modular.

Python, import-linter

[importlinter:contract:layered]
name = Enforce module layering
type = layers
layers =
platform
catalog | checkout | fulfillment
include_external_packages = False

[importlinter:contract:checkout_privacy]
name = checkout internals are not imported elsewhere
type = forbidden
source_modules =
checkout.domain
checkout.persistence
forbidden_modules =
catalog
fulfillment

This runs as lint-imports in CI and fails on violation.

A topology of rules

Dotted lines are what the rule system must permit or forbid.

Common Confusion / Misconception

"Code review catches this." It does not, reliably. Code review catches things a human notices while reading a diff. Boundary violations are often three files, each of which looks fine. Reviewers who are not also mentally running a dependency-graph analyzer will miss most of them.

"We can enforce it later." Enforcement on a clean graph takes an afternoon. Enforcement on a rotted graph takes weeks because every first run finds hundreds of violations that someone has to triage. The cost goes up superlinearly. Start with the rules in place.

"Fitness functions only work for Java." The term is broad. Any CI check that validates an architectural property is a fitness function. A grep for from catalog.domain import in CI is a crude fitness function. The form does not matter; the automation does.

"Packwerk and ArchUnit solve the same problem at the same layer." They aim at the same goal but differ: ArchUnit inspects compiled bytecode and can check patterns (inheritance, annotations) as well as package dependencies; Packwerk works from Ruby source + YAML config and emphasizes boundary privacy and explicit dependency declaration. Pick the strongest tool your language has.

"Rules should be aspirational -- let violations accumulate and fix later." Do not tolerate red CI you have trained the team to ignore. Either fix the violation or grandfather it explicitly (most tools support an ignore file with a known count) and schedule the fix. A permanently-red rule is not a rule.

How To Use It

Adoption playbook for a monolith that has no enforcement today:

Minimum viable rule set for any modular monolith:

  1. "No module imports another module's internals."
  2. "No cycles between modules."
  3. "No direct DB access across module boundaries (persistence layers are module-private)."
  4. "Cross-cutting concerns live in one shared platform module and nowhere else."

Add more rules over time based on observed drift.

Check Yourself

  1. What is a fitness function, in one sentence? Name three forms it can take in your language.
  2. Why do teams without enforcement almost always end up with a spaghetti module graph, regardless of how good their engineers are?
  3. A CI rule finds 200 violations on first run. What is your plan? (Hint: "fix all now" is not the only option, and sometimes the wrong one.)

Mini Drill or Application

In a codebase you have access to (or a sample project):

  1. Draw the intended module graph (5-10 modules).
  2. Pick a fitness-function tool for the language.
  3. Write 3 rules covering: module isolation, no cycles, and public API restriction.
  4. Run it. Record the violation count.
  5. Pick one violation and write the fix or the grandfather entry.

Total: 45-60 minutes.

Read This Only If Stuck