Architecture for Large Systems
Ports and adapters for isolating core logic from I/O, event sourcing/CQRS as a consistency trade-off, observability as a first-class concern, and writing an ADR-driven incremental migration plan.
By the end of this lesson, you can
- Apply the ports-and-adapters pattern to isolate core logic from a specific I/O implementation
- Explain what event sourcing and CQRS trade for what, not just what they are
- Design a structured log line and a correlation id strategy for tracing one request across services
- Write an architectural decision record and an incremental (not big-bang) migration plan
Why it matters
Everything so far has covered a single module, function, or feature. A large system’s biggest risks are architectural: coupling that makes change expensive, consistency trade-offs made implicitly instead of deliberately, and a production incident that’s undiagnosable because nothing was designed to be observed. This lesson is about designing at that scale, and changing an existing system safely.
Ports and adapters
The ports-and-adapters pattern (also called hexagonal architecture) isolates core application logic behind interfaces (ports) it depends on, with concrete I/O implementations (adapters) plugged in from outside:
// The port — an interface the core logic depends on, not a concrete implementation
// (in plain JS, a shape/contract; in TypeScript, an actual interface)
// Core logic — knows nothing about HOW orders are persisted, only THAT they can be
async function placeOrder(orderRepository, order) {
validateOrder(order);
await orderRepository.save(order);
return order;
}
// An adapter — one concrete implementation of the port, swappable without touching core logic
const postgresOrderRepository = {
save: (order) => db.query('INSERT INTO orders ...', [order]),
};
const inMemoryOrderRepository = {
save: (order) => testOrders.push(order), // used in tests — recall dependency inversion
};This is the same dependency-inversion principle from Testing and Maintainability and Security and Isolation, applied at the scale of an entire module or service boundary rather than a single function — core business logic becomes testable without real I/O, and swapping a concrete adapter (a new database, a different email provider) never requires touching the core logic that depends on the port.
Event sourcing and CQRS: a trade, not a free upgrade
Traditional persistence stores current state directly. Event sourcing instead stores the sequence of events that led to that state, deriving current state by replaying them:
| Gains | Costs |
|---|---|
| A complete, auditable history of every change, not just the current snapshot | Reading current state requires replaying events (or maintaining a derived snapshot) |
| Time-travel debugging — reconstruct state as of any past point | Event schema changes over time require careful, deliberate migration/versioning strategy |
| Natural fit for genuinely event-driven domains (an audit log, a ledger) | Real added complexity for a domain that's naturally just "current state" |
CQRS (Command Query Responsibility Segregation) separates the model used for writes (commands) from the model used for reads (queries) — often paired with event sourcing, but a distinct idea on its own. The trade is similar: it enables independently optimizing/scaling reads and writes, at the cost of the read model potentially lagging behind the write model (an eventual-consistency trade, recall eventual consistency from Concurrency, Scheduling, and Distributed Effects).
Observability as a first-class concern
Observability means a system’s internal state can be understood from its external outputs — logs, traces, and metrics — without needing to attach a debugger to a live, running instance:
// A structured log line — machine-parseable, not just a human-readable string
logger.info('order.placed', {
orderId: order.id,
userId: order.userId,
amount: order.total,
correlationId: request.correlationId, // ties this log line to the specific request that caused it
});
A correlation id — generated once per incoming request and threaded through every downstream call and log line it causes — is what makes tracing a single request across multiple services (or even multiple async steps within one service) possible after the fact, rather than trying to reconstruct a sequence of events from unrelated log lines with no shared identifier.
Evolutionary architecture and ADRs
An architectural decision record (ADR) is a short, durable document capturing one significant decision: the context that prompted it, the decision itself, and its consequences — written at the time the decision is made, not reconstructed later from memory or a chat log.
# ADR 12: Adopt ports-and-adapters for the order service
## Context
The order service's core logic is currently tightly coupled to a specific ORM, making
it hard to test without a real database and blocking a planned database migration.
## Decision
Introduce a repository port; the core logic depends only on that interface. The current
ORM-backed implementation becomes one adapter; the planned migration adds a second.
## Consequences
- Core logic becomes unit-testable without a real database.
- Slightly more indirection for a reader unfamiliar with the pattern.
- The database migration becomes additive (new adapter) rather than a rewrite of core logic.
An incremental migration changes a coupled system in small, independently-shippable steps — each one leaving the system in a working state — rather than a “big-bang” rewrite that can’t ship until entirely finished, and that risks discovering a fundamental problem only after most of the work is already done.
Check your understanding
Or reveal the answer without checking
Answer:Real added complexity (separate read/write models, potential read-lag) with little corresponding benefit for this specific system's actual needs
CQRS trades added complexity for independently-scaled read/write paths — valuable when that specific trade-off is actually needed (high read/write asymmetry, independent scaling requirements). A low-traffic internal tool with no such need pays the complexity cost without a matching benefit.
Or reveal the answer without checking
Answer:A correlation id generated once per request and threaded through every downstream call and log line
Without a shared correlation id, reconstructing which log lines across three services actually belong to the same originating request requires guesswork based on timing alone — exactly the problem a correlation id threaded through the whole call chain solves directly.
Practice: warm-up
For a hypothetical decision — “switch the primary database from PostgreSQL to a document store” — write a one-paragraph Context section for an ADR, stating what problem prompted considering the change.
Stuck? Reveal one hint at a time.
Hint 1
A good Context section states the problem, not the solution — what's actually going wrong or proving difficult with the current approach.
Hint 2
Avoid justifying the decision itself here — that belongs in the Decision section; Context is purely "here is the situation that led us to consider a change."
Reveal an example Context section
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
Context: The application's data has grown increasingly schema-flexible — different order
types now carry substantially different fields, and the current PostgreSQL schema requires
frequent migrations to accommodate new order-type variations, each one carrying real
production risk and coordination overhead. We are evaluating whether a document-oriented
store would better fit this evolving, variable-shape data.Practice: apply it
A monolithic order-processing function is tightly coupled to a specific email-sending library called directly inside it. Sketch how you’d apply ports-and-adapters to make it swappable and independently testable.
Stuck? Reveal one hint at a time.
Hint 1
The port here is "something that can send an order-confirmation email" — defined as a simple function shape, not tied to any specific library.
Hint 2
The current email library becomes one adapter implementing that port; a test double implementing the same shape becomes another, used only in tests.
Reveal a reference solution
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
// Port: any function matching this shape works
// (email: { to, subject, body }) => Promise<void>
// Core logic depends on the port, not a concrete library
async function processOrder(order, sendEmail) {
await saveOrder(order);
await sendEmail({
to: order.customerEmail,
subject: 'Order confirmed',
body: `Your order ${order.id} is confirmed.`,
});
}
// Adapter: the real library, used in production
const sendViaProvider = (email) => emailLibrary.send(email);
// Adapter: a fake, used in tests
const sentEmails = [];
const sendFake = async (email) => {
sentEmails.push(email);
};Summary
- Ports-and-adapters isolates core logic behind interfaces it depends on, with concrete I/O plugged in from outside — dependency inversion applied at module/service scale, making core logic testable and I/O swappable without touching it.
- Event sourcing and CQRS each trade real complexity for a specific benefit (audit history, independently-scaled reads/writes) — not a default upgrade, and the wrong choice for a system that doesn’t actually need what they offer.
- Observability means understanding a system from its external outputs; structured logs plus a correlation id threaded through a request’s full call chain is what makes tracing an incident across services tractable after the fact.
- An ADR captures one architectural decision’s context, choice, and consequences at the time it’s made — a durable record, not a reconstruction from memory later.
- An incremental migration changes a coupled system in small, independently-shippable steps, each leaving it in a working state — safer than a big-bang rewrite that can’t ship until fully done.