Skip to content
← All insights
System design16 min read

System design patterns: choose structures that keep change local

How to choose system-design patterns from real product constraints: clear boundaries, data ownership, synchronous and asynchronous coordination, resilience and gradual change.

A pattern responds to a recurring constraint

A system-design pattern is not a component diagram chosen before the product is understood. It is a repeatable response to a pressure such as independent change, unreliable dependencies, long-running work or competing writers.

The pattern name matters less than the problem it solves. A queue can absorb a burst and decouple a producer from a slow consumer, but introduces delay and duplicate delivery. A microservice can make a capability independently deployable, but creates a network boundary that must be operated.

Start with a plain-language decision: “a retry must not charge the customer twice” is a better starting point than “we need idempotency”. The pattern is useful when it makes that statement true and observable.

Start with a valuable user journey

Users experience journeys rather than component diagrams: create an account, submit an order, view a calculation or receive a notification. Trace one valuable journey from input to visible result before dividing the application into boxes.

For each step, identify the owner of the decision, the data it needs, the dependency it calls and the evidence that the result is correct. This separates core behaviour from technical plumbing and shows what genuinely needs an immediate response.

The first design does not need to solve every future use case. It needs a coherent route through the current important one, with boundaries that leave room for the next change.

Use boundaries to assign responsibility

A useful boundary owns a business decision and the data needed to make it. An order capability can own the lifecycle of an order; a notification capability can own delivery attempts and preferences. A boundary that merely wraps a database table usually has less to protect.

Within a modular monolith, boundaries can be packages and explicit interfaces. They still prevent unrelated code reaching into internal state. A process boundary is justified later when independent deployment, scaling, security or ownership makes the network cost worthwhile.

The test is whether a change can be explained locally. If every small rule touches five modules because none owns the decision, the system has coupling whether it runs in one process or fifty.

Expose a capability, not a persistence detailscala
trait OrderService:
  def place(command: PlaceOrder): Either[OrderError, OrderId]
  def find(id: OrderId): Option[Order]

// Callers ask the order capability to make an order.
// They do not write directly to its tables or collections.

Keep one source of truth for each fact

When several components can update the same fact, they eventually disagree about which value is authoritative. Choose an owner for customer preferences, order status or inventory allocation, then make other components use its public contract or a deliberate copy.

Read models and caches are often valuable copies. The distinction is that they declare their source and update path. A search index built from product events can be eventually consistent without becoming a second place where product data is edited.

Shared databases make accidental coupling easy because every service can see every table. If a database is shared temporarily, restrict access through modules, views or repositories and record the intended ownership boundary.

Use synchronous calls only when the answer is needed now

A request-response call fits when the next user-visible decision depends on the result: authenticate a user, validate a payment method or calculate a price before showing it. The caller should set a timeout, understand its fallback and avoid holding scarce resources while it waits.

Synchronous chains create a latency and availability budget. If checkout waits for stock, pricing, promotions, tax and recommendations, each added call makes the full journey slower and less reliable.

Keep the request path short. A dependency that is useful but not essential to the immediate decision should normally move out of the critical path rather than receive an ever-longer timeout.

Use events and queues for work that can happen later

Asynchronous messaging fits work that does not need to complete before the user receives an answer: sending email, generating reports, updating search or informing another bounded capability. The producer records that something happened; consumers perform their own work at a pace they can sustain.

Delivery is rarely exactly once. A consumer can complete its side effect and lose the acknowledgement, so it may receive the same message again. Make handlers idempotent by recording a stable event or business key and making repeated writes safe.

A queue is not an error sink. Monitor backlog age, failed messages, retry rate and consumer throughput. Decide which failures retry automatically, which need a dead-letter path and who resolves them.

Give an event a stable identitytext
OrderPlaced
  eventId:  "evt_01J..."
  orderId:  "ord_123"
  occurredAt: "2026-08-31T10:15:00Z"

Consumer rule:
  if eventId has already been handled, do nothing
  otherwise perform the local action and record eventId atomically

Make failure handling part of the contract

Networks fail, dependencies become slow and callers retry. A resilient design decides what the user sees and what the system records in each case. “Try again later” is only honest if the operation has not been partly accepted with no way to discover its state.

For operations that create value or move money, issue a stable client request key. Store it with the outcome so a retry returns the original result rather than performing the work again. Use bounded retries only when an operation is safe to repeat and a dependency is expected to recover.

Circuit breakers, bulkheads and rate limits limit the spread of failure. They do not make a critical path with too many unavailable dependencies acceptable.

Evolve from a well-structured monolith

A modular monolith is often the fastest safe starting point. It has one deployment and one set of operational tools, while internal modules make ownership visible and prevent casual coupling. It lets a team learn real boundaries before paying for distributed coordination.

Extract a service when a concrete constraint persists: one capability must scale independently, release on a different cadence, use a different security boundary or be owned by a team that cannot work effectively inside the same deployment.

A successful extraction makes a previously difficult change easier. If it only replaces function calls with HTTP calls while data ownership remains shared, it has added failure modes without removing the original coupling.

  • Which component owns each important fact?
  • Which journeys require an immediate answer?
  • Where can the system accept work and finish it later?
  • What makes a retry safe?
  • How are stalled messages discovered?
  • Can the owning team deploy, observe and recover independently?