How do you decide when NOT to separate two concerns? Give the costs of a boundary and the signals that a split is wrong.
answer
- boundary = investment; option value vs. premium
- co-change 80% → don't split
- never split inside a consistency boundary/aggregate
- chatty calls, lockstep releases, sagas = wrong seam
- modularize early, distribute late; two-way vs one-way door
basics
~20 sEvery 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 sA 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
Say boundaries add extra code and indirection, and that things that always change together should stay together.
List concrete costs — mapping code, versioning, latency, partial failure, lost transactions — and name co-change and shared invariants as reasons not to split.
Give the wrong-split signals (chattiness, sagas, lockstep releases, shared database) and argue modularize-early / distribute-late with evidence from change history.
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.