skip to content

How do you decide when NOT to separate two concerns? Give the costs of a boundary and the signals that a split is wrong.

level: principalimportance: should knowfreq 29%

answer

  1. boundary = investment; option value vs. premium
  2. co-change 80% → don't split
  3. never split inside a consistency boundary/aggregate
  4. chatty calls, lockstep releases, sagas = wrong seam
  5. modularize early, distribute late; two-way vs one-way door

basics

~20 s

Every boundary costs something: extra interfaces, mapping code, and — if it's a process boundary — network calls and lost transactions. Don't split when two things always change together, must stay consistent in one transaction, or when you don't yet understand the domain.

solid answer

~60 s

A boundary is an investment repaid only if the two sides genuinely vary independently. Costs: indirection and mapping code; a contract you must now version and keep backward compatible; harder end-to-end reasoning and debugging; and, across a process boundary, network latency, partial failure, loss of ACID transactions (forcing sagas/eventual consistency), distributed tracing needs, and duplicated operational burden. Don't separate when: the two parts always co-change (commit history proves it); they share an invariant that must hold atomically; the split would land inside a single aggregate/consistency boundary; the domain is still poorly understood (premature boundaries in the wrong place are the expensive kind — they're hard to move once contracts and teams form around them); or when the performance profile can't absorb the hop. Signals a split is wrong: chatty cross-boundary conversation, distributed transactions or two-phase commits, changes that always touch both sides, an interface with one implementation and no prospect of another, and mapping code exceeding logic. The safe path is to start with a boundary that's cheap to move — a module inside one process — and only harden it into a network boundary when independent deployment, scaling, or ownership demands it.

go deeper

for a junior

Say boundaries add extra code and indirection, and that things that always change together should stay together.

for a middle

List concrete costs — mapping code, versioning, latency, partial failure, lost transactions — and name co-change and shared invariants as reasons not to split.

for a senior

Give the wrong-split signals (chattiness, sagas, lockstep releases, shared database) and argue modularize-early / distribute-late with evidence from change history.

for a principal

Frame it as reversibility and option pricing: which boundaries are two-way doors, what makes a boundary organizationally irreversible, how to keep optionality cheap, and how you'd validate a proposed split before committing the org to it.

## The premise: boundaries are not free Separation of Concerns is usually taught as an unqualified good. At architecture scale it is an *investment decision*: you pay a fixed and recurring cost for each boundary, in exchange for optionality — the ability to change or replace one side without the other. Judgment means knowing when the option is worth its premium. ## The full cost list **In-process boundary (module/interface):** - Indirection: an interface, a factory/wiring point, often DTOs and mapping code in both directions. - Cognitive cost: reading a feature means jumping across files; stack traces deepen. - Refactoring friction: a change that spans the boundary must be coordinated on both sides. - Risk of an abstraction shaped by one implementation, which then fits no other. **Process/network boundary (service):** everything above, plus - **Latency and chattiness** — an in-process call is nanoseconds; a network call is milliseconds and can be retried, timed out, or lost. - **Partial failure** — the callee may be down, slow, or have processed the request but failed to reply. This forces idempotency, retries, timeouts, circuit breakers, and dead-letter handling. - **Loss of ACID across the boundary** — you cannot commit both sides atomically. You adopt sagas/compensations, the outbox pattern, and eventual consistency, and you must design for the windows where the system is temporarily inconsistent. - **Contract versioning** — the interface becomes a published API with backward-compatibility obligations and independent release cycles. - **Operational multiplication** — separate deploys, dashboards, alerts, on-call, secrets, CI pipelines; distributed tracing to reconstruct one user action. - **Data duplication and staleness** — each side owns data, so the other holds a replica or read model that lags. ## When NOT to separate 1. **The parts always co-change.** If commit history shows A and B in the same commit 80% of the time, they are one concern with a line drawn through it. A boundary here converts every change into a two-sided, possibly two-team change. 2. **A shared invariant must hold atomically.** In DDD terms, do not split *inside* an aggregate — the aggregate is precisely the consistency boundary. "Order total must equal the sum of its lines" cannot survive being split across services without heavy machinery. 3. **The domain is not yet understood.** Early boundaries are guesses; a wrong in-process boundary is a refactor, a wrong service boundary is a migration plus data ownership change plus team reorganization. Prefer to keep it monolithic-but-modular until change patterns reveal the seams. ThoughtWorks' "monolith first" and the modular-monolith position both rest on this. 4. **The volume of interaction is high and latency-sensitive.** A boundary crossed thousands of times per request is a design error, whatever its conceptual purity. 5. **The team can't operate it.** One team, no platform maturity, no tracing — the operational cost of a network boundary exceeds the modularity benefit. 6. **The split is along a non-existent variation axis.** An interface abstracting something that will never have a second implementation is cost without option value. ## Signals an existing split is wrong - **Chatty conversation** across the boundary (N+1 remote calls; needing three calls to render one screen). - **Distributed transactions / two-phase commit** appearing, or ad-hoc "undo" code compensating for partial failure of what should have been one operation. - **Lockstep releases**: neither side can ship without the other — the option you paid for doesn't exist. - **Co-change**: every user story's diff spans both sides. - **Shared database behind separate services** — the boundary is fictional; a schema change hits both. - **Mapping code dominating**: more lines converting between representations than implementing behavior. - **A pass-through layer** that only forwards calls (the *sinkhole* anti-pattern). ## Reversibility as the deciding lens Martin Fowler's framing of architecture as "the decisions that are hard to change" plus Bezos's *one-way vs. two-way doors* gives the practical rule: - **In-process module boundary** — cheap to introduce, cheap to move. Introduce liberally, on suspicion. - **Network/service boundary, separate data ownership, separate team** — expensive to move (contracts, data migration, org change). Require evidence: proven independent change rate, independent scaling need, independent deployment need, regulatory isolation, or a team-ownership requirement. So the default: **modularize early, distribute late**. Get the SoC benefit (deletability, clear ownership, testability) at module scale, and pay the distribution premium only where an independent axis is demonstrated. ## Cohesion is the other half SoC without cohesion produces fragmentation. The right question is not "are these two things different?" — almost everything is different from everything — but "do these two things change *for different reasons, at different rates, or for different stakeholders*?" If they change together, keep them together; that is high cohesion, and it is as much a part of good design as separation.

  • You inherit two services that are always released together and share a database. What do you do?
    Treat it as a mis-drawn boundary. Either merge them back into one deployable with an internal module boundary, or fix the boundary properly: give each exclusive data ownership and replace synchronous coupling with a published contract or events. Merging is usually the cheaper first step — it removes the operational premium while retaining a movable in-process seam.
  • What makes a boundary hard to move later, beyond the code itself?
    Published contracts consumed by third parties, data ownership and migration, separate operational tooling and on-call rotations, and — most durably — team structure. Once a team owns a service, moving the boundary becomes an organizational change, which is why service boundaries behave like one-way doors while module boundaries do not.
  • How do you get most of the benefit of separation without paying the distribution cost?
    A modular monolith: enforce module boundaries inside one deployable with compile-time or tooling checks (module systems, dependency rules, architecture tests), give each module its own schema and public API, and forbid reaching across internals. You get deletability, ownership, and testability; you keep in-process calls and one transaction; and the seam stays cheap to move or later extract.

Boundaries are like walls in a house: a partition wall you can move next year, a load-bearing wall you cannot. Put up partitions freely; think hard before you make one structural — and never put a wall through the middle of the staircase everyone uses.

context