Separation of Concerns: The Foundation of Maintainable Software
Master separation of concerns in modern software design. Explore Dijkstra and Parnas principles, Hexagonal architecture, vertical slices, and TypeScript patterns.

Separation of Concerns: The Foundation of Maintainable Software
Software systems rarely degrade because of a single catastrophic architectural failure. Degradation is almost always the cumulative result of hundreds of minor, pragmatic compromises that steadily blur the boundaries between unrelated responsibilities.
When a database migration breaks HTTP response serialization, or when a payment gateway SDK upgrade forces modifications in inventory validation, the system suffers from architectural entanglement. The codebase resists modification, unit tests become brittle integration suites, and onboarding new engineers requires transferring an invisible catalog of unwritten side effects.
Separation of Concerns (SoC) is frequently cited as self-evident software wisdom, yet production codebases routinely violate it. SoC is not an aesthetic preference for tidy files or neat folder trees. It is a fundamental engineering mechanism designed to manage human cognitive limits, isolate the blast radius of change, preserve business invariants, and enable evolutionary architecture.
Theoretical Foundations: Cognitive Limits and Information Hiding
To apply Separation of Concerns effectively, we must first understand the theoretical foundations laid by computer science pioneers in the 1970s. Modern architectural patterns—such as Hexagonal, Onion, Clean, and Vertical Slice architectures—are direct evolutions of these core ideas.
Dijkstra (1974): Managing Human Cognitive Capacity
Edsger W. Dijkstra coined the phrase "separation of concerns" in his 1974 paper On the role of scientific thought (EWD447). Dijkstra observed that the human mind cannot simultaneously process all dimensions of a complex problem without being overwhelmed:
"It is what I sometimes have called 'the separation of concerns', which, even if not perfectly possible, is yet the only available technique for effective ordering of one's thoughts, that I know of. This is what I mean by focusing one's attention upon some aspect: it does not mean ignoring the other aspects, it is just doing justice to the fact that from this aspect's point of view, the other is irrelevant. It is being one- and multiple-track minded simultaneously."
In a running production system, business rules, concurrency controls, persistence mechanisms, network protocols, and security policies execute simultaneously. However, a human engineer reasoning about order discount calculations should not be forced to keep relational query execution plans, HTTP header encodings, or WebSocket connection pools in working memory.
Separation of concerns is cognitive ergonomics: it enables an engineer to focus entirely on one aspect of a system with confidence that orthogonal aspects will remain unaffected.
Parnas (1972): Information Hiding and Secrets
Two years prior to Dijkstra's formulation, David L. Parnas published a landmark paper titled On the Criteria To Be Used in Decomposing Systems into Modules (Communications of the ACM, 1972).
Parnas challenged the dominant software engineering practice of decomposing systems according to execution flowcharts—where modules were split sequentially into "Step 1: Read Input", "Step 2: Validate Data", "Step 3: Process", and "Step 4: Output". Parnas proved that flowchart-based decomposition produces tightly coupled systems because every module depends on shared data representations across each execution step.
Instead, Parnas proposed that modular decomposition should be driven by Information Hiding:
- Every module must encapsulate a volatile design decision—a "secret" likely to change over the system's lifecycle.
- The module exposes a stable, abstract interface that reveals nothing about how the secret is implemented.
- Callers depend exclusively on the contract, remaining completely isolated from changes to the internal implementation.
In modern application engineering, volatile "secrets" include database engines, third-party vendor SDKs, wire protocols, serialization formats, and physical storage mechanisms.
Disambiguating the Concepts: SoC, SRP, Cohesion, and Coupling
Architectural discussions often conflate Separation of Concerns with related principles. Understanding their distinct boundaries ensures they are applied with precision:
- Separation of Concerns (SoC): The overarching meta-principle of partitioning a system into distinct sections, where each section addresses a separate, well-defined problem domain (e.g., isolating delivery protocols from domain logic).
- Single Responsibility Principle (SRP): Formulated by Robert C. Martin within SOLID, SRP states: "A module should have one, and only one, reason to change." Martin explicitly ties "reason to change" to a specific business actor or stakeholder (e.g., accounting vs. inventory management). While SoC separates broad technical and domain concerns, SRP aligns class and module boundaries with organizational actors.
- High Cohesion: An internal metric evaluating the degree to which elements within a single module belong together functionally. A highly cohesive module performs a single, tightly defined task where all methods and state variables cooperate.
- Low Coupling: An external metric evaluating the degree of direct interdependence between separate modules. Loosely coupled modules interact via minimal, explicit interfaces, allowing internal implementations to change without rippling through dependents.
High Cohesion (Focused Internal Scope)
+
Low Coupling (Minimal Inter-Module Dependencies)
=
Sustainable Separation of Concerns
The Anatomy of Tangled Software: The Procedural Anti-Pattern
To appreciate clean boundaries, we must examine what happens when concerns are collapsed into a single procedural block. The following TypeScript example illustrates a common anti-pattern: an Express-style HTTP handler managing an e-commerce order checkout.
import type { HttpRequest, HttpResponse } from "./http-types";
// Anti-Pattern: Procedural controller intertwining transport,
// validation, business logic, persistence, external APIs, and side effects.
export async function checkoutHandler(req: HttpRequest, res: HttpResponse): Promise<void> {
try {
// 1. Delivery & Authentication Concern
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing or malformed authorization token" });
return;
}
const token = authHeader.substring(7);
const userId = token.split(".")[0]; // Insecure, primitive token parsing
// 2. Request Parsing and Shape Validation Concern
const { items, paymentMethodId } = req.body as {
items?: Array<{ productId: string; quantity: number; unitPriceInCents: number }>;
paymentMethodId?: string;
};
if (!items || !Array.isArray(items) || items.length === 0) {
res.status(400).json({ error: "Order must contain at least one item" });
return;
}
if (!paymentMethodId || typeof paymentMethodId !== "string") {
res.status(400).json({ error: "Valid paymentMethodId is required" });
return;
}
// 3. Domain Logic & Invariant Calculation Concern
let totalCents = 0;
for (const item of items) {
if (!item.productId || item.quantity <= 0 || item.unitPriceInCents < 0) {
res.status(422).json({ error: "Invalid line item price or quantity" });
return;
}
totalCents += item.quantity * item.unitPriceInCents;
}
// 4. Persistence Concern (Direct dependency on database driver)
const db = (globalThis as unknown as {
databaseClient: { query: (sql: string, params: unknown[]) => Promise<{ rows: any[] }> }
}).databaseClient;
const inventoryResult = await db.query(
"SELECT product_id, stock FROM inventory WHERE product_id = ANY($1)",
[items.map((i) => i.productId)]
);
for (const item of items) {
const stockRow = inventoryResult.rows.find((r: any) => r.product_id === item.productId);
if (!stockRow || stockRow.stock < item.quantity) {
res.status(409).json({ error: `Insufficient inventory for product: ${item.productId}` });
return;
}
}
// 5. External Payment Gateway Concern (Raw HTTP integration)
const stripeResponse = await fetch("https://api.stripe.com/v1/payment_intents", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY ?? ""}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
amount: totalCents.toString(),
currency: "usd",
payment_method: paymentMethodId,
confirm: "true",
}),
});
const paymentData = (await stripeResponse.json()) as { id?: string; error?: { message?: string } };
if (!stripeResponse.ok || !paymentData.id) {
res.status(502).json({
error: "Payment gateway rejected transaction",
detail: paymentData.error?.message,
});
return;
}
// 6. Persistence Update Concern
const insertResult = await db.query(
"INSERT INTO orders (user_id, total_cents, status, payment_id) VALUES ($1, $2, $3, $4) RETURNING id",
[userId, totalCents, "PAID", paymentData.id]
);
const orderId = insertResult.rows[0].id as string;
// 7. Notification Concern (Asynchronous side effect bound to HTTP request lifecycle)
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RESEND_API_KEY ?? ""}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "orders@openstair.example",
to: "customer@example.com",
subject: `Order Confirmation #${orderId}`,
text: `Your order totaling $${(totalCents / 100).toFixed(2)} has been confirmed.`,
}),
});
res.status(201).json({ orderId, status: "PAID", totalCents });
} catch (error: unknown) {
// Leaks low-level internal database or network exceptions to caller
res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
}
}
Architectural Failure Modes of the Procedural Approach
While this function appears straightforward in a code review, it harbors severe architectural defects that emerge under production pressure:
- Violation of the Inward Dependency Rule: The delivery layer (HTTP) directly references database tables and external vendor endpoints. If the database schema changes from
total_centstoamount_minor_units, the HTTP controller file must be edited. - Untestable Domain Invariants: The business logic (e.g., calculating item totals and verifying non-negative quantities) cannot be verified without spinning up an HTTP server, mocking database query results, mocking Stripe HTTP responses, and stubbing email APIs. As a consequence, unit tests are abandoned.
- Partial Failures and Broken Transactions: If the Stripe payment succeeds but the subsequent SQL
INSERTstatement fails (e.g., due to a database deadlock or constraint violation), the customer's card is billed, but no order record exists. The handler lacks an orchestration layer capable of executing compensating actions. - Absence of Change Locality: Changing the email notification service from Resend to SendGrid or Amazon SES requires modifying the checkout controller. The blast radius of an infrastructure change encompasses the core business checkout workflow.
Architectural Topologies: Visualizing Clean Boundaries
The diagrams below contrast a tangled architecture with systems designed using explicit inward dependency boundaries and feature-based decomposition.
Diagram 1: Tangled Monolith vs. Decoupled Boundary Architecture
TANGLED PROCEDURAL MONOLITH DECOUPLED BOUNDARY ARCHITECTURE
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ HTTP Controller │ │ Delivery Layer (HTTP / RPC) │
│ ├── Token Parsing │ └───────────────┬───────────────┘
│ ├── Request Validation │ │ (Input DTO)
│ ├── Domain Calculations │ ▼
│ ├── Raw SQL Queries ───────┼──┐ ┌───────────────────────────────┐
│ ├── Third-Party Stripe API │ │ │ Application Use Case │
│ └── Email Dispatch │ │ │ (Orchestration & Transactions)│
└───────────────────────────────┘ │ └───┬───────────────┬───────────┘
│ │ │
│ ▼ ▼
│ ┌───────────┐ ┌─────────────────┐
│ │ Pure │ │ Ports │
│ │ Domain │ │ (Interfaces) │
│ │ Model │ └────────┬────────┘
│ └───────────┘ │
│ ▼
│ ┌─────────────────┐
└──────────────────>│ Adapters │
│ (SQL / Stripe) │
└─────────────────┘
Diagram 2: The Inward Dependency Rule (Clean / Hexagonal)
In a Hexagonal architecture (Ports and Adapters), dependencies point strictly inward toward business logic. The core domain never imports outer infrastructure frameworks.
+-------------------------------------+
| INFRASTRUCTURE / DELIVERY |
| (HTTP, GraphQL, gRPC, CLI, CRON) |
| | |
| v |
| +-----------------------------+ |
| | APPLICATION LAYER | |
| | (Use Cases, Commands) | |
| | | | |
| | v | |
| | +---------------------+ | |
| | | DOMAIN CORE | | |
| | | Entities, Value | | |
| | | Objects, Invariants| | |
| | +---------------------+ | |
| | ^ | |
| | | | |
| | Ports (Output) | |
| +-----------------------------+ |
| ^ |
| | |
| Adapters (Postgres, Stripe, S3) |
+-------------------------------------+
Diagram 3: Horizontal Layers vs. Vertical Slice Decomposition
HORIZONTAL LAYERING (Silos) VERTICAL SLICE ARCHITECTURE (Features)
┌─────────────────────────────────┐ ┌──────────────┬──────────────┬──────────────┐
│ Presentation / Controllers │ │ Feature: │ Feature: │ Feature: │
├─────────────────────────────────┤ │ Place Order │ Cancel Order │ View History │
│ Application Services │ │ │ │ │
├─────────────────────────────────┤ │ ├── Endpoint │ ├── Endpoint │ ├── Endpoint │
│ Domain Model │ │ ├── Validator│ ├── Validator│ ├── Query │
├─────────────────────────────────┤ │ ├── Domain │ ├── Domain │ ├── SQL/View │
│ Persistence / Repositories │ │ └── Storage │ └── Storage │ └── DTO │
└─────────────────────────────────┘ └──────────────┴──────────────┴──────────────┘
(Shotgun surgery across layers) (Locality of change within feature slice)
Multi-Axis Separation: Horizontal, Vertical, and Cross-Cutting Concerns
Separation of concerns is not a one-dimensional problem. Mature architectures separate code across three orthogonal axes: technical layers (horizontal), business capabilities (vertical), and system-wide policies (cross-cutting).
1. Horizontal Separation (Technical Layers)
Horizontal separation groups components by their technical role within the software lifecycle:
- Delivery / Presentation: Transports data over wire protocols (HTTP status codes, header parsing, WebSockets, gRPC stubs, CLI arguments). It knows nothing about SQL or business calculations.
- Application Orchestration: Implements specific use cases (e.g.,
PlaceOrderUseCase). It coordinates transactions, retrieves domain models via abstract ports, invokes domain methods, and calls secondary adapters. It contains no direct SQL or HTTP dependencies. - Domain Model: Encapsulates enterprise business rules, entities, and value objects. This code has zero framework dependencies. It enforces invariants regardless of whether the request originated from an HTTP POST, a CLI script, or a message queue consumer.
- Infrastructure / Persistence: Implements outbound ports. It translates domain operations into database queries (Postgres, DynamoDB), cache operations (Redis), or external API calls (Stripe, Twilio).
2. Vertical Separation (Vertical Slice Architecture)
While horizontal layering provides clear technical categories, strictly enforcing it across a large codebase often creates the Shotgun Surgery code smell. Adding a single field to a user profile might require modifying files across six directories: UserController, UserService, UserDomainModel, UserRepository, UserEntity, and UserDto.
Vertical Slice Architecture—popularized by Jimmy Bogard—organizes code around features and business capabilities rather than technical layers.
In a vertical slice architecture:
- Each use case is an independent slice (
Features/Orders/PlaceOrder,Features/Orders/CancelOrder). - A slice contains only the code necessary to fulfill that specific capability: its request structure, input validator, handler, and database query.
- Slices do not share generic abstraction layers unless business invariants require it.
- Coupling between features is minimized, while cohesion within a feature is maximized.
3. Cross-Cutting Concerns
Cross-cutting concerns represent functionality required across multiple vertical slices and horizontal layers, such as:
- Authentication and Role-Based Access Control (RBAC)
- Distributed tracing (e.g., OpenTelemetry span creation)
- Structured application logging and metric aggregation
- Rate limiting and circuit breaking
- Database transaction demarcation
The engineering goal is to apply cross-cutting behavior without polluting pure domain logic. This is achieved using architectural patterns such as:
- HTTP / RPC Middleware: Intercepts requests at the delivery boundary to enforce authentication and rate limiting before invoking the application layer.
- Decorators / Interceptors: Wraps application use cases with telemetry or transaction management.
- Domain Events: Emits immutable domain facts (
OrderPlacedEvent) that asynchronous subscribers process independently for logging, notifications, or cache invalidation.
Architecture Comparison: Horizontal, Vertical, and Hexagonal
The following matrix compares the three dominant architectural patterns across six concrete engineering criteria:
| Evaluation Dimension | Horizontal Layering | Vertical Slice Architecture | Hexagonal (Ports & Adapters) |
| :--- | :--- | :--- | :--- |
| Primary Organization Axis | Technical roles (Presentation, Service, Data Access) | Business capabilities / Use cases (PlaceOrder, RefundOrder) | Inward dependency boundaries (Core Domain vs. Outer Adapters) |
| Change Locality (Feature Addition) | Low; requires modifying classes across all technical layers (Shotgun Surgery) | High; changes are isolated entirely within the specific feature directory | Moderate to High; localized to use case, domain model, and necessary adapters |
| Domain Logic Isolation | Moderate; domain models frequently leak into persistence entities or services | Variable; simple slices may query storage directly, bypassing heavy domain models | Maximum; domain models are completely decoupled from external frameworks |
| Abstraction Overhead | High; requires mapping DTOs and interfaces across every horizontal boundary | Minimal; code is tailored strictly to the use case without speculative layers | Moderate to High; requires explicit port interfaces and adapter implementations |
| Team Topology & Scale | Fits small teams; can cause merge conflicts as multiple teams touch shared layers | Excels in mid-to-large teams; squads own distinct feature slices independently | Excels in core enterprise domains with complex business invariants and long lifecycles |
| Automated Testing Strategy | Heavy reliance on integration tests or deep mocking of intermediate layers | Slices tested via fast, black-box handler tests against an in-memory database | Lightning-fast domain and use-case unit tests using in-memory port stubs |
Implementing Clean Boundaries: Ports and Adapters in TypeScript
To demonstrate how separation of concerns works in practice, we refactor the procedural order checkout into a clean Ports and Adapters architecture.
Notice the following invariants:
- The domain models (
Money,OrderItem,Order) import zero external libraries. - Ports are interfaces owned by the application layer.
- Infrastructure adapters implement the ports and handle all database and vendor-specific nuances.
- The composition root binds concrete dependencies at application startup.
1. The Pure Domain Layer (Invariants & Entities)
// domain/money.ts
export class Money {
readonly amountInCents: number;
readonly currency: string;
constructor(amountInCents: number, currency: string = "USD") {
if (!Number.isInteger(amountInCents) || amountInCents < 0) {
throw new Error(`Invalid monetary amount: ${amountInCents}. Must be a non-negative integer.`);
}
if (!currency || currency.length !== 3) {
throw new Error(`Invalid currency code: ${currency}. Must be a 3-letter ISO code.`);
}
this.amountInCents = amountInCents;
this.currency = currency.toUpperCase();
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error(`Cannot add distinct currencies: ${this.currency} and ${other.currency}`);
}
return new Money(this.amountInCents + other.amountInCents, this.currency);
}
multiply(quantity: number): Money {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new Error(`Multiplier must be a positive integer. Received: ${quantity}`);
}
return new Money(this.amountInCents * quantity, this.currency);
}
equals(other: Money): boolean {
return this.amountInCents === other.amountInCents && this.currency === other.currency;
}
}
// domain/order.ts
export class OrderItem {
constructor(
readonly productId: string,
readonly quantity: number,
readonly unitPrice: Money
) {
if (!productId || productId.trim().length === 0) {
throw new Error("OrderItem requires a valid productId.");
}
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new Error(`OrderItem quantity must be a positive integer. Received: ${quantity}`);
}
}
get subtotal(): Money {
return this.unitPrice.multiply(this.quantity);
}
}
export type OrderStatus = "PENDING" | "PAID" | "CANCELLED";
export class Order {
private _status: OrderStatus;
private _paymentId?: string;
private constructor(
readonly id: string,
readonly customerId: string,
private readonly _items: OrderItem[],
status: OrderStatus = "PENDING",
paymentId?: string
) {
if (!id || !customerId) {
throw new Error("Order requires a valid id and customerId.");
}
if (_items.length === 0) {
throw new Error("Order must contain at least one line item.");
}
this._status = status;
this._paymentId = paymentId;
}
static create(id: string, customerId: string, items: OrderItem[]): Order {
return new Order(id, customerId, [...items], "PENDING");
}
static reconstitute(
id: string,
customerId: string,
items: OrderItem[],
status: OrderStatus,
paymentId?: string
): Order {
return new Order(id, customerId, [...items], status, paymentId);
}
get items(): ReadonlyArray<OrderItem> {
return [...this._items];
}
get status(): OrderStatus {
return this._status;
}
get paymentId(): string | undefined {
return this._paymentId;
}
calculateTotal(): Money {
const currency = this._items[0].unitPrice.currency;
return this._items.reduce(
(total, item) => total.add(item.subtotal),
new Money(0, currency)
);
}
markAsPaid(paymentTransactionId: string): void {
if (this._status !== "PENDING") {
throw new Error(`Cannot mark order as PAID from state: ${this._status}`);
}
if (!paymentTransactionId || paymentTransactionId.trim().length === 0) {
throw new Error("A valid payment transaction ID is required.");
}
this._status = "PAID";
this._paymentId = paymentTransactionId;
}
cancel(): void {
if (this._status === "PAID") {
throw new Error("Cannot cancel an order that has already been paid. Issue a refund instead.");
}
this._status = "CANCELLED";
}
}
2. Application Ports (Interfaces Owned by the Core)
// application/ports.ts
import type { Order } from "../domain/order";
import type { Money } from "../domain/money";
export interface OrderRepositoryPort {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
export interface PaymentGatewayPort {
charge(amount: Money, paymentMethodId: string): Promise<{ transactionId: string }>;
}
export interface InventoryPort {
reserve(items: ReadonlyArray<{ productId: string; quantity: number }>): Promise<void>;
}
3. Application Use Case (Workflow Orchestration)
// application/place-order.use-case.ts
import { Order, OrderItem, type OrderStatus } from "../domain/order";
import { Money } from "../domain/money";
import type { OrderRepositoryPort, PaymentGatewayPort, InventoryPort } from "./ports";
export interface PlaceOrderCommand {
orderId: string;
customerId: string;
items: Array<{
productId: string;
quantity: number;
unitPriceInCents: number;
currency: string;
}>;
paymentMethodId: string;
}
export interface PlaceOrderResult {
orderId: string;
status: OrderStatus;
totalAmountInCents: number;
currency: string;
paymentTransactionId: string;
}
export class PlaceOrderUseCase {
constructor(
private readonly orderRepository: OrderRepositoryPort,
private readonly paymentGateway: PaymentGatewayPort,
private readonly inventory: InventoryPort
) {}
async execute(command: PlaceOrderCommand): Promise<PlaceOrderResult> {
// 1. Instantiate pure domain objects (enforcing domain invariants)
const domainItems = command.items.map(
(item) =>
new OrderItem(
item.productId,
item.quantity,
new Money(item.unitPriceInCents, item.currency)
)
);
const order = Order.create(command.orderId, command.customerId, domainItems);
const total = order.calculateTotal();
// 2. Coordinate external ports
await this.inventory.reserve(
domainItems.map((item) => ({ productId: item.productId, quantity: item.quantity }))
);
const payment = await this.paymentGateway.charge(total, command.paymentMethodId);
// 3. Mutate domain state via explicit method
order.markAsPaid(payment.transactionId);
// 4. Persist updated aggregate state
await this.orderRepository.save(order);
// 5. Return decoupled application DTO
return {
orderId: order.id,
status: order.status,
totalAmountInCents: total.amountInCents,
currency: total.currency,
paymentTransactionId: payment.transactionId,
};
}
}
4. Infrastructure Adapters (Database and Payment Gateways)
// infrastructure/postgres-order-repository.ts
import type { OrderRepositoryPort } from "../application/ports";
import { Order, OrderItem } from "../domain/order";
import { Money } from "../domain/money";
export interface DatabaseClient {
query(sql: string, params: unknown[]): Promise<{ rows: any[] }>;
}
export class PostgresOrderRepository implements OrderRepositoryPort {
constructor(private readonly db: DatabaseClient) {}
async save(order: Order): Promise<void> {
await this.db.query(
`INSERT INTO orders (id, customer_id, status, payment_id, total_cents, currency)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
payment_id = EXCLUDED.payment_id`,
[
order.id,
order.customerId,
order.status,
order.paymentId ?? null,
order.calculateTotal().amountInCents,
order.calculateTotal().currency,
]
);
for (const item of order.items) {
await this.db.query(
`INSERT INTO order_items (order_id, product_id, quantity, unit_price_cents, currency)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (order_id, product_id) DO UPDATE SET
quantity = EXCLUDED.quantity`,
[
order.id,
item.productId,
item.quantity,
item.unitPrice.amountInCents,
item.unitPrice.currency,
]
);
}
}
async findById(id: string): Promise<Order | null> {
const orderRes = await this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
if (orderRes.rows.length === 0) return null;
const row = orderRes.rows[0];
const itemsRes = await this.db.query("SELECT * FROM order_items WHERE order_id = $1", [id]);
const items = itemsRes.rows.map(
(r: any) =>
new OrderItem(
r.product_id,
r.quantity,
new Money(r.unit_price_cents, r.currency)
)
);
return Order.reconstitute(row.id, row.customer_id, items, row.status, row.payment_id);
}
}
// infrastructure/stripe-payment-adapter.ts
import type { PaymentGatewayPort } from "../application/ports";
import type { Money } from "../domain/money";
export class StripePaymentGatewayAdapter implements PaymentGatewayPort {
constructor(private readonly apiKey: string) {}
async charge(amount: Money, paymentMethodId: string): Promise<{ transactionId: string }> {
const response = await fetch("https://api.stripe.com/v1/payment_intents", {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
amount: amount.amountInCents.toString(),
currency: amount.currency.toLowerCase(),
payment_method: paymentMethodId,
confirm: "true",
}),
});
const data = (await response.json()) as { id?: string; error?: { message?: string } };
if (!response.ok || !data.id) {
throw new Error(`Stripe charge failed: ${data.error?.message ?? "Gateway transaction rejected"}`);
}
return { transactionId: data.id };
}
}
5. Delivery Adapter and Composition Root
// delivery/order-http-controller.ts
import type { PlaceOrderUseCase, PlaceOrderCommand } from "../application/place-order.use-case";
import type { HttpRequest, HttpResponse } from "./http-types";
export class OrderHttpController {
constructor(private readonly placeOrderUseCase: PlaceOrderUseCase) {}
async handlePlaceOrder(req: HttpRequest, res: HttpResponse): Promise<void> {
try {
const body = req.body as {
orderId?: string;
customerId?: string;
items?: Array<{
productId: string;
quantity: number;
unitPriceInCents: number;
currency: string;
}>;
paymentMethodId?: string;
};
if (!body.orderId || !body.customerId || !body.items || !body.paymentMethodId) {
res.status(400).json({ error: "Missing required order payload fields." });
return;
}
const command: PlaceOrderCommand = {
orderId: body.orderId,
customerId: body.customerId,
items: body.items,
paymentMethodId: body.paymentMethodId,
};
const result = await this.placeOrderUseCase.execute(command);
res.status(201).json(result);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unexpected error";
// Explicitly map domain and port exceptions to HTTP semantics
if (message.includes("Invalid") || message.includes("requires")) {
res.status(422).json({ error: message });
} else if (message.includes("Stripe charge failed")) {
res.status(402).json({ error: "Payment failed", details: message });
} else {
res.status(500).json({ error: "Internal order processing error." });
}
}
}
}
Architectural Traps and Anti-Patterns
Implementing separation of concerns is fraught with traps where teams introduce abstraction without achieving real isolation.
Trap 1: The "Lasagna Architecture" (Over-Engineering Pass-Through Layers)
The Lasagna Architecture occurs when a team introduces multiple horizontal layers that perform zero transformation or invariant validation. Each layer merely re-packages data from the layer above and forwards it to the layer below:
HTTP Controller (UserRequest)
↓
Application Facade (UserDTO)
↓
Business Service (UserServiceDTO)
↓
Domain Model (UserEntity)
↓
Data Access Layer (UserRecord)
↓
Relational Database Table
When every change to a database field requires updating six distinct files across six directories with identical property names, the architecture provides zero isolation. It imposes an abstraction tax with none of the benefits of information hiding.
The Remedy: Apply Command-Query Responsibility Segregation (CQRS). For write operations involving complex business invariants, use the full aggregate and use-case pipeline. For simple read operations (e.g., populating a user profile UI), allow query handlers to fetch read-model DTOs directly from the persistence store, bypassing unnecessary domain aggregates.
Trap 2: Leaky Abstractions and Entity Leakage
A frequent violation of Parnas's Information Hiding principle is Entity Leakage: returning active database ORM entities (such as Prisma or TypeORM instances) directly to API clients or presentation templates.
// LEAKY ANTI-PATTERN: Exposing ORM entities directly across the HTTP boundary
export async function getUserProfile(userId: string) {
// Returns raw database model including passwordHash, internal flags, and database IDs
return await prisma.user.findUnique({ where: { id: userId } });
}
Hazards of Entity Leakage:
- Security Vulnerabilities (Mass Assignment): If an incoming HTTP request body is deserialized directly into an ORM model without filtering, malicious clients can inject fields like
isAdmin: trueoraccountBalance: 99999. - Unintended N+1 Database Queries: Serializing ORM entities to JSON often triggers lazy-loaded relational properties, initiating dozens of unexpected SQL queries inside the HTTP serialization loop.
- Schema Coupling: Modifying a database column name or data type immediately breaks public REST API contracts.
Comparison: Leaky Boundaries vs. Enforced Boundaries
| Feature | Leaky Boundary (Entity Leakage) | Enforced Boundary (Decoupled DTOs) | | :--- | :--- | :--- | | API Contract Stability | Tightly bound to database schema; migrations break external clients | Stable; API contracts change only when external DTO definitions change | | Security Risk | High; susceptible to mass assignment and accidental field exposure | Low; payload shape is strictly validated against explicit input contracts | | Lazy Loading Behavior | Unpredictable; JSON serialization can trigger hidden queries | Predictable; data is loaded eagerly and mapped explicitly before transfer | | Domain Invariant Enforcement| Weak; ORM entities are often anemic data bags with public setters | Strong; mutations occur exclusively through domain aggregate methods |
Trap 3: Premature Network Boundary Splitting (The Microservices Fallacy)
A common mistake in modern cloud architectures is confusing separation of concerns with separation of physical processes.
Engineers frequently attempt to enforce boundaries by splitting a system into dozens of microservices before domain boundaries have stabilized. When concerns are prematurely distributed across the network:
- In-memory function calls are replaced by remote HTTP/gRPC network hops, introducing latency, timeout handling, and partial failure modes.
- Single-database ACID transactions are lost, forcing teams to implement complex distributed sagas, two-phase commits, and eventual consistency reconciliations.
- Cross-cutting schema changes require coordinated deployments across multiple repositories and CI/CD pipelines.
Separation of concerns is primarily a logical module boundary, not a physical deployment topology. A well-structured modular monolith provides strong separation of concerns via in-process module boundaries while retaining simple local testing, shared ACID transactions, and zero network serialization overhead. Microservices should be reserved for scenarios requiring independent scaling, differing technology stacks, or autonomous team deployment cadences.
Production Code Review Checklist: Evaluating Separation of Concerns
Before approving pull requests that introduce new architectural layers or feature modules, evaluate them against this practical engineering checklist:
- [ ] Domain Independence: Does the core domain model contain zero imports from web frameworks (
express,next,fastify), ORM libraries (prisma,typeorm), or third-party cloud SDKs? - [ ] Consumer-Driven Port Ownership: Are ports (interfaces) defined in the application layer based on what the domain workflow requires, rather than mimicking vendor SDK method signatures?
- [ ] True Information Hiding: Can the database engine or third-party payment vendor be replaced without modifying any file inside the domain or application use-case folders?
- [ ] Absence of Shotgun Surgery: Can a business invariant rule (such as discount eligibility or tax calculation) be modified by changing only the domain entity and its corresponding unit test?
- [ ] Strict Boundary Translation: Are database records and external API responses mapped to domain entities or application DTOs before crossing into the business logic layer?
- [ ] Sub-Millisecond Domain Unit Tests: Can core business workflows be tested in milliseconds using in-memory port stubs without running Docker containers, databases, or local network servers?
- [ ] Explicit Composition Root: Are dependencies instantiated and wired together in an explicit entry point or dependency injection container, rather than using
newwithin business services?
Conclusion: Architecture as Cognitive Ergonomics
Separation of concerns is not an abstract academic exercise or a dogma to be followed blindly. It is an engineering discipline that aligns the structure of software with the cognitive limits of the human engineers who build and maintain it.
By anchoring system design in Dijkstra’s focus on cognitive isolation and Parnas’s information hiding, software teams can establish durable boundaries:
- Keep the core domain pure and focused on business invariants.
- Invert dependencies using ports so infrastructure serves the domain, not the other way around.
- Organize feature-heavy code around vertical slices to ensure change locality.
- Beware of over-engineered pass-through layers and premature microservice distribution.
When applied with discipline, Separation of Concerns produces software that is straightforward to test, safe to refactor, resilient under partial failure, and capable of evolving as business requirements change.


