Software Architecture Principles

Separation of Concerns · High Cohesion · Loose Coupling — the three principles that decide whether a system stays changeable after year three.

Contents
  1. Separation of Concerns
  2. High Cohesion
  3. Loose Coupling
  4. How the three balance each other
  5. Measuring them
  6. Review checklist
  7. Smells and anti-patterns

1. Separation of Concerns (SoC)

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.

Where the boundaries usually go

ConcernOwnsMust not know about
Presentation / UIRendering, input events, view state, formattingSQL, table names, HTTP verbs of internal services
Transport / APIRouting, status codes, (de)serialization, auth handshakeBusiness invariants, persistence details
Application / use casesOrchestration, transactions, authorization checksThe concrete database, the concrete UI
DomainEntities, invariants, business rules, domain eventsFrameworks, I/O, JSON, HTTP, ORM annotations
InfrastructureRepositories, message brokers, file systems, external clientsBusiness rules (it implements, it does not decide)
Cross-cuttingLogging, tracing, metrics, caching, error mappingShould be applied declaratively, not copied into each module
DOMAIN entities · invariants · domain events no frameworks, no I/O APPLICATION / USE CASES ADAPTERS — HTTP · UI · CLI · MESSAGING · PERSISTENCE dependencies always point inward outer layer may change freely
Concerns separated into concentric layers; the domain stays framework-free, so infrastructure can be replaced without touching business rules.

Violation, then repair

// ---- 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);
  }
}
Practical test. Ask of every module: "if this concern changed — new database, new UI framework, new auth provider — how many files would I open?" If the answer includes files that have nothing to do with that concern, the separation is decorative rather than real.

2. High Cohesion

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:

LevelMeaningVerdict
FunctionalEvery element contributes to one single, well-defined tasktarget
SequentialOutput of one part is the input of the next (a pipeline)good
CommunicationalParts operate on the same data (e.g. a report built from one dataset)acceptable
ProceduralParts run in a fixed order but share no dataweak
TemporalGrouped because they happen at the same time (an initAll())weak
LogicalGrouped by category, chosen at runtime by a flag ("all validators")avoid
CoincidentalGrouped arbitrarily ("utils", "helpers", "common")avoid

The "and" test

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
Symptoms of low cohesion.
Heuristic for splitting. Split by reason to change, not by technical layer alone. If two pieces of code always change together for the same business reason, they belong together even if they are "different layers" — cohesion beats symmetry.

3. Loose Coupling

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:

TypeWhat leaksExampleVerdict
ContentOne module touches another's internalsReading order._items directlynever
CommonShared mutable global stateA singleton cache everyone writes toavoid
ControlOne module dictates another's flow through flagsrender(data, true, false)avoid
StampA whole structure is passed when a field is neededprocessCustomer(customer) using only idtolerable
DataOnly the parameters that are actually neededcharge(customerId, amountCents)good
Message / contractNothing but a published interface or event schemaPaymentGateway.charge(Charge)target

Tight versus loose, same feature

// ---- 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 { /* ... */ }

Techniques that actually reduce coupling

Coupling is not binary, it is a budget. You cannot remove it — a system with zero coupling does nothing. You choose where it lands: at a stable, documented, versioned seam rather than scattered through internals.

4. How the three balance each other

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 getBecause
Separation of concernsMicro-modules, "lasagna code", a change touching twelve filesEvery concept becomes its own layer; the distance between a request and its effect grows
High cohesion (per module)God modules that absorb every related ruleMaximising internal cohesion without considering boundaries concentrates change
Loose couplingIndirection everywhere, hard-to-follow call graphs, "just one more interface"Every dependency is hidden behind a port, including trivial ones
The working rule. Cut along business capabilities (SoC), keep each cut internally coherent around one reason to change (cohesion), and expose only a narrow contract across the cut (loose coupling). Applied together they yield the property that matters operationally: a change stays local.

5. Measuring them

You do not have to guess. Structural metrics approximate cohesion and coupling well enough to catch regressions in CI:

MetricFormula / meaningReading
Afferent coupling CaClasses outside the package that depend on itHigh = stable, widely used, expensive to change
Efferent coupling CeClasses outside the package it depends onHigh = fragile, many external reasons to break
Instability ICe / (Ca + Ce), from 0 (stable) to 1 (unstable)Stable modules must be abstract; unstable ones concrete
Abstractness AAbstract types / total types in the packagePairs 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 doHigh 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.

6. Review checklist

7. Smells and anti-patterns

SmellPrinciple violatedTypical repair
God class / god serviceCohesionSplit by reason to change; extract collaborators
Anemic domain + logic in controllersSoCMove invariants into the domain, keep controllers for mapping
Shared database between servicesCouplingOne owner per dataset; integrate via API or events
Circular package dependenciesCoupling + SoCExtract the shared abstraction into a lower, stable module
Distributed monolithCouplingSynchronous chain of services → merge or make it asynchronous
Utility / "common" module growthCohesionDelete it; move each function to its real owner
Boolean parameter flagsControl couplingSplit into distinct methods or a strategy type
Leaky abstraction (ORM entities in the API)SoC + couplingExplicit DTOs at the boundary
Service locator / static singletonsCouplingConstructor injection with explicit ports