← All posts

Coupling, Cohesion, and Boundaries

The single fundamental beneath every architecture decision — what changes together, and how to keep it together — plus the domain-driven design vocabulary for drawing boundaries well.

architecture
tech-lead
engineering

Coupling, Cohesion, and Boundaries

Strip away the named architectures and the buzzwords, and almost every structural decision in software reduces to one question:

What changes together, and how do I keep things that change together close — and things that don't, apart?

That's it. Microservices, modular monoliths, layered design, domain boundaries — they're all attempts to answer that question at different scales. If you understand coupling and cohesion deeply, you can reason about any architecture, including ones that don't have a name yet. This is why it's the most leveraged concept in the book.

Cohesion: keep related things together

Cohesion measures how strongly the elements inside a module belong together. High cohesion means a module does one well-defined job and everything in it serves that job. Low cohesion means a module is a junk drawer of unrelated functions that happened to land in the same file.

You want high cohesion. A high-cohesion module is easy to name, easy to understand, and changes for a single reason. When a requirement shifts, the change lands in one place instead of being smeared across the codebase.

A useful lens here is the Single Responsibility Principle, best stated as: a module should have one, and only one, reason to change. If your User class changes when the tax rules change, when the email template changes, and when the database schema changes, it has three reasons to change — it's doing three jobs, and they should probably be three things.

Coupling: minimize dependencies between things

Coupling measures how strongly modules depend on each other. Two modules are tightly coupled when a change in one forces a change in the other. They're loosely coupled when each can change freely as long as the contract between them holds.

You want loose coupling — but not zero coupling, which is impossible in a system whose parts must cooperate. The goal is to make the coupling that exists explicit, narrow, and stable: modules talk through small, well-defined interfaces rather than reaching into each other's internals.

Not all coupling is equal. Roughly from worst to most acceptable:

The classic silent killer in distributed systems is a shared database — two services reading and writing the same tables. It looks loose (they're separate services!) but it's actually tight content coupling: now neither team can change the schema without coordinating with the other, and you've recreated a monolith's entanglement with none of its convenience. Each service should own its data.

Picturing it

Tight coupling · low cohesioneverything reaches into everythingone change ripples everywhereLoose coupling · high cohesiontight inside, narrow contracts betweenone narrow contract per connection (orange)

Notice that the goal isn't "fewer lines." It's that the lines within a module are dense (cohesion) and the lines between modules are few and deliberate (loose coupling). Good architecture is mostly the art of drawing the boxes so the lines fall this way.

Where do the boundaries go? Domain-Driven Design

Knowing you want loose coupling and high cohesion doesn't tell you where to cut. The most reliable answer comes from Domain-Driven Design (DDD): draw boundaries around areas of the business domain, because business capabilities tend to change together and evolve independently of one another. A few concepts carry most of the value:

Ubiquitous language. Within a given part of the system, the code, the conversations, and the business stakeholders should use the same words to mean the same things. When engineers say "order" and mean one thing while finance says "order" and means another, you've found a boundary.

Bounded context. A bounded context is the region within which a particular model and its language are consistent. The crucial insight: the same word can mean different things in different contexts, and that's fine. A "Customer" in the Sales context (a lead with a pipeline stage) is not the same as a "Customer" in the Shipping context (a name and an address) or the Billing context (a payment method and an invoice history). Forcing them into one shared Customer object is a classic way to create tight coupling across the whole system.

flowchart TB
  subgraph Sales["Sales context"]
    C1["Customer = lead,<br/>pipeline stage, deal size"]
  end
  subgraph Shipping["Shipping context"]
    C2["Customer = name,<br/>address, delivery prefs"]
  end
  subgraph Billing["Billing context"]
    C3["Customer = payment method,<br/>invoice history, credit"]
  end
  Sales -->|"published events /<br/>narrow API"| Shipping
  Sales -->|"published events /<br/>narrow API"| Billing

Aggregates (one level down) group objects that must stay consistent together and are treated as a single unit for changes — for example, an Order and its LineItems change as one. They're the smaller-scale version of the same "what changes together" question.

The big idea: bounded contexts are your candidate service boundaries. If you ever split a system, splitting along context lines gives you services with high internal cohesion and naturally narrow contracts between them — which is exactly the loose coupling you're after.

What this means for you as a lead

When you review a design, look at the boxes before the lines. Ask: "What's the single responsibility of this module — can you state it in one sentence without using 'and'?" and "When this changes, what else has to change?" If the answers reveal a module that changes for many unrelated reasons, or a change that ripples across half the system, you've found the real problem — and it's almost always more important than whatever framework or pattern is being debated. Boundaries are the highest-leverage thing you influence, and they're far cheaper to get right early than to fix later.