AYOUB.*
← CS FUNDAMENTALSREPOS ↗

Chapter 6 · Part II: Software Architecture, Languages, and System Design

Software Design Patterns and Domain-Driven Design

A boundary in code is cheap to move. A boundary across a network is not. This chapter covers the design principles that survive the move from a function call to a remote call, and the domain modeling techniques that decide where the boundary belongs.

In this chapter
  1. What a boundary buys and what it costs
  2. SOLID, read again for systems that fail
  3. Hexagonal architecture and the dependency rule
  4. Strategic domain-driven design
  5. Tactical design: aggregates and the consistency boundary
  6. Event sourcing
  7. CQRS and the read model
  8. Sagas, compensation, and the outbox
  9. Anti-patterns that cost the most
  10. Sources and further reading

What a boundary buys and what it costs

A boundary separates two pieces of code so that one can change without the other. Inside one process a boundary costs an interface and an indirect call, which Chapter 5 priced at under a nanosecond. Across a network the same boundary costs a serialization, a round trip, a partial failure mode, and a deployment coupling. The distance between those two numbers is six orders of magnitude, and almost every bad architecture comes from forgetting it.

The rule that follows is short. Put boundaries where the domain has a seam, and turn a boundary into a network hop only when an independent reason demands it. Good reasons include a different scaling profile, a different failure domain, a different compliance boundary, and a different team that deploys on its own schedule. A diagram that looks tidy is not a reason.

Table 6.1: the cost of one boundary crossing

Boundary Latency Failure modes Change cost
Function call under 1 ns exception only rename in one commit
Interface call, same module under 1 ns exception only rename in one commit
Module call, same process under 1 ns exception only one build, one deploy
In-process message bus 50 to 500 ns queue full, handler throws one deploy
Unix socket, same host 5 to 15 us peer gone, backpressure two deploys
HTTP within a datacenter 200 us to 2 ms timeout, partial write, retry storm two deploys, one contract
HTTP across regions 30 to 150 ms all of the above, plus partition two deploys, one contract
Asynchronous message 1 ms to hours duplicate, out of order, poison two deploys, one schema

The change cost column matters more than the latency column over a product lifetime. A rename inside a module is one commit. The same rename across a network contract is a versioned change, a compatibility window, two deployments in order, and a rollback plan.

SOLID, read again for systems that fail

The five SOLID principles were written for classes. Each one has a form that applies to a service, and the service form is the one that costs money when you get it wrong.

Single responsibility says that a module has one reason to change. For a service, the reason to change is a stakeholder, not a noun. A service owned by the payments team and the fraud team has two reasons to change and it will have two release schedules that fight. Split by the reason, not by the entity.

Open for extension, closed for modification says that new behavior arrives through new code rather than through edits to existing code. For a service it means that adding a consumer of an event needs no change to the producer. A producer that keeps a list of consumers has the property inverted, and every new consumer becomes a change to the producer.

Liskov substitution says that a subtype works wherever the supertype works. For a service it means that version N plus 1 accepts everything that version N accepted and produces something that every version N client still parses. That is exactly backward and forward compatibility, and Chapter 12 turns it into schema rules.

Interface segregation says that a client depends on no method it does not use. For a service it means narrow, purpose-built endpoints rather than one getEverything call. A wide endpoint couples every client to every field, and one field's change breaks clients that never read it.

Dependency inversion says that both sides depend on an abstraction. For a service it means that the domain logic defines the interface and the infrastructure implements it. That single sentence is the whole content of hexagonal architecture, and the next section builds it.

Connascence measures coupling more precisely than a word

Meilir Page-Jones proposed connascence as a graded measure of coupling. Two pieces of code are connascent when a change in one requires a change in the other. The kinds run from weak to strong: name, type, meaning, position, algorithm, execution order, timing, value, and identity. The rule is to keep strong forms inside a small scope and allow only weak forms across a boundary. Connascence of position, which is what a positional argument list creates, is tolerable inside a function and intolerable across a wire protocol. That is why every durable wire format uses named or numbered fields.

Hexagonal architecture and the dependency rule

Layered architecture puts the user interface on top, the domain in the middle, and the database at the bottom, with each layer depending on the one below. The arrangement fails on one point: the domain depends on the database. Business rules then cannot be tested, cannot be reused, and change whenever the storage technology changes.

Hexagonal architecture, which Alistair Cockburn also named ports and adapters, inverts that dependency. The domain sits in the center and defines ports, which are interfaces expressed in domain terms. Adapters implement the ports against real technology. Every dependency arrow points inward. The domain knows nothing about HTTP, about SQL, or about a message broker.

                    DRIVING SIDE (the outside calls in)
     +-------------+   +-------------+   +--------------------+
     | HTTP handler|   | CLI command |   | message consumer   |
     +------+------+   +------+------+   +---------+----------+
            |                 |                    |
            v                 v                    v
     +=========================================================+
     |            APPLICATION SERVICES (use cases)             |
     |  +---------------------------------------------------+  |
     |  |                 DOMAIN MODEL                      |  |
     |  |  entities, value objects, aggregates, events      |  |
     |  |  defines PORTS as interfaces, in domain words     |  |
     |  +---------------------------------------------------+  |
     +====================+=====================+==============+
                          |                     |
                          v                     v
            +-------------+-----+   +-----------+-----------+
            | Postgres adapter  |   | Kafka publisher       |
            +-------------------+   +-----------------------+
                    DRIVEN SIDE (the domain calls out)

  The domain compiles with no import of HTTP, SQL, or Kafka.
  A test replaces both adapters with in-memory implementations.

Figure 6.1 Ports and adapters. Every arrow points toward the domain.

The test consequence is the practical payoff. The whole domain runs in a unit test with an in-memory repository, in microseconds, with no container and no fixture. Teams that adopt the structure report test suites that run in seconds rather than minutes, and the speed changes how often people run them.

The cost is indirection. A small service with one storage technology and no plan to change it gains little and pays an interface for every operation. Use the full structure where the domain logic is rich. Use a simpler layering where the service is a thin translation over a table.

Strategic domain-driven design

Domain-driven design starts from language. A ubiquitous language is a vocabulary that the domain experts and the engineers both use, in conversation, in the code, and in the tests. When a domain expert says "reservation" and the code says BookingRecord, every conversation pays a translation tax and every misunderstanding survives into production.

A bounded context is the scope inside which one model and one language hold. The critical insight is that the same word means different things in different contexts, and forcing one model across them produces a model that serves nobody. In a shipping company, "customer" in the sales context holds contract terms and credit limits. "Customer" in the routing context holds a delivery address and access instructions. They are different models with the same name, and the mistake is to merge them into one class with thirty fields.

Bounded contexts are the correct unit for a service boundary. A context has its own model, its own language, its own data, and usually its own team. A service that spans two contexts holds two models and leaks one into the other. A context split across three services shares a database and loses its autonomy.

Context mapping names the relationships between contexts. Seven patterns cover almost every case.

  • Partnership. Two teams succeed or fail together and coordinate releases.
  • Shared kernel. Two contexts share a small model, which both must agree to change.
  • Customer and supplier. The downstream team's needs enter the upstream team's backlog.
  • Conformist. The downstream context adopts the upstream model with no translation.
  • Anticorruption layer. The downstream context translates the upstream model into its own.
  • Open host service. The upstream publishes a stable protocol for many consumers.
  • Published language. A shared, versioned schema, such as an event contract.

The anticorruption layer is the one to reach for when integrating with a legacy system or with a vendor. It is a translation layer that keeps the foreign model out of the domain. It costs a mapping and it prevents a vocabulary that nobody chose from spreading through the code base.

Subdomains sort by value rather than by size. A core subdomain is where the business competes, and it deserves the best engineers and a hand-built model. A supporting subdomain is necessary and not differentiating, and it deserves a simple implementation. A generic subdomain is a solved problem, and it deserves a purchase rather than a build. Teams routinely invert this and build a custom authentication system while buying a pricing engine.

Tactical design: aggregates and the consistency boundary

Inside a bounded context, the tactical patterns organize the model.

An entity has an identity that persists through change. A customer with identifier 4471 remains the same customer after a name change. A value object has no identity and equals another value object with the same fields. Money, a date range, and an address are value objects. Value objects are immutable, which removes a whole class of aliasing bug, and they carry their own validation, which removes a whole class of invalid state.

An aggregate is a cluster of entities and value objects with one root. The root is the only object that outside code references, and every change goes through it. The reason for the pattern is not tidiness. An aggregate is a transactional consistency boundary. Everything inside it is consistent after every operation, and everything outside it is consistent eventually.

Four rules make aggregates work in a distributed system.

  1. Protect one true invariant per aggregate. If a rule does not need immediate consistency, it belongs outside.
  2. Reference other aggregates by identity, never by object reference. A direct reference invites a transaction that spans both.
  3. Change one aggregate per transaction. Two aggregates in one transaction means a lock that spans them and a bottleneck that appears under load.
  4. Keep aggregates small. A large aggregate is a large lock, and concurrent updates to unrelated parts will conflict.
# An aggregate root that owns its invariant and emits events.
class Order:
    MAX_LINES = 100

    def __init__(self, order_id: OrderId, customer_id: CustomerId):
        self._id = order_id
        self._customer_id = customer_id      # reference by identity
        self._lines: list[OrderLine] = []
        self._status = OrderStatus.DRAFT
        self._events: list[DomainEvent] = []
        self._version = 0

    def add_line(self, sku: Sku, quantity: int, unit_price: Money) -> None:
        if self._status is not OrderStatus.DRAFT:
            raise OrderAlreadySubmitted(self._id)
        if len(self._lines) >= self.MAX_LINES:
            raise TooManyOrderLines(self._id, self.MAX_LINES)
        if quantity < 1:
            raise InvalidQuantity(quantity)

        self._lines.append(OrderLine(sku, quantity, unit_price))
        self._events.append(OrderLineAdded(self._id, sku, quantity))

    def submit(self, credit_limit: Money) -> None:
        # The invariant this aggregate protects: an order never exceeds
        # the credit limit that the caller supplies.
        if self.total() > credit_limit:
            raise CreditLimitExceeded(self._id, self.total(), credit_limit)
        self._status = OrderStatus.SUBMITTED
        self._events.append(OrderSubmitted(self._id, self.total()))

    def total(self) -> Money:
        return sum((line.subtotal() for line in self._lines), Money.zero())

    def pull_events(self) -> list[DomainEvent]:
        events, self._events = self._events, []
        return events

Note what the aggregate does not do. It does not read the credit limit from a customer service, because that makes the domain depend on infrastructure. The application service reads the limit and passes it in. It does not publish its events, because publishing is infrastructure. It collects them, and the application service publishes after the transaction commits.

A repository gives the aggregate a collection-like interface. It loads an aggregate by identity and saves it, and it exposes nothing else. A repository with a findByStatusAndDateRangeAndCustomerType method is no longer a repository. It is a query service, and the difference matters because the read side deserves its own model.

Event sourcing

Event sourcing stores the sequence of changes rather than the current state. The current state is a fold over the events. The event log is the source of truth, and it is append-only.

The pattern gives four properties that a state-storing design cannot. It gives a complete audit trail for free, because the events are the audit trail. It gives temporal queries, because folding the events up to time T gives the state at time T. It gives debugging by replay, because a bug reproduces from the exact event sequence. It gives new read models at any time, because a new projection replays history.

  command                    +-------------------+
  SubmitOrder(id) ---------> | load aggregate    |
                             |  replay events    |
                             |  from snapshot    |
                             +---------+---------+
                                       | validate invariant
                                       v
                             +-------------------+
                             | append new events |----+
                             | (expected version)|    |
                             +-------------------+    |
                                                      v
   EVENT LOG (append only, the source of truth)
   +-----+-----+-----+-----+-----+-----+-----+-----+-----+
   | e1  | e2  | e3  | e4  | e5  | e6  | e7  | e8  | e9  |
   +-----+-----+-----+-----+-----+-----+-----+-----+-----+
      |            |                          |
      v            v                          v
  +---------+ +-----------+           +----------------+
  | orders  | | analytics |           | search index   |   PROJECTIONS
  | summary | | warehouse |           | (Elasticsearch)|   rebuildable
  +---------+ +-----------+           +----------------+   eventually
                                                            consistent

Figure 6.2 Event-sourced write side and projected read side.

Three problems come with it, and each has a known answer.

Replay cost grows with history. An aggregate with 500,000 events takes too long to load. Snapshots solve it: store the folded state every N events and replay only the tail. Pick N so that replay stays under a few milliseconds, which is usually between 100 and 1,000 events.

Schema change is permanent. An event written in 2019 must still be readable in 2029, because the log never gets rewritten. Upcasting solves it: a small function converts a version 1 event into a version 2 event at load time, and the chain of upcasters grows over time. Keep events small and keep them factual, because a small factual event survives schema change and a large event carrying derived data does not.

Deletion conflicts with an append-only log. A privacy regulation that gives a person the right to erasure meets a log that never deletes. Crypto shredding solves it: encrypt each subject's personal data with a per-subject key, store the keys in a mutable store, and delete the key on an erasure request. The events remain, and their personal content becomes unreadable.

Event sourcing is not a default

The pattern costs a projection pipeline, a snapshot mechanism, an upcasting chain, and a team that understands eventual consistency. It pays for itself in domains where history is the product: finance, insurance, compliance, and anything with an audit obligation. In a domain where nobody ever asks what a record looked like last March, it is overhead. A common middle path stores current state as the source of truth and emits events for integration, which gives the integration benefit without the replay machinery.

CQRS and the read model

Command query responsibility segregation, or CQRS, uses different models for writes and reads. The write model enforces invariants and stays normalized. The read model serves queries and denormalizes freely. A projection keeps the read model current from the write model's events.

The separation exists because the two sides have different requirements. The write side needs a small consistency boundary and an aggregate shape. The read side needs whatever shape answers the query in one lookup, which is usually a wide, denormalized row or a document. Forcing one schema to do both produces a schema that does neither well.

CQRS introduces eventual consistency between the two sides, and that fact reaches the user interface. A user submits an order and then reads the order list, and the projection has not caught up. Three fixes exist. Read your own writes from the write side for a short window after a write. Include the expected version in the read request and wait for the projection to reach it. Or design the interface so that the user sees an optimistic local result while the projection catches up.

Three conditions justify CQRS. The read and write loads differ by an order of magnitude. The queries need shapes that the write model cannot serve. Or the read side needs a different storage technology. Do not use it because the write model and the read model are both "Order". A single model with a few query methods is the correct design for most services.

Sagas, compensation, and the outbox

A transaction that spans several aggregates or several services cannot use a database transaction. Two-phase commit exists and it holds locks across a network, which turns a slow participant into a stalled system. Chapter 8 covers why that trade is usually wrong.

A saga replaces the distributed transaction with a sequence of local transactions plus compensating actions. Each step commits locally and publishes an event. If a later step fails, the saga runs compensating actions for the completed steps, in reverse order.

Compensation is not rollback. A rollback restores the previous state as though nothing happened. A compensation performs a new, visible business action: a refund rather than an un-charge, a cancellation notice rather than a deleted booking. Design the compensating action with the domain expert, because it is a business decision.

  ORCHESTRATOR                                 PARTICIPANTS
  +---------------------+
  | 1. reserve stock    |----------------> [ inventory ] OK
  +---------------------+
  | 2. charge card      |----------------> [ payments  ] OK
  +---------------------+
  | 3. book courier     |----------------> [ shipping  ] FAILS
  +---------------------+
  | compensate in reverse order:
  |   2c. refund card   |----------------> [ payments  ]
  |   1c. release stock |----------------> [ inventory ]
  +---------------------+

  Orchestration: one component owns the flow. Easy to see, easy to
  change, and a single point of coupling.
  Choreography: each service reacts to events with no coordinator.
  Loosely coupled, and no single place shows the whole flow.

Figure 6.3 Orchestrated saga, with compensation on failure.

Orchestration puts the sequence in one component. The flow is visible, the timeouts are explicit, and the coupling concentrates in the orchestrator. Choreography spreads the sequence across event handlers. Coupling is lower and the flow exists only in the reader's head, which makes debugging harder. Choose orchestration for flows with more than three steps or with real compensation, and choreography for simple notification chains.

The dual write problem breaks both. A service that writes to its database and then publishes an event performs two writes with no transaction between them. A crash between them leaves the database updated and the event lost, or the event published and the database unchanged. Retrying does not fix it, because the failure happens between the two.

The transactional outbox fixes it. Write the event to an outbox table in the same database transaction as the state change. A separate relay reads the outbox and publishes, marking rows as sent. The database transaction makes the state change and the outbox row atomic, and the relay gives at-least-once delivery to the broker.

-- One transaction covers both the state change and the intent to publish.
BEGIN;

UPDATE orders
   SET status = 'SUBMITTED', version = version + 1
 WHERE order_id = $1 AND version = $2;   -- optimistic concurrency

INSERT INTO outbox (id, aggregate_id, event_type, payload, occurred_at)
VALUES (gen_random_uuid(), $1, 'OrderSubmitted', $3, now());

COMMIT;

-- A relay process, or a change-data-capture stream, publishes and marks:
--   SELECT ... FROM outbox WHERE published_at IS NULL
--   ORDER BY occurred_at LIMIT 500 FOR UPDATE SKIP LOCKED;

Change data capture, which Chapter 12 covers, replaces the polling relay by reading the database write-ahead log directly. Debezium is the common implementation. The outbox row appears in the log, the connector publishes it, and no polling query runs.

Anti-patterns that cost the most

The distributed monolith is the most expensive failure in this chapter. It happens when a system is split into services that still deploy together, share a database, and call each other synchronously in a chain. It has the operational cost of microservices and the coupling of a monolith. The test is simple: if one service cannot deploy without another deploying in the same window, it is not a separate service.

The shared database is the usual cause. Two services that write the same tables share a schema, and a schema change breaks both. The database becomes the integration contract, and nobody can change it. Give each service its own schema, and integrate through an API or through events.

Entity services split by noun rather than by capability. A CustomerService, an OrderService, and a ProductService force every real use case to call all three. The result is a chatty synchronous chain with a tail latency problem and no clear owner for any business rule. Split by capability instead: Checkout, Fulfilment, Pricing.

Nanoservices split too far. A service that wraps one table and three fields costs a repository, a deployment pipeline, a dashboard, an on-call rotation, and a network hop, and it holds no logic. Merge it.

Start with a modular monolith

Build one deployable unit with hard internal module boundaries. Enforce the boundaries with a tool, not with a convention: Java modules, .NET internal visibility, Rust crates, or an architecture test such as ArchUnit. Give each module its own schema inside the shared database and forbid cross-module table access. When one module needs a different scaling profile or a different team, extract it. The boundaries are already there, and extraction becomes a deployment change rather than a redesign. Teams that split first and find the boundaries later pay for every wrong guess at the price in Table 6.1.

Sources and further reading

Eric Evans, "Domain-Driven Design", 2003, for the strategic and tactical patterns. Vaughn Vernon, "Implementing Domain-Driven Design", for the aggregate rules and for practical modeling. Alistair Cockburn's original ports and adapters article. Chris Richardson, "Microservices Patterns", for sagas and the outbox. Greg Young's talks on event sourcing and CQRS, and the distinction he draws between the two. Meilir Page-Jones, "What Every Programmer Should Know About Object-Oriented Design", for connascence. Sam Newman, "Monolith to Microservices", for extraction order.

What this chapter established

  • A boundary inside a process costs a nanosecond. The same boundary across a network costs a round trip, a partial failure mode, and a versioned contract.
  • Each SOLID principle has a distributed form. Dependency inversion in that form is the whole of hexagonal architecture.
  • In ports and adapters, every dependency points inward. The domain compiles without importing HTTP, SQL, or a broker, and the full domain runs in a unit test in microseconds.
  • A bounded context is the correct unit for a service. The same word carries different models in different contexts, and merging them produces a model that serves nobody.
  • An aggregate is a transactional consistency boundary, not a tidy grouping. Change one aggregate per transaction and reference others by identity.
  • Event sourcing gives audit, temporal queries, and rebuildable read models. It costs snapshots, upcasting, and crypto shredding for the right to erasure.
  • CQRS pays when read and write loads or shapes differ sharply. It introduces eventual consistency that reaches the user interface, and read-your-own-writes needs an explicit design.
  • A saga replaces a distributed transaction with local transactions and compensating business actions. Compensation is a new visible action, not a rollback.
  • The dual write problem has one correct fix: a transactional outbox, or change data capture reading the database log.
  • A distributed monolith costs more than either alternative. Start with a modular monolith, enforce module boundaries with a tool, and extract when a real reason appears.

Chapter 6 of Computer Science: From First Principles to Production Scale, revision 1.0. Attribution and sole author credit: Ayoub Zulfiqar.