Drawing module boundaries
Maximize cohesion inside a module, minimize coupling across. Afferent vs efferent coupling and the Stable-Dependencies Principle: depend toward stability. Package by feature, not by layer — package-by-layer scatters one feature's change across every folder.
Two folders, same code, opposite cost. In one repo, adding a “wishlist” feature touches a single directory — its handler, its rule, its query, its types all sit together — and you ship without opening anything else. In another, the same feature means a new file in controllers/, another in services/, another in repositories/, another in dto/, four imports stitched across four packages. Both look organised. Only one is.
The difference is where the boundaries fall. A boundary is not a folder for tidiness — it is a bet about what changes together. Draw it around things that change for the same reason and you get a small, stable seam you rarely cross. Draw it around technical kind — all controllers here, all services there — and you’ve cut straight across every feature, so every feature change bleeds through all of them. This lesson is about drawing the cut deliberately: high cohesion inside, low coupling across, with dependencies pointed the right way.
After this lesson you can state the boundary objective precisely — maximize cohesion within a module and minimize coupling across it; distinguish afferent coupling (who depends on me) from efferent coupling (whom I depend on) and use that to spot which modules are stable and which are volatile; apply the Stable-Dependencies Principle so volatile detail depends on stable abstraction and never the reverse; and choose package-by-feature over package-by-layer, naming exactly why the layer split is high coupling wearing the costume of organisation.
A good boundary confines a likely change behind a small, stable interface. The whole point of a module edge is the change you don’t have to make: callers on the far side keep working because they depend on the interface, not the implementation. So you draw the boundary around the thing most likely to vary — a payment provider, a tax rule, a storage backend — and expose the narrowest surface that callers actually need.
// payments/index.ts — the boundary. Callers see only this.
export interface PaymentGateway {
charge(amountCents: number, token: string): Promise<ChargeResult>;
}
export type ChargeResult = { ok: true; id: string } | { ok: false; reason: string };Behind this seam, StripeGateway can become AdyenGateway, retries and idempotency keys can appear, and the checkout code never changes — it only ever knew charge. The interface is small (one method) and stable (it describes what, which rarely moves), while the implementation is large and volatile (it describes how, which moves constantly). That asymmetry — small stable surface, large volatile body — is the signature of a boundary worth having.
Cohesion is the force that decides what lives inside a boundary: things that change together belong together. A module is cohesive when its parts serve one purpose and change for one reason. The operational test is the one from the change-cost lens: when a single requirement moves, how many modules do you open? One means the cohesion matched the change. Many means the boundary was drawn across the change instead of around it.
// HIGH cohesion: everything a "checkout" needs, in one place
checkout/
checkout.handler.ts // HTTP in/out for checkout
pricing.ts // the checkout pricing rule
checkout.repo.ts // checkout persistence
checkout.types.ts // shapes shared only inside checkoutAdding a checkout coupon is one directory of edits. The pieces are coupled to each other — but that’s fine: they were always going to change together, so internal coupling is just the module doing its job. The error is confusing this benign within-module coupling with the harmful kind across modules.
Coupling has a direction, and the direction is the whole game: afferent vs efferent. Afferent coupling (Ca) counts who depends on a module — its incoming arrows, its fan-in. Efferent coupling (Ce) counts whom the module depends on — its outgoing arrows, its fan-out. The ratio gives instability: I = Ce / (Ca + Ce), from 0 (nothing I depend on; everyone depends on me — maximally stable) to 1 (I depend on everything; nobody depends on me — maximally unstable).
// A leaf detail: high Ce, low Ca → I ≈ 1 (unstable, free to change)
// StripeGateway depends on the Stripe SDK, config, logging, the PaymentGateway interface
// …and (almost) nothing depends on StripeGateway directly.
// A core abstraction: low Ce, high Ca → I ≈ 0 (stable, expensive to change)
// PaymentGateway depends on nothing; checkout, refunds, invoicing all depend on it.Stability here is not “good code” — it’s a statement about blast radius. A module that many things depend on (high Ca, low I) is expensive to change because the change ripples outward, so it had better be an abstraction that rarely needs to. A module nothing depends on (low Ca, high I) is cheap to change because nothing breaks downstream — which is exactly where you want volatile detail to sit.
Stable-Dependencies Principle: depend in the direction of increasing stability. Arrows should point from the unstable toward the stable — I should decrease along every dependency edge. Volatile detail depends on stable abstraction; never the reverse. The reason is mechanical: if a stable module depended on a volatile one, every twitch of the volatile detail would force a change in the thing everyone else depends on, and the ripple would tear through the system. Pointing the arrow the other way means volatility stays trapped in the leaves where its blast radius is zero.
// RIGHT: detail → abstraction. Instability decreases along the arrow.
// checkout (I≈0.6) → PaymentGateway (I≈0.0) ← StripeGateway (I≈1.0)
// Both the policy and the detail depend on the stable interface in the middle.
// WRONG: abstraction → detail. checkout imports StripeGateway directly.
// import { StripeGateway } from "../payments/stripe"; // a stable caller now married to a volatile detailThe right-hand shape is just Dependency Inversion stated as a boundary law: the volatile StripeGateway and the volatile checkout policy both point inward at the stable PaymentGateway. When the failure mode appears — a stable module reaching out to a concrete, fast-moving one — you’ve planted a dependency that runs uphill against stability, and it will be the source that breaks far more than it should.
Package by feature, not by layer — and recognise package-by-layer as high coupling disguised as organisation. Package-by-layer groups files by technical kind: controllers/, services/, repositories/. It looks orderly because every file of a kind is together. But requirements don’t arrive by layer — they arrive by feature. “Add gift cards” is one change request that, under a layer split, forces edits in controllers/, services/, repositories/, and dto/ simultaneously: shotgun surgery, every time. The folders are cohesive by the wrong axis (kind), which means they are coupled along the axis that actually varies (feature).
package-by-layer (low cohesion per feature) package-by-feature (high cohesion per feature)
controllers/ checkout.ctrl, refund.ctrl checkout/ handler, pricing, repo, types
services/ checkout.svc, refund.svc refunds/ handler, policy, repo, types
repositories/ checkout.repo, refund.repo payments/ gateway (boundary), stripe, adyen
one feature = one edit in every folder one feature = one folderPackage-by-feature isn’t a style preference; it aligns the boundary with the change axis, so the common change is local and the cross-module surface shrinks to deliberate, stable seams (like payments/). When not to apply it: a genuinely cross-cutting concern with no feature home — auth middleware, a logging adapter, shared value types — does belong in a shared/platform module. Forcing those into a feature folder just relocates the coupling. Feature-first, with a small honest “shared kernel” for what truly spans features.
Same code, two boundary cuts. Start with the layer split — orderly to look at, hostile to change:
// controllers/checkout.controller.ts
import { CheckoutService } from "../services/checkout.service";
// services/checkout.service.ts
import { CheckoutRepository } from "../repositories/checkout.repository";
import { StripeGateway } from "../gateways/stripe.gateway"; // service married to a concrete detail
// repositories/checkout.repository.ts
// dto/checkout.dto.tsNow add a coupon to checkout. You edit the controller (new field), the service (apply discount), the repository (persist coupon code), and the DTO (validate it) — four folders, four files, four chances to forget one. And checkout.service imports StripeGateway directly: a relatively stable policy depending on a volatile detail (I going up along the arrow — the failure mode from Step 4).
Recut by feature, with the gateway as a stable boundary:
// checkout/checkout.handler.ts — HTTP edge
// checkout/pricing.ts — coupons + discounts live here
// checkout/checkout.repo.ts — persistence
// checkout/checkout.types.ts — shapes used only inside checkout
import type { PaymentGateway } from "../payments"; // depend on the abstraction, not Stripe
// payments/index.ts — the one stable seam checkout crosses
export interface PaymentGateway { charge(amountCents: number, token: string): Promise<ChargeResult>; }The coupon change is now one folder. Checkout depends on the PaymentGateway interface (I≈0), so swapping Stripe for Adyen never touches checkout. Cohesion went up (a feature’s parts sit together), cross-module coupling went down (one narrow seam instead of four diffuse ones), and every dependency runs toward stability. Nothing got cleverer — the cut just stopped crossing the change axis.
▸Why this works
Why is instability not a defect to “fix”? Because a healthy system needs unstable modules — they are where change is supposed to happen. You want StripeGateway at I≈1: it should be trivial to rewrite, and the high instability is exactly what makes it cheap. The principle isn’t “make everything stable”, it’s “arrange the arrows so volatility lives in modules with low afferent coupling (nothing downstream to break) and stability lives in the abstractions everything points at”. Robert Martin’s companion idea — the Stable-Abstractions Principle — adds the other half: a module that is stable (hard to change) should also be abstract (an interface), so that it can be stable without being rigid. Stable-and-concrete is the genuinely painful quadrant: everyone depends on it and you can’t extend it without editing it.
▸Common mistake
The seductive mistake is reading “low coupling” as “no coupling” and then over-splitting. Tearing one cohesive feature into five micro-modules to drive coupling toward zero just converts internal calls into cross-boundary imports — you’ve added afferent/efferent edges, not removed them, and now a single feature change crosses five seams instead of staying in one. Coupling between things that change together isn’t the enemy; coupling between things that change for different reasons is. Equally, don’t cargo-cult package-by-feature onto truly shared infrastructure (auth, logging, money types): a “feature” folder for code with no feature is just a layer folder with a friendlier name. Cut around the change axis, not around a slogan.
A checkout policy module currently imports a concrete StripeGateway directly. By the Stable-Dependencies Principle and afferent/efferent coupling, why is this the wrong direction, and what is the fix?
A module boundary is a bet about what changes together: maximize cohesion inside (parts that change for one reason live together) and minimize coupling across (few, narrow, deliberate seams). Coupling has direction — afferent (who depends on me) versus efferent (whom I depend on) — and their ratio gives instability, a measure of blast radius. The Stable-Dependencies Principle says arrows must run toward stability: volatile detail depends on stable abstraction, never the reverse, so churn stays trapped in low-afferent leaves. The practical lever is package by feature, not by layer: the layer split looks organised but cuts across every feature, turning one change request into shotgun surgery — high coupling masquerading as tidiness. Cut around the change axis, point dependencies inward at stable interfaces, and keep a small honest shared kernel for what genuinely spans features.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.