Separation of Concerns · High Cohesion · Loose Coupling — the three principles that decide whether a system stays changeable after year three.
Definition. SoC is the practice of dividing a system into parts that each address a distinct concern, so that a decision about one concern can be made without knowing the details of the others. A "concern" is any coherent set of responsibilities that can change for its own reasons: persistence, transport, business rules, presentation, authentication, observability, configuration.
SoC is the oldest of the three — Dijkstra described it in 1974 as "the only available technique for effective ordering of one's thoughts" — and it is the reason layered, hexagonal and clean architectures exist at all. The other two principles are what you measure after you have separated concerns: cohesion tells you whether you cut in the right place, coupling tells you whether the cut actually isolates change.
| Concern | Owns | Must not know about |
|---|---|---|
| Presentation / UI | Rendering, input events, view state, formatting | SQL, table names, HTTP verbs of internal services |
| Transport / API | Routing, status codes, (de)serialization, auth handshake | Business invariants, persistence details |
| Application / use cases | Orchestration, transactions, authorization checks | The concrete database, the concrete UI |
| Domain | Entities, invariants, business rules, domain events | Frameworks, I/O, JSON, HTTP, ORM annotations |
| Infrastructure | Repositories, message brokers, file systems, external clients | Business rules (it implements, it does not decide) |
| Cross-cutting | Logging, tracing, metrics, caching, error mapping | Should be applied declaratively, not copied into each module |
// ---- BAD: one function owns transport, rules, persistence and presentation ----
async function checkout(req, res) {
const cart = JSON.parse(req.body); // transport
if (cart.items.length === 0) return res.status(400).send('empty'); // rules + transport
let total = cart.items.reduce((s, i) => s + i.price * i.qty, 0);
if (cart.coupon === 'SAVE10') total = total * 0.9; // rule buried in the controller
await db.query('INSERT INTO orders (total) VALUES (?)', [total]); // persistence
res.send('<h1>Thanks! Total ' + total + '</h1>'); // presentation
}
// ---- GOOD: each concern in its own place ----
class Checkout { // domain: pure rules, unit-testable without a server
static total(items, coupon) { /* invariants + pricing */ }
}
class OrderRepository { save(order) {} } // infrastructure
class CheckoutController { // transport only: map in, map out
constructor(useCase) { this.useCase = useCase; }
async handle(req, res) {
const result = await this.useCase.execute(toCommand(req.body));
res.status(result.ok ? 200 : 422).json(result);
}
}
Definition. Cohesion measures how strongly related the responsibilities inside a single module are. High cohesion means everything in the module serves one purpose and changes for the same reason. It is the answer to "did I cut the system in the right place?".
The classic seven levels, best to worst:
| Level | Meaning | Verdict |
|---|---|---|
| Functional | Every element contributes to one single, well-defined task | target |
| Sequential | Output of one part is the input of the next (a pipeline) | good |
| Communicational | Parts operate on the same data (e.g. a report built from one dataset) | acceptable |
| Procedural | Parts run in a fixed order but share no data | weak |
| Temporal | Grouped because they happen at the same time (an initAll()) | weak |
| Logical | Grouped by category, chosen at runtime by a flag ("all validators") | avoid |
| Coincidental | Grouped arbitrarily ("utils", "helpers", "common") | avoid |
A cheap, reliable detector: describe what the module does in one sentence. Every "and" in that sentence is a candidate split. Every "or" is a strategy hiding behind a flag.
// ---- BAD: "InvoiceService creates invoices AND renders PDFs AND emails customers" ----
class InvoiceService {
create() {}
renderPdf() {}
sendEmail() {}
}
// ---- GOOD: three modules, three reasons to change ----
class InvoiceService { create() {} } // changes when billing rules change
class InvoiceRenderer { toPdf(invoice) {} } // changes when the template changes
class InvoiceNotifier { send(invoice) {} } // changes when the delivery channel changes
Definition. Coupling measures how much one module depends on the internal details of another. Loose coupling means a module can be understood, tested, replaced or redeployed without editing its neighbours — interaction happens through a narrow, stable, intention-revealing contract.
Coupling types, from worst to best:
| Type | What leaks | Example | Verdict |
|---|---|---|---|
| Content | One module touches another's internals | Reading order._items directly | never |
| Common | Shared mutable global state | A singleton cache everyone writes to | avoid |
| Control | One module dictates another's flow through flags | render(data, true, false) | avoid |
| Stamp | A whole structure is passed when a field is needed | processCustomer(customer) using only id | tolerable |
| Data | Only the parameters that are actually needed | charge(customerId, amountCents) | good |
| Message / contract | Nothing but a published interface or event schema | PaymentGateway.charge(Charge) | target |
// ---- BAD: the use case is welded to Stripe, to its SDK and to its errors ----
class SubscribeUseCase {
async run(userId, plan) {
const stripe = new Stripe(process.env.STRIPE_KEY);
const sub = await stripe.subscriptions.create({ customer: userId, items: [{ price: plan }] });
return sub.id;
}
}
// ---- GOOD: the use case depends on a port it defines itself ----
interface PaymentGateway { // the contract belongs to the caller, not the vendor
charge(customerId: string, amountCents: number): Promise<ChargeId>;
}
class SubscribeUseCase {
constructor(private readonly payments: PaymentGateway) {}
async run(customerId: string, amountCents: number) {
return this.payments.charge(customerId, amountCents);
}
}
// infrastructure: swap Stripe for Adyen or an in-memory fake without touching the use case
class StripeGateway implements PaymentGateway { /* ... */ }
a.getB().getC().do() is a coupling chain of length three).The three principles are one idea seen from three angles: SoC tells you to cut, cohesion tells you whether the cut is coherent, coupling tells you whether the cut insulates change. Optimising only one degrades the others.
| If you over-apply… | You get | Because |
|---|---|---|
| Separation of concerns | Micro-modules, "lasagna code", a change touching twelve files | Every concept becomes its own layer; the distance between a request and its effect grows |
| High cohesion (per module) | God modules that absorb every related rule | Maximising internal cohesion without considering boundaries concentrates change |
| Loose coupling | Indirection everywhere, hard-to-follow call graphs, "just one more interface" | Every dependency is hidden behind a port, including trivial ones |
You do not have to guess. Structural metrics approximate cohesion and coupling well enough to catch regressions in CI:
| Metric | Formula / meaning | Reading |
|---|---|---|
Afferent coupling Ca | Classes outside the package that depend on it | High = stable, widely used, expensive to change |
Efferent coupling Ce | Classes outside the package it depends on | High = fragile, many external reasons to break |
Instability I | Ce / (Ca + Ce), from 0 (stable) to 1 (unstable) | Stable modules must be abstract; unstable ones concrete |
Abstractness A | Abstract types / total types in the package | Pairs with I to locate the "main sequence" |
Distance D | |A + I − 1| | D → 1 flags a zone of pain (rigid & concrete) or uselessness (abstract & unused) |
| Lack of cohesion of methods (LCOM) | Method pairs that share no attribute, minus pairs that do | High LCOM = the class does several jobs |
Tooling that computes these on real code: jdepend and ArchUnit (Java), NetArchTest (·NET), dependency-cruiser and madge (JS/TS), pydeps and import-linter (Python). Architectural tests are the durable form of these principles: encode "domain must not import infrastructure" as an assertion, not as a convention in a wiki.
| Smell | Principle violated | Typical repair |
|---|---|---|
| God class / god service | Cohesion | Split by reason to change; extract collaborators |
| Anemic domain + logic in controllers | SoC | Move invariants into the domain, keep controllers for mapping |
| Shared database between services | Coupling | One owner per dataset; integrate via API or events |
| Circular package dependencies | Coupling + SoC | Extract the shared abstraction into a lower, stable module |
| Distributed monolith | Coupling | Synchronous chain of services → merge or make it asynchronous |
| Utility / "common" module growth | Cohesion | Delete it; move each function to its real owner |
| Boolean parameter flags | Control coupling | Split into distinct methods or a strategy type |
| Leaky abstraction (ORM entities in the API) | SoC + coupling | Explicit DTOs at the boundary |
| Service locator / static singletons | Coupling | Constructor injection with explicit ports |