Module Boundaries and Coupling: How to Measure and Enforce Them

Boundaries that live only in a wiki erode. This post shows how to measure coupling with real numbers and how to enforce module boundaries with tools that fail the build.

By The OpenVisio Team · · 12 min read

Every codebase starts with good intentions about boundaries. The payments code will be self-contained. The UI will not reach into the database. Feature modules will talk through public APIs, not each other's internals. A year later, a deadline produced one shortcut, a refactor produced another, and the boundaries exist mainly in a diagram. Nothing failed when they eroded, so nobody noticed.

This post is about making boundaries real: measuring coupling with numbers instead of impressions, and enforcing boundaries with tools that fail the build. It covers the classic coupling metrics, change coupling from git history, and concrete rule configurations for TypeScript, Python, Java and Go.

Key takeaways

  • A module boundary is only real if something checks it. Otherwise it is a suggestion.
  • Afferent coupling (Ca) counts incoming dependencies; efferent coupling (Ce) counts outgoing ones. Instability I = Ce / (Ca + Ce) tells you how exposed a module is to change elsewhere.
  • Dependencies should point toward stable modules. A stable module depending on an unstable one is a design smell.
  • Import graphs miss coupling through shared data, protocols and conventions. Change coupling from git history finds it.
  • Enforce boundaries with dependency-cruiser or ESLint rules (JS/TS), Nx module boundaries, import-linter (Python), ArchUnit (Java), and Go's internal/ directories.
  • Introduce rules with a baseline of existing violations, so you can enforce from day one without a big cleanup.

What a boundary is

A module boundary separates the inside of a module (its implementation) from the outside (everyone else). A good boundary has three properties:

  1. A small public surface. Other modules use a deliberate API: an index.ts, a package's exported names, a service interface. Everything else is private.
  2. Controlled dependencies. The module depends on a known, short list of other modules, in an agreed direction.
  3. Independent change. Most changes inside the module do not require changes outside it.

The third property is the point. Boundaries exist so a team can change one part of the system without understanding, retesting or redeploying all of it. Coupling is the measure of how far you are from that.

Measuring structural coupling

The classic metrics, from Robert C. Martin's package principles, are computed per module from the dependency graph:

MetricDefinitionReading
Afferent coupling (Ca)Number of modules outside this one that depend on itHigh Ca: many things break if this changes
Efferent coupling (Ce)Number of modules outside this one it depends onHigh Ce: this breaks when many things change
Instability (I)Ce / (Ca + Ce), from 0 to 10 is maximally stable, 1 is maximally unstable
Abstractness (A)Abstract types / total types in the module0 is all concrete, 1 is all interfaces
Distance from main sequence (D)abs(A + I - 1)Near 0 is balanced; near 1 is a problem zone

"Stable" here does not mean "unchanging". It means "hard to change, because many things depend on it". A shared types package with 80 dependents and no dependencies has I = 0. A feature module that imports 12 things and is imported by nothing has I = 1. Both are fine in their roles.

The stable dependencies principle

The useful rule that falls out: dependencies should point in the direction of stability. An unstable module (I near 1) should depend on stable ones (I near 0), not the other way around. If your stable core module imports your unstable checkout feature, every change to checkout ripples into everything that depends on core.

The distance metric adds a second check. A module that is very stable and entirely concrete (A = 0, I = 0) sits in the "zone of pain": hard to change and with no abstractions to extend instead. A database schema module often lands here. A module that is very abstract and unstable (A = 1, I = 1) sits in the "zone of uselessness": interfaces nobody implements or uses.

Computing the numbers

For TypeScript, you can compute Ca, Ce and I per folder from dependency-cruiser's JSON output:

// coupling.mjs: npx depcruise src --collapse "^src/[^/]+" --output-type json > deps.json && node coupling.mjs
import { readFileSync } from 'node:fs'

const { modules } = JSON.parse(readFileSync('deps.json', 'utf8'))
const ca = {}
const ce = {}

for (const m of modules) {
  if (!m.source.startsWith('src/')) continue
  ce[m.source] ??= new Set()
  ca[m.source] ??= new Set()
  for (const d of m.dependencies) {
    if (!d.resolved.startsWith('src/') || d.resolved === m.source) continue
    ce[m.source].add(d.resolved)
    ;(ca[d.resolved] ??= new Set()).add(m.source)
  }
}

const rows = Object.keys(ce).map((mod) => {
  const a = ca[mod]?.size ?? 0
  const e = ce[mod].size
  return { module: mod, Ca: a, Ce: e, I: a + e ? +(e / (a + e)).toFixed(2) : 0 }
})
console.table(rows.sort((x, y) => x.I - y.I))

Then look for edges that go from low I to high I. Each one is a dependency pointing away from stability, and a candidate for inversion through an interface.

For Java, JDepend has long reported these metrics per package, and many static analysis platforms include them. For other languages, the same calculation works on any import graph collapsed to the module level.

Measuring change coupling

Structural metrics only see imports. Two modules can be tightly coupled with no import between them: a frontend and a backend sharing a JSON shape, two services reading the same database table, a config file and the code that interprets it. Version control sees this coupling because those files change together.

A quick way to find co-changing pairs:

# For each commit, list the top-level folders it touched; count folder pairs
git log --since="12 months ago" --no-merges --format='@@' --name-only \
  | awk '/^@@/ { if (n) for (i in d) for (j in d) if (i < j) print i, j; delete d; n = 0; next }
         NF { split($0, p, "/"); d[p[1] "/" p[2]] = 1; n = 1 }
         END { for (i in d) for (j in d) if (i < j) print i, j }' \
  | sort | uniq -c | sort -rn | head -20

This prints pairs of second-level folders (for example src/orders src/billing) that appear in the same commits, most frequent first. Divide by the number of commits touching either folder to get a coupling percentage. For file-level analysis with proper normalization, code-maat's coupling analysis does the job, as described in the code hotspots post.

High change coupling across a boundary means one of three things: the boundary is in the wrong place, the contract between the modules is implicit and should be made explicit (a shared schema, a versioned API), or one feature genuinely spans both and the coupling is acceptable. Look at the commits to tell which.

Enforcing boundaries

Measurement shows where you are. Enforcement keeps you from sliding back. The tools differ by language, but they all express the same kinds of rules: "A may not import B", "only the public entry point of A may be imported", "these modules must be independent", "layers may only depend downward".

TypeScript and JavaScript: dependency-cruiser

// .dependency-cruiser.js
module.exports = {
  forbidden: [
    {
      name: 'no-deep-imports-into-features',
      comment: 'Import a feature through its index.ts, not its internals',
      severity: 'error',
      from: { path: '^src/(app|components)/' },
      to: { path: '^src/features/[^/]+/.+', pathNot: '^src/features/[^/]+/index\\.ts$' },
    },
    {
      name: 'ui-not-to-db',
      severity: 'error',
      from: { path: '^src/(components|app)/' },
      to: { path: '^src/db/' },
    },
    {
      name: 'features-independent',
      comment: 'Features talk through src/shared or events, not each other',
      severity: 'error',
      from: { path: '^src/features/([^/]+)/' },
      to: { path: '^src/features/', pathNot: '^src/features/$1/' },
    },
  ],
}

The $1 in the third rule is a back-reference to the group captured in the from path, which is how dependency-cruiser expresses "a different feature than the one I am in". Run it with npx depcruise src --config .dependency-cruiser.js in CI.

Monorepos: Nx module boundaries

In an Nx workspace, tag projects in project.json and constrain which tags may depend on which:

// project.json of libs/orders
{ "tags": ["scope:orders", "type:feature"] }
// eslint.config.js
'@nx/enforce-module-boundaries': [
  'error',
  {
    depConstraints: [
      { sourceTag: 'type:feature', onlyDependOnLibsWithTags: ['type:ui', 'type:data', 'type:util'] },
      { sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },
      { sourceTag: 'scope:orders', onlyDependOnLibsWithTags: ['scope:orders', 'scope:shared'] },
    ],
  },
],

The rule also blocks deep imports into another library's internals, so the public index.ts of each library becomes its boundary.

Python: import-linter

[tool.importlinter]
root_package = "shop"

[[tool.importlinter.contracts]]
name = "Layers"
type = "layers"
layers = ["shop.api", "shop.services", "shop.domain"]

[[tool.importlinter.contracts]]
name = "Domain has no framework imports"
type = "forbidden"
source_modules = ["shop.domain"]
forbidden_modules = ["django", "fastapi", "sqlalchemy"]

lint-imports checks every contract and reports the import chains that break them. Forbidden contracts that include third-party packages need include_external_packages = true in the [tool.importlinter] section.

Java: ArchUnit

@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");

These run as ordinary unit tests, so they fail the build like any other test.

Go: internal directories

Go builds one boundary into the toolchain. Code under a directory named internal can only be imported by code rooted at the parent of that directory. shop/orders/internal/pricing can be imported by shop/orders/... but not by shop/billing. The compiler rejects the import with use of internal package ... not allowed. Structuring a Go module around internal/ gets you enforced privacy for free; for layer rules beyond that, tools like go-arch-lint or a custom check using go list -deps fill the gap.

Rolling out enforcement without a big cleanup

Most codebases already violate the rules you want. Do not let that stop you:

  1. Write the rules you want, not the rules you have.
  2. Record current violations as a baseline. dependency-cruiser has --output-type baseline and --ignore-known; import-linter contracts take ignore_imports; ArchUnit offers FreezingArchRule.
  3. Fail CI only on new violations. The situation can no longer get worse.
  4. Fix baseline entries opportunistically, especially in hotspot files where the coupling is costing the most.
  5. Report the baseline size in a dashboard or weekly note, so progress is visible.

When a rule blocks a pull request, the conversation that follows is the valuable part. Either the change is a shortcut that should be done differently, or the boundary is wrong and the rule should change. Both are better than the boundary silently eroding. Record boundary changes in an architecture decision record so the reasoning survives.

Seeing boundaries before enforcing them

Rules are easier to write when you can see the current shape. OpenVisio's Explore Architecture view groups a repository's modules and draws the edges between groups, which is a quick way to spot the dependencies that cross boundaries you thought were clean before writing a dependency-cruiser or import-linter rule for them. For coding agents, get_dependents and get_neighborhood over MCP let the agent check which modules a change will touch, so it is less likely to introduce the very cross-boundary import your rule exists to prevent.

couplingmodule boundariessoftware architecturedependency rules

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading