Separation of Concerns: Boundaries Between Different Reasons to Change
📑 On this page
- A concrete example: invoice totals
- Concern means responsibility
- Presentation and domain logic
- Data access as a boundary
- Separate policy from mechanism
- Side effects and pure logic
- Layers organize dependency direction
- Ports and adapters
- Cross-cutting concerns
- Separation improves testing
- Separation limits change impact
- Boundaries can leak
- Too much separation creates fragmentation
- Organize around change
- Refactor toward discovered boundaries
- Knowledge check
- The one idea to remember
Software becomes difficult to change when unrelated responsibilities are tangled together.
A button click that directly calculates tax, writes a database row, formats an email, and calls a payment provider forces every concern into one place.
Separation of concerns gives distinct responsibilities clear boundaries so each can be understood, tested, and changed with limited impact on the others.
A useful boundary groups things that change for the same reason.
A concrete example: invoice totals
Suppose a user interface calculates:
const total = subtotal - discount + tax;The same rule is copied into a mobile app and a report generator.
When tax behavior changes, three codebases must update consistently.
A separate invoice-calculation module can expose:
calculateInvoiceTotal(invoice, taxPolicy)The website, mobile app, and report use one business rule while presenting the result differently.
Calculation and presentation are separate concerns.
Concern means responsibility
Common concerns include:
- User-interface presentation
- Business rules
- Data persistence
- Authentication
- Authorization
- Network communication
- Logging
- Notifications
- Configuration
The list is contextual.
In one system, tax is a small function inside checkout. In another, tax is a complex regulated domain with its own team and service.
Separation follows meaningful complexity and change, not a universal folder template.
Presentation and domain logic
Presentation decides:
- Labels
- Layout
- Formatting
- Input interaction
- Navigation
Domain logic decides:
- Eligibility
- Pricing
- Approval
- State transitions
- Business invariants
If domain rules live only in UI code, another interface can bypass them.
Keeping rules behind a stable interface allows websites, mobile apps, APIs, scheduled jobs, and tests to reuse them.
The UI still owns presentation-specific validation and feedback, but trusted business enforcement belongs deeper.
Data access as a boundary
Business logic should not need to know every SQL statement or storage-client detail.
An interface might provide:
findCustomerById
saveOrder
listUnpaidInvoicesThe data-access implementation owns queries, connection handling, and mapping stored records.
This does not mean hiding all database capabilities behind vague generic repositories.
The boundary should express domain operations while preserving important transaction and query semantics.
Separate policy from mechanism
Policy decides what should happen.
Mechanism provides how it can happen.
For access control:
- Policy: finance managers may approve refunds up to a limit.
- Mechanism: identity tokens, roles, and an authorization engine enforce the decision.
Keeping policy explicit makes it reviewable and changeable.
Scattering role-name checks throughout controllers makes it difficult to know the complete authorization rule.
Side effects and pure logic
Side effects include:
- Database writes
- Network calls
- File changes
- Sending messages
- Reading the clock
- Generating random values
Pure calculations are easier to test and reason about.
A checkout flow can separate:
- Calculate totals and validate cart.
- Request payment.
- Persist outcome.
- Publish notification work.
The flow still has side effects, but their boundaries and failure behavior become visible.
Do not pretend side effects can disappear. Isolate and coordinate them.
Layers organize dependency direction
A common layered arrangement is:
Presentation
Application workflow
Domain rules
Infrastructure adaptersThe exact names vary.
The important question is which direction dependencies point.
Core business rules should not need to import a web framework or specific database client. Infrastructure code can adapt those external details to interfaces the core understands.
This protects important rules from technology churn.
Ports and adapters
Hexagonal or ports-and-adapters architecture describes:
- Ports: Interfaces through which the application interacts
- Adapters: Implementations for databases, web requests, queues, or external services
For example, a payment port:
interface PaymentGateway {
charge(request: ChargeRequest): Promise<ChargeResult>;
}Production uses a real provider adapter. Tests can use a controlled fake.
The purpose is not interface creation everywhere. It is insulating meaningful external volatility.
Cross-cutting concerns
Logging, metrics, security, and tracing affect many components.
Careless separation can lead to business code filled with repetitive infrastructure calls.
Common approaches include:
- Middleware
- Decorators
- Framework hooks
- Shared libraries
- Platform-level instrumentation
Cross-cutting does not mean unowned.
Define schemas, privacy limits, failure behavior, and versioning. A shared logging helper can become a tight dependency across every service.
Separation improves testing
When calculation is separate from I/O:
- Unit tests can provide direct inputs.
- Failure cases do not require real payment calls.
- Database integration tests focus on queries.
- UI tests focus on interaction.
Different tests verify different boundaries.
If every unit test needs a browser, database, network, and clock, concerns are likely entangled.
Too much mocking can also indicate interfaces that do not match real behavior, so retain integration tests at important boundaries.
Separation limits change impact
Suppose an application changes email providers.
With a notification boundary, the provider adapter changes while order rules remain stable.
Without one, provider-specific request shapes may appear across checkout, account, and support code.
The practical measure of separation is not number of folders. It is how much unrelated code must change together for one business or technology reason.
Version-control history can reveal files that repeatedly change together.
Boundaries can leak
An abstraction leaks when callers need to know hidden implementation details.
Examples:
- A storage interface claims to be generic but exposes vendor error codes.
- A repository returns database-specific lazy objects.
- A payment abstraction requires every caller to handle one provider's status names.
Some leaks are unavoidable because underlying behavior matters.
Do not hide transaction limits, consistency, latency, or failure semantics merely to create a cleaner interface.
A good boundary hides replaceable detail while exposing consequential behavior.
Too much separation creates fragmentation
Every boundary adds:
- Names
- Interfaces
- Navigation
- Data conversion
- Ownership questions
- Testing surfaces
Splitting a five-line cohesive calculation into seven classes does not improve clarity.
Separating every module into a network service adds far more complexity: deployment, authentication, latency, and partial failure.
Create a boundary when responsibilities, rates of change, risk, ownership, or testing benefit justify it.
Organize around change
Ask:
- Which rules change together?
- Which technology is likely to be replaced?
- Which data needs stronger protection?
- Which capability has separate ownership?
- Which side effect needs independent failure handling?
These questions produce better boundaries than grouping all functions, all data classes, or all utilities together merely by technical shape.
Modules should tell the story of the domain.
Refactor toward discovered boundaries
Teams do not need perfect boundaries before learning.
Signals for refactoring include:
- Repeated logic
- Files changed for unrelated reasons
- Tests with excessive setup
- Cyclic dependencies
- Provider details spread everywhere
- One module knowing too much
Refactor incrementally:
- Identify one responsibility.
- Define its inputs and outputs.
- Move behavior behind a clear interface.
- Add tests.
- Migrate callers.
Boundaries can emerge from evidence.
Knowledge check
- What does it mean to group things that change for the same reason?
- Why should domain rules not exist only in the user interface?
- How do ports and adapters protect core logic?
- What consequential behavior should an abstraction avoid hiding?
- When can separation make software worse?
The one idea to remember
Separate concerns when responsibilities have different reasons to change, different risks, or different external dependencies. The goal is not more pieces; it is less unrelated change traveling through the same piece.