Engineering Atlas › Low-Level Design

Low coupling and high cohesion

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Coupling: the cost of change crossing boundaries

Coupling comes in degrees. From loosest to tightest, roughly: modules that exchange simple data through a narrow call; modules that pass a shared data structure and each read parts of it; modules that pass flags telling the other what to do; modules that share global state; and modules that reach into each other's internals. A useful test for any pair of modules is "if I change this one, how often must I touch that one?".

There is also coupling that no import statement shows. Two services that both parse the same JSON event format are coupled even though neither imports the other. Two modules that both assume a table's status column holds one of four strings are coupled through the database. Temporal coupling means one call must happen before another (init before send). These hidden forms are often worse because tools cannot see them.

Cohesion: things that change together, live together

A cohesive module has one reason to change. A pricing package containing discount rules, tax rules and currency rounding is cohesive if pricing changes usually touch two or three of them together. A utils package containing date parsing, retry helpers, an S3 wrapper and a slug generator has low cohesion: its parts share a folder and nothing else. Low cohesion causes coupling, because everything that needs one helper imports the whole grab bag, and the grab bag imports everything its helpers need.

A realistic "before"

An order service in a Node backend has this layout: orders/ holds the HTTP handlers, the order state machine, the SQL queries, the payment calls, the email templates and a helpers.ts with 1,400 lines. inventory/ imports orders/helpers.ts for a date function. orders/ imports inventory/db.ts to read stock directly from inventory's tables. A product change to "allow partial shipments" touches 23 files across both folders; it takes three weeks and causes an incident when an inventory query breaks because an orders engineer renamed a column helper.

The "after"

The team splits by reason to change. orders/domain holds the state machine and rules and imports nothing outside the language. orders/http and orders/storage are adapters depending on the domain. Inventory exposes a small StockReader API instead of its tables. The date function moves into a tiny shared/time package with no dependencies of its own. The next similar change touches 7 files, all in orders.

Measuring it

Robert Martin's package metrics make the discussion concrete. For a module, afferent coupling Ca is the number of other modules that depend on it; efferent coupling Ce is the number it depends on. Instability is I = Ce / (Ca + Ce), from 0 (everyone depends on me and I depend on nothing: hard to change, so it had better be stable) to 1 (nobody depends on me: free to change). The Stable Dependencies Principle says each dependency should point toward lower instability. An edge from a module with I = 0.2 to one with I = 0.8 is a warning: a widely used module now leans on a volatile one, so volatile changes ripple outward.

For cohesion inside a class, LCOM4 counts groups of methods connected by shared fields. A score of 1 means the class is one idea. A score of 3 means three classes are hiding in one.

Too much of a good thing

Chasing low coupling has its own failure mode. Splitting a cohesive module into five packages to reduce Ce per package adds indirection and interface churn without reducing the real cost of change. Event buses introduced "for decoupling" often make coupling worse: the dependency still exists, but now it is invisible, untyped and asynchronous. Duplicating code to avoid an import trades one kind of coupling for a quieter one, where two copies must be changed in step. Some coupling is necessary; a module with zero dependencies does nothing. The goal is coupling that is explicit, narrow and pointed in the right direction.

The same shape elsewhere

This is the fan-out shape: one change, many receivers. In microservices it appears as the "distributed monolith", where services must be deployed together. In data platforms it appears as hundreds of dashboards reading one raw table, so a column rename breaks them all. In organisations it is Conway's law: module boundaries follow team boundaries, and a highly coupled codebase forces teams into constant coordination.

Down the stack

Layer 1The contract

There is no runtime contract for coupling; the promise is economic. Low coupling promises that the expected number of modules touched per change stays small and predictable. That is testable from history: from version control, compute for each commit how many modules it touched, and how often pairs of modules change in the same commit (change coupling). High cohesion promises that most commits touching a module touch several of its parts together.

Layer 2The mechanism

Static coupling metrics start from the import graph. Go exposes it directly with go list -deps -json; Python tools walk the ast for import nodes; JavaScript tools such as dependency-cruiser and madge resolve import and require; Java has jdeps and ArchUnit. From the graph, Ca and Ce are in-degree and out-degree after collapsing duplicate edges. Cycles are found with Tarjan's strongly connected components algorithm in O(V + E); any component with more than one module is a set that can only be understood, tested and released together. LCOM4 is a union-find over methods, joining two methods when they read or write a common field. Change coupling comes from mining git log: count co-changes for each pair of files and divide by the changes of each.

Languages enforce some of this. Go rejects import cycles at compile time, and its internal/ directories stop packages outside the parent tree from importing them. Java modules (module-info.java) export only named packages. Python and TypeScript enforce nothing by default, which is why lint rules such as import-linter contracts or dependency-cruiser's forbidden rules exist.

Layer 3The machine

Coupling has physical costs in the build. Go compiles one package at a time in dependency order and caches the result keyed by the hashes of its inputs, including the export data of everything it imports. Editing a package with high afferent coupling invalidates the cache entry of every transitive dependent; in a large monorepo one edit to a core package can turn a 20-second incremental build into a 10-minute one. Bazel and similar systems have the same property at the action-graph level. Low Ca on volatile code keeps rebuilds small.

Cohesion has a hardware analogue in memory layout. Fields used together should sit together: a CPU loads 64-byte cache lines, so a struct whose hot fields share one line costs one memory access instead of several. The opposite failure is false sharing: two unrelated counters, updated by different cores, that happen to share one cache line. Each write forces the line to move between cores, costing roughly 40 to 100 ns instead of about 1 ns for a local hit. Padding or splitting the struct (separating things that change independently) fixes it, which is cohesion and decoupling applied to bytes.

Across a network, runtime coupling multiplies failure. A request that synchronously calls five services, each 99.9% available, succeeds about 99.5% of the time, and its latency is the sum of theirs. Asynchronous messaging cuts that runtime coupling but keeps the schema coupling.

Why it's built this way

  1. Most engineering time goes into changing existing code, not writing new code.
  2. The cost of a change grows with the number of places it must reach and the number of people who own them.
  3. Coupling sets how far a change spreads; cohesion sets whether the places it reaches are near each other.
  4. Machines charge for spread too: rebuild graphs, cache lines and network hops all get slower when unrelated things are tangled.
  5. So designs that group by reason to change and point dependencies toward stable code are cheaper to evolve, to build and to run.

Diagram

flowchart LR
  A["checkout (I=1.0)"] --> U["shared/money (I=0.25)"]
  B["billing (I=1.0)"] --> U
  C["refunds (I=1.0)"] --> U
  U -->|"violates SDP"| F["feature-flags (I=0.67)"]
  F --> H["http client (I=0)"]
  F --> J["json (I=0)"]

Worked example

A Python monolith has 140 modules. The team extracts the import graph and finds common.utils with Ca = 96 and Ce = 31, so I = 31 / 127 ≈ 0.24: very depended upon, yet depending on a quarter of the codebase. It imports billing.models for one constant, which in turn imports the ORM and Stripe's SDK, so loading any module pulls in Stripe. Cold start for a CLI command is 1.8 seconds, and a change to billing.models reruns tests for 96 modules.

An LCOM4 pass on common.utils gives 11 components: eleven unrelated ideas in one file. Git history agrees: in the past year, 212 commits touched it and no two of those commits touched the same function more than twice.

They split it into common.time, common.retry and common.text (each Ce = 0), move the billing-specific helpers into billing, and add an import-linter contract forbidding common.* from importing any domain package. Average modules affected per change, measured over the next two months of commits, dropped from 6.1 to 2.4. CLI cold start fell to 0.4 seconds because Stripe's SDK is no longer imported by default. CI time per pull request fell by about a third, mostly from smaller test selection.

Trade-offs

OptionStrengthsWeaknessesChoose when
Group by technical layer (controllers, models, services)Familiar, easy to startA feature change touches every layer; low cohesion per folderSmall apps, one team
Group by feature or domainHigh cohesion, local changesShared concepts need a deliberate homeMost growing codebases
Shared utility packageAvoids duplicationBecomes a high-Ca, low-cohesion magnetOnly for small, dependency-free helpers
Duplicate small code instead of sharingNo import couplingCopies drift; hidden change couplingTiny, stable snippets across team boundaries
Event-based decouplingRuntime independenceInvisible, untyped dependenciesTruly independent consumers, with a schema registry

Pitfalls in production

Leadership lens

Check yourself

  1. A module has Ca = 0 and Ce = 6. What is its instability, and is that bad?

    Show answer

    I = 6 / 6 = 1. Not necessarily bad: nothing depends on it, so it is free to change; leaf modules such as HTTP handlers are expected to be unstable.

  2. Why is an edge from a module with I = 0.1 to one with I = 0.9 a warning?

    Show answer

    A module many others depend on now depends on a volatile one, so frequent changes in the volatile module can ripple through to all dependents of the stable one, violating the Stable Dependencies Principle.

  3. A class has LCOM4 of 3. What does that suggest, and what would you do?

    Show answer

    Its methods form three groups that share no fields, so it holds three separate ideas; consider splitting it into three classes along those groups.

  4. How can two services be tightly coupled without any code dependency between them?

    Show answer

    Through shared data or assumptions: the same database table, the same untyped event format, or a required call order. A change on one side breaks the other with no compiler or import tool noticing.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →