Separation of Concerns
Status: Complete
Category: Architecture
Default enforcement: Soft
Author: PushBackLog team
Tags
- Topic: architecture, quality
- Skillset: any
- Technology: generic
- Stage: execution, review
Summary
A system should be organised so that each distinct concern (business logic, data access, presentation, infrastructure) is handled in a distinct module or layer, with minimal coupling between them. Changes to one concern should rarely require changes to another.
Rationale
What a “concern” is
Edsger Dijkstra introduced the term “separation of concerns” in 1974. A concern is any aspect of a system that has a distinct purpose: displaying data, persisting data, validating business rules, handling HTTP requests, sending emails, logging. When two concerns are tangled in the same module or function, a change to either one requires understanding and risk-assessing the other.
Change propagation
The cost of a change in a well-separated system is local. The cost of a change in a tangled system is unpredictable. When business logic is embedded in SQL queries, “update the commission calculation” requires a database migration. When database logic is in a React component, “swap MySQL for PostgreSQL” requires frontend changes. Separation of concerns contains the blast radius of change.
Testability as a proxy for SoC
A module that is easy to test in isolation is almost always well-separated from its concerns. A unit test that requires a running database, a real HTTP server, and a configured email sender to test a discount calculation is a diagnostic: those concerns have not been separated.
Guidance
Common concern boundaries
| Concern | Responsibility |
|---|---|
| Presentation / UI | Rendering, formatting, user interaction |
| Application / Use case | Orchestrating domain objects to fulfil a specific use case |
| Domain / Business logic | Business rules, invariants, calculations, domain events |
| Infrastructure | Database queries, HTTP calls, file I/O, queue publishing |
| Configuration | Environment-specific settings, feature flags |
The classic layered architecture institutionalises these concern boundaries. Hexagonal architecture (ports and adapters) takes it further by making the domain entirely independent of all infrastructure.
Practical signs of mixed concerns
- SQL queries in UI components or controllers
- Business rules in database stored procedures
- HTTP error handling logic in domain objects
- Logging and error reporting embedded in business logic methods
- Formatting/presentation logic inside API response serialisers that also apply business transformations
Dependency direction
Separation of concerns is most effective when the dependency direction is intentional: lower-level concerns (infrastructure, persistence) depend on higher-level ones (domain, application), not vice versa. This is the Dependency Inversion Principle applied at the architectural level.
Examples
Mixed concerns (anti-pattern)
// An express route handler that queries the DB, applies business logic,
// formats a response, AND sends an email
app.post('/orders', async (req, res) => {
const items = await db.query(`SELECT * FROM products WHERE id IN (${req.body.items.join(',')})`);
let total = items.reduce((s, i) => s + i.price * req.body.quantities[i.id], 0);
if (total > 1000) total *= 0.9; // Business rule buried in HTTP handler
const order = await db.query(`INSERT INTO orders ... RETURNING *`);
await sendgrid.send({ to: req.body.email, subject: 'Order confirmed' });
res.json({ orderId: order.id, total: total.toFixed(2) + ' GBP' }); // Formatting in handler
});
Five concerns in one function. Untestable without a real DB, a real email service, and a running HTTP server.
Separated concerns
// Controller: HTTP concern only
app.post('/orders', async (req, res) => {
const command = CreateOrderCommand.fromRequest(req.body);
const order = await orderService.createOrder(command);
res.status(201).json(OrderResponse.from(order));
});
// Application service: orchestration
class OrderService {
async createOrder(command: CreateOrderCommand): Promise<Order> {
const items = await this.productRepo.findByIds(command.itemIds);
const order = Order.create(items, command.quantities); // Domain logic
await this.orderRepo.save(order);
await this.emailService.sendConfirmation(command.email, order);
return order;
}
}
// Domain: business logic only, no infrastructure
class Order {
static create(items: Product[], quantities: Map<string, number>): Order { ... }
applyVolumeDiscount(): void { if (this.total > 1000) this.total *= 0.9; }
}
Each concern can be tested in isolation. The domain class has no imports from infrastructure.
Anti-patterns
1. Database logic in UI components
SQL queries or ORM calls in React/Vue/Angular components. Frontend components should call API endpoints; the API layer handles persistence.
2. Business rules in HTTP controllers
Controllers translate HTTP to domain language and back. When discount rules, validation logic, and permission checks accumulate in controllers, they become untestable without a running HTTP server.
3. Formatting in domain models
Domain objects returning currency strings, formatted dates, or HTML. Domain models should work with raw values; formatting is a presentation concern.
4. Cross-cutting concerns repeated everywhere
Logging, tracing, timing, and audit logic copied into every method. These cross-cutting concerns are handled once with middleware, decorators, or aspect-oriented patterns — not inline in every function.
Related practices
Part of the PushBackLog Best Practices Library. Suggest improvements →