In this guide
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:
- 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. - Controlled dependencies. The module depends on a known, short list of other modules, in an agreed direction.
- 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:
| Metric | Definition | Reading |
|---|---|---|
| Afferent coupling (Ca) | Number of modules outside this one that depend on it | High Ca: many things break if this changes |
| Efferent coupling (Ce) | Number of modules outside this one it depends on | High Ce: this breaks when many things change |
| Instability (I) | Ce / (Ca + Ce), from 0 to 1 | 0 is maximally stable, 1 is maximally unstable |
| Abstractness (A) | Abstract types / total types in the module | 0 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:
- Write the rules you want, not the rules you have.
- Record current violations as a baseline. dependency-cruiser has
--output-type baselineand--ignore-known; import-linter contracts takeignore_imports; ArchUnit offersFreezingArchRule. - Fail CI only on new violations. The situation can no longer get worse.
- Fix baseline entries opportunistically, especially in hotspot files where the coupling is costing the most.
- 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.

