AdvancedArchitecture for large systems~45 min

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:

JavaScriptCore logic depending on a port, not a concrete database
// 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:

What event sourcing trades for what
GainsCosts
A complete, auditable history of every change, not just the current snapshotReading current state requires replaying events (or maintaining a derived snapshot)
Time-travel debugging — reconstruct state as of any past pointEvent 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

A team adopts CQRS for a simple internal admin tool with low traffic and no auditing requirement. What's the most likely outcome?
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.

A production incident spans three services. Logs exist in all three, but none share a common identifier tying the request together. What's missing?
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.

  1. Hint 1

    A good Context section states the problem, not the solution — what's actually going wrong or proving difficult with the current approach.

  2. 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.

  1. 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.

  2. 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.