Filter concepts by levelShowing all levels.

System Design · Section 9

Basic Architecture Patterns

Level
intermediate
Read
28 min
Concepts
7

Six named shapes a system can take — three-tier and layered architecture for a single deployable's internal structure, modular monolith and microservices for how independently its parts deploy and scale, and event-driven and pipeline architecture for how data and work move through it — closed out by the discipline that matters more than any one pattern: naming what each choice buys and what it costs, rather than treating any pattern as universally correct.

What is true here

  1. Three-tier: client → application → database. Layered: presentation → application/service → domain → data access → storage.
  2. Modular monolith: one deployable, enforced module boundaries, module-owned data, in-process calls.
  3. Microservices: multiple independently deployable services, each owning its own data, communicating over the network.
  4. Event-driven: producers publish to a broker without knowing or waiting on consumers. Pipeline: ingestion → processing → storage/output.
  5. No pattern is universally superior — each buys a specific property and costs a specific price; the right choice matches actual team and system constraints.

What you will be able to do

  • Describe the three-tier and layered patterns and where each boundary sits
  • Distinguish a modular monolith from both a plain monolith and microservices
  • Explain what a microservices network call needs that an in-process call does not
  • Describe the producer/broker/consumer shape of event-driven architecture and the ingestion/processing/storage shape of a pipeline
  • State the trade-off (what is bought, what is cost) for each of the six patterns before choosing one

The six patterns

Six named architectural shapes, from a single deployable's internal layers to fully independent, network-connected services.

Three-tier architecture

corebeginner

Three-tier architecture splits a system into a client (what the user interacts with), an application tier (business logic), and a database tier (persistent storage) — each tier only talks to its immediate neighbor.

Think of it as

Think of a restaurant: the client is the dining room where a customer places an order, the application tier is the kitchen that turns the order into food using business rules (recipes), and the database tier is the pantry storing raw ingredients. The customer never walks into the pantry directly — every request passes through the kitchen, which is what lets the pantry's organization change without the customer noticing. Each tier can scale, deploy and fail independently of the others.

text
client  --request-->  application  --query-->  database
client  <--response--  application  <--result--  database

client never talks to the database directly

What we're doing: Trace one request through all three tiers and show what would break if the client bypassed the application tier.

three-tier-architecture.txttext
Request: "Place an order for 3 units of item #42."

  Client:      sends the order request to the API
  Application: checks stock, applies a bulk discount
               rule, validates the order, THEN writes
  Database:    stores the order row, decrements stock

If the client wrote to the database directly, the
discount rule and stock check would never run —
the application tier is what enforces them.
3
The client only ever talks to the application tier — it has no direct database access.
4
Business rules (the discount, the validation) live in the application tier, not the client or the database.
9
Bypassing the application tier would also bypass every rule it enforces — this is exactly why the tiers are kept separate.

Why this works: Separating tiers keeps business logic in one place, lets the database evolve (schema changes, a new storage engine) without touching client code, and lets each tier scale independently — more application servers for compute-heavy logic, a bigger database for storage-heavy load.

Letting the client query the database directly, skipping the application tier

Wrong

text
// client-side code
const orders = await db.query(
  'SELECT * FROM orders WHERE user_id = ?', [userId]
);

Better

text
// client-side code
const orders = await fetch('/api/orders').then(r => r.json());
// application tier owns the query, auth check,
// and any business rules around what's returned

What you see: Business rules meant to apply to every order (auth checks, filtering, pricing logic) get silently skipped whenever a client queries storage directly, and the database schema can no longer change without breaking every client that queries it.

Why: A client with direct database access bypasses every rule the application tier is meant to enforce, and couples every client permanently to the current database schema — exactly the coupling the three-tier split exists to prevent.

Three-tier architecture
requestqueryresultresponse

Client

browser, mobile app

Application

business logic, validation

Database

persistent storage

  • Client — browser, mobile app
    • leads to Application (request)
  • Application — business logic, validation
    • leads to Database (query)
    • leads to Client (response)
  • Database — persistent storage
    • leads to Application (result)

The three tiers

The three tiers
TierResponsibilityExample
Clientpresentation, user interactiona browser, a mobile app
Applicationbusiness logic, validation, orchestrationan API server
Databasepersistent storagePostgreSQL, MySQL

Together

text
An online store:

  Client:      React app in the browser
  Application: Node.js API — validates orders,
               applies pricing rules
  Database:    PostgreSQL — stores products, orders

Remember: Client (presentation) → application (business logic) → database (storage) — each tier only talks to its immediate neighbor.

See also: layered architecture · pattern tradeoffs

Layered architecture

standardbeginner

Layered architecture is a finer split within a single deployable: presentation (handling requests), application/service (orchestration), domain (core business rules), data access (querying), and storage. Each layer only calls the layer directly below it.

Think of it as

Three-tier architecture describes separate deployables talking over a network; layered architecture describes the internal structure inside one of those deployables — the application tier itself. It is an organizational discipline: presentation code never touches storage directly, domain logic never depends on how data is stored, and each layer can be tested or replaced by mocking the layer below it.

text
presentation -> application/service -> domain
                                            |
                              data access <-+
                                   |
                               storage

each layer only calls the layer directly below it

What we're doing: Show one request flowing through all five layers, and why domain logic is kept isolated from storage details.

layered-architecture.txttext
POST /orders { itemId: 42, quantity: 3 }

  Presentation:  parses JSON, calls the service layer
  Application:   PlaceOrderService coordinates the steps
  Domain:        Order.validate() rejects quantity <= 0,
                 applies the bulk-discount rule — no
                 knowledge of SQL or HTTP at all
  Data access:   OrderRepository.save(order) translates
                 the Order object into a SQL INSERT
  Storage:       the row lands in the orders table

Swapping PostgreSQL for MongoDB only touches the
data access layer — domain logic is untouched.
6
The domain layer enforces business rules with no idea what database or protocol is involved — that isolation is the whole point.
9
Data access is the only layer that knows about SQL — it is the translation boundary between domain objects and storage.
12
Because storage details are confined to one layer, changing the storage technology does not require touching business logic.

Why this works: Isolating domain logic from storage and transport details means business rules can be tested without a database or an HTTP server, and the storage technology can change without rewriting business logic.

Putting SQL queries directly inside domain logic

Wrong

text
class Order {
  validate() {
    const db = require('./db');
    const stock = db.query('SELECT qty FROM stock ...');
    // domain logic now depends on SQL and a live connection
  }
}

Better

text
class Order {
  validate(availableStock) {
    // pure business rule, no I/O, easy to test
    return this.quantity <= availableStock;
  }
}
// data access layer fetches availableStock and passes it in

What you see: Testing a business rule requires a live database connection, and switching databases means rewriting core business logic scattered across files that were never meant to know about SQL.

Why: Domain logic mixed with storage code loses the isolation layering is meant to provide — it can no longer be tested without a database, and a storage change ripples into business rules that have nothing to do with storage.

The five layers, top to bottom

Presentation

parses requests, formats responses

Application/service

orchestrates a use case

Domain

core business rules, no I/O

Data access

translates domain objects to/from storage

Storage

the actual persistence mechanism

  1. Presentation — parses requests, formats responses
  2. Application/service — orchestrates a use case
  3. Domain — core business rules, no I/O
  4. Data access — translates domain objects to/from storage
  5. Storage — the actual persistence mechanism

The five layers

The five layers
LayerResponsibilityExample
Presentationparses requests, formats responsesan HTTP controller/handler
Application/serviceorchestrates a use casePlaceOrderService.execute()
Domaincore business rules, independent of I/Oan Order entity's validation rules
Data accesstranslates domain objects to/from storagean OrderRepository
Storagethe actual persistence mechanismPostgreSQL, a table

Together

text
Placing an order, layer by layer:

  Presentation:  parses the HTTP POST body
  Application:   PlaceOrderService.execute(orderData)
  Domain:        Order.validate() checks business rules
  Data access:   OrderRepository.save(order)
  Storage:       INSERT INTO orders ...

Remember: Presentation → application/service → domain → data access → storage — each layer calls only the layer directly below it; domain logic stays free of I/O.

See also: three tier architecture · modular monolith pattern

Modular monolith

coreintermediate

A modular monolith is one deployable system, but internally split into modules with strong, enforced boundaries — each module owns its own data and exposes a defined interface, even though everything ships and runs as a single unit.

Think of it as

Picture an apartment building versus a house with no interior walls. A plain monolith is the house with no walls — any room can be reached from any other, and nothing stops code in one area from reaching into another's data. A modular monolith is the apartment building: separate units with locked doors between them (enforced module boundaries), but sharing one building, one address, one deploy. Microservices would be separate houses on separate lots — full isolation, but now you need roads (networking) between them.

text
one process, one deploy
  module A --(interface call)--> module B
  module A: owns its own data, no cross-module table access

if module A needs module B's data, it calls
module B's interface — never queries module B's
table directly

What we're doing: Show a modular monolith enforcing a module boundary, and what breaks when that boundary is skipped.

modular-monolith-pattern.txttext
Orders module needs to check stock before
confirming an order.

Correct (through the interface):
  Orders -> Inventory.checkStock(itemId, qty)
  Inventory module owns the query, the table,
  and the business rule for "in stock"

Boundary violation (bypassing the interface):
  Orders -> direct SQL: "SELECT qty FROM inventory ..."
  Now Orders depends on Inventory's table schema
  directly — any change to that schema silently
  breaks Orders too.
5
The correct call goes through Inventory's own interface — Orders never needs to know how stock is stored.
10
Querying Inventory's table directly from Orders is exactly the boundary violation that turns a modular monolith back into a plain, tangled one.

Why this works: The whole value of a modular monolith is that module boundaries are real and enforced, even without a network between them — that discipline is what keeps the codebase splittable into real services later, if that ever becomes necessary.

Calling it "modular" while modules still share tables freely

Wrong

text
// "Orders module" queries the shared inventory
// table directly, no interface in between
const stock = await db.query(
  'SELECT qty FROM inventory WHERE item_id = ?', [id]
);

Better

text
// Orders module calls Inventory's own interface
const stock = await InventoryModule.checkStock(id);
// Inventory owns the table; Orders never touches it directly

What you see: The codebase is organized into folders labeled by module, but any module can still query any other module's tables directly — the boundaries are cosmetic, and a schema change in one module silently breaks code in another.

Why: A modular monolith without enforced data ownership is just a plain monolith with better folder names — the property that actually matters (module A cannot reach into module B's data) is never enforced, so the coupling a modular monolith is meant to prevent happens anyway.

One deployable, three enforced module boundaries

Orders module

Orders logic

orders table

Inventory module

Inventory logic

inventory table

Billing module

Billing logic

billing table

  • Orders module — owns the orders table
    • Orders logic
    • orders table
  • Inventory module — owns the inventory table
    • Inventory logic
    • inventory table
  • Billing module — owns the billing table
    • Billing logic
    • billing table

Modular monolith vs a plain monolith

Modular monolith vs a plain monolith
AspectPlain monolithModular monolith
Deploymentsingle deployablesingle deployable (same)
Internal boundariesnone enforced — any code can call any otherenforced module boundaries with defined interfaces
Data ownershipshared tables, any module can query any tableeach module owns its own data
Refactor cost to microserviceshigh — boundaries have to be discovered firstlower — boundaries already exist, just need a network hop added

Together

text
An e-commerce modular monolith, one deployable,
three internal modules:

  Orders module    -> owns the orders table
  Inventory module -> owns the inventory table
  Billing module    -> owns the billing table

Orders calls Inventory.checkStock(itemId) —
an in-process function call through a defined
interface, not a direct query against the
inventory table.

Remember: One deployable, strong internal module boundaries, each module owns its own data — communication is in-process, not networked.

See also: microservices pattern · modular monolith advantages

Microservices

coreintermediate

Microservices split a system into multiple independently deployable services, each with its own codebase, data, and deploy pipeline, communicating over explicit network calls rather than in-process function calls.

Think of it as

Where a modular monolith is an apartment building with locked doors between units but one shared address, microservices are separate houses on separate lots — each fully independent, but now requiring roads (the network) to reach each other. Each service can be built with a different language, scaled independently, and deployed on its own schedule — but every one of those benefits comes at the cost of the network calls, serialization, and partial-failure handling that in-process calls never needed.

text
service A --(network call: HTTP/gRPC/queue)--> service B

each service: own codebase, own database, own deploy

a network call can fail in ways a function call cannot:
  timeout, partial response, service unavailable

What we're doing: Show the same operation as a network call between microservices, and the failure handling it now needs that an in-process call never did.

microservices-pattern.txttext
Orders service confirming an order needs
Inventory's stock count.

  Orders -> HTTP POST /inventory/check-stock
  Inventory -> queries its own database, responds

Now handle what a modular monolith's in-process
call never had to:
  - Inventory service is down: Orders needs a
    timeout and a retry or fallback policy
  - the network is slow: Orders needs to decide
    how long to wait before giving up
  - Inventory responds twice due to a retry:
    Orders needs the call to be safe to repeat
    (idempotent)
4
The call that used to be a function call is now a network request — with everything a network request can go wrong.
9
A downed dependency now requires an explicit policy (timeout, retry, fallback) — an in-process call could never partially fail this way.
13
Idempotency becomes a real design requirement once retries are possible — a function call is never accidentally invoked twice by a network layer.

Why this works: Microservices trade the simplicity of in-process calls for independent scaling and deployment — but every one of those network calls now needs explicit handling for timeouts, retries, and partial failure that a modular monolith's function calls got for free.

Adopting microservices for independent deployment without handling network failure modes

Wrong

text
// Orders service calls Inventory with no
// timeout, retry, or fallback
const stock = await fetch('http://inventory-svc/check-stock');

Better

text
const stock = await fetchWithTimeout(
  'http://inventory-svc/check-stock',
  { timeoutMs: 500, retries: 2, fallback: assumeOutOfStock },
);

What you see: Inventory service has a brief slowdown, and every Orders request hangs waiting on a network call with no timeout — one struggling service takes down a service that was otherwise healthy.

Why: A network call with no timeout or fallback policy makes the calling service only as reliable as the one it depends on, network included — exactly the fragility microservices are often mistakenly assumed to avoid just by being "independent".

Independently deployable services, each with its own data
HTTP/gRPCHTTP/gRPC

Orders service

own deploy + DB

Orders DB

Inventory service

own deploy + DB

Inventory DB

Billing service

own deploy + DB

Billing DB

  • Orders service — own deploy + DB
    • leads to Orders DB
    • leads to Inventory service (HTTP/gRPC)
    • leads to Billing service (HTTP/gRPC)
  • Orders DB
  • Inventory service — own deploy + DB
    • leads to Inventory DB
  • Inventory DB
  • Billing service — own deploy + DB
    • leads to Billing DB
  • Billing DB

Microservices vs a modular monolith

Microservices vs a modular monolith
AspectModular monolithMicroservices
Deploymentsingle deployablemany independently deployable services
Communicationin-process function callsnetwork calls (HTTP, gRPC, messaging)
Scalingthe whole app scales togethereach service scales independently
Failure modea bug can crash the whole processone service failing does not crash the others directly, but the network call to it can fail

Together

text
An e-commerce system as microservices:

  Orders service    -> own database, own deploy, own scaling
  Inventory service -> own database, own deploy, own scaling
  Billing service    -> own database, own deploy, own scaling

Orders calls Inventory over HTTP:
  POST http://inventory-svc/check-stock
  (a network call — can time out, fail, or be slow,
  none of which an in-process call risks)

Remember: Multiple independently deployable services, each owning its own data, communicating over the network — every call needs a timeout, retry, and idempotency plan a function call never did.

See also: modular monolith pattern · microservices costs

Event-driven architecture

standardintermediate

Event-driven architecture decouples components through a message broker: producers publish events without knowing who consumes them, and consumers process events without the producer waiting for a response.

Think of it as

Think of a bulletin board versus a phone call. A direct call (like a synchronous API request) requires both sides available at the same time, and the caller waits for an answer. A bulletin board (the broker) lets a producer post a notice and walk away — any number of interested consumers read it whenever they check the board, and the poster never waits for a reply. This is what lets producers and consumers evolve, scale, and fail independently — a new consumer can start reading tomorrow's board without any change to who posts to it.

text
producer --publish(event)--> broker --route--> consumer(s)

producer does not know who (or how many) consumers exist
producer does not wait for a consumer to finish

What we're doing: Contrast event-driven with a direct synchronous call to show the decoupling event-driven architecture buys.

event-driven-architecture.txttext
Direct call (tightly coupled):
  OrderService directly calls EmailService,
  InventoryService, AnalyticsService, one by one,
  and waits for all three to respond before
  returning. Adding a 4th consumer means changing
  OrderService's code.

Event-driven (decoupled):
  OrderService publishes "order.placed" once,
  and returns immediately. EmailService,
  InventoryService and AnalyticsService each
  subscribe independently. Adding a 4th consumer
  means only that new consumer subscribes —
  OrderService's code never changes.
3
A direct call means OrderService must know about, call, and wait on every consumer — adding a consumer means changing OrderService.
9
The event-driven version lets OrderService stay unaware of who is listening — new consumers subscribe without OrderService changing at all.

Why this works: Decoupling producers from consumers is what lets a system add new functionality (a new consumer) without touching the code of the component that produces the event — a direct call requires the opposite: the caller must know about every callee.

Treating an event broker as guaranteeing synchronous, ordered, exactly-once delivery

Wrong

text
// assumes the consumer processes this
// instantly, in order, exactly once
publishEvent('order.placed', order);
// code immediately assumes inventory is
// already decremented

Better

text
publishEvent('order.placed', order);
// consumer processing is async — do not assume
// completion order; design consumers to be
// idempotent in case of redelivery

What you see: Code written assuming an event is processed instantly and exactly once breaks under real broker behavior — most brokers can redeliver a message, deliver out of order, or take time to process a backlog.

Why: A broker decouples timing as well as identity — a producer publishing an event has no guarantee about when, in what order, or how many times a consumer processes it, unless the specific broker and configuration explicitly provide that guarantee.

One event, fanned out to independent consumers
order.placed

OrderService

publishes, returns immediately

Broker

Kafka, RabbitMQ, SQS

EmailService

sends confirmation

InventoryService

decrements stock

AnalyticsService

logs the sale

  • OrderService — publishes, returns immediately
    • leads to Broker (order.placed)
  • Broker — Kafka, RabbitMQ, SQS
    • leads to EmailService
    • leads to InventoryService
    • leads to AnalyticsService
  • EmailService — sends confirmation
  • InventoryService — decrements stock
  • AnalyticsService — logs the sale

Event-driven architecture: the three roles

Event-driven architecture: the three roles
RoleResponsibilityExample
Producerpublishes an event, does not wait for a responseOrderService publishes "order.placed"
Brokerreceives, stores and routes events to consumersKafka, RabbitMQ, SQS
Consumersubscribes to events and processes them asynchronouslyEmailService, InventoryService both read "order.placed"

Together

text
An order is placed:

  Producer: OrderService publishes "order.placed"
            to the broker, then returns immediately

  Broker:   holds the event, routes it to every
            subscribed consumer

  Consumers: EmailService sends a confirmation
             InventoryService decrements stock
             AnalyticsService logs the sale
  (three consumers, one event, none of them known
  to OrderService when it published)

Remember: Producers publish events to a broker without knowing or waiting on consumers — new consumers subscribe independently, with no change to the producer.

See also: pipeline architecture · pattern tradeoffs

Pipeline architecture

standardintermediate

Pipeline architecture moves data through a sequence of stages — ingestion (bringing data in), processing (transforming it), and storage/output (where the result lands) — each stage feeding the next, common in data and batch-processing systems.

Think of it as

Think of a factory assembly line: raw material enters at ingestion, each station along the line transforms it a bit further (processing), and the finished product exits at storage/output. Each stage does one job and passes its output to the next stage's input — a stage can be scaled, replaced, or have its logic changed without the other stages knowing, as long as the interface between them (the data format) stays the same.

text
ingestion --> processing --> storage/output

each stage: single responsibility, feeds the next
stage's input format

What we're doing: Trace raw data through all three pipeline stages and show why stages are kept separate.

pipeline-architecture.txttext
Raw log line arrives:
  203.0.113.5 - - [21/Aug/2026] "GET /product/42" 200

  Ingestion:  the log-shipping agent reads the
              line and puts it on a queue
  Processing: parse fields (IP, path, status),
              drop bot traffic by IP reputation,
              aggregate into "views per product
              per minute"
  Storage:    aggregated counts land in the
              warehouse table product_views_by_minute

A dashboard queries the storage stage only — it
never touches raw log lines or the processing logic.
4
Ingestion's only job is getting raw data into the pipeline — no transformation happens yet.
6
All transformation logic lives in the processing stage, isolated from both how data arrived and where it ends up.
10
Downstream consumers (the dashboard) depend only on the storage stage's output shape — changes to ingestion or processing internals do not affect them.

Why this works: Splitting a data flow into ingestion, processing and storage stages lets each one change independently — a new data source only touches ingestion, a new transformation only touches processing, and a new consumer only reads from storage.

Mixing ingestion and processing logic into one untestable stage

Wrong

text
// one function reads from Kafka AND parses
// AND filters AND aggregates AND writes to
// the warehouse, all inline
function handleMessage(raw) { /* everything */ }

Better

text
const parsed = parse(raw);           // processing
const clean = filterBots(parsed);    // processing
const agg = aggregate(clean);        // processing
await writeToWarehouse(agg);         // storage
// ingestion (reading from Kafka) stays separate too

What you see: Testing the aggregation logic requires a live Kafka connection and a live warehouse, because ingestion, processing and storage are all tangled into one function with no boundary between them.

Why: Collapsing pipeline stages into one block loses the independent testability and replaceability that keeping them separate provides — a processing bug now requires standing up the entire pipeline to reproduce, instead of testing the processing function in isolation.

Clickstream pipeline, stage by stage
raw eventsper-minutecounts

Ingestion

read raw click events from Kafka

Processing

parse, filter bots, aggregate

Storage/output

aggregated counts in a warehouse

  • Ingestion — read raw click events from Kafka
    • leads to Processing (raw events)
  • Processing — parse, filter bots, aggregate
    • leads to Storage/output (per-minute counts)
  • Storage/output — aggregated counts in a warehouse

The three pipeline stages

The three pipeline stages
StageResponsibilityExample
Ingestionbrings raw data into the pipelinereading log files, an API webhook, a Kafka topic
Processingtransforms, filters, aggregates, or enriches the dataparsing, deduplication, computing aggregates
Storage/outputwhere the processed result landsa data warehouse, a dashboard, a downstream API

Together

text
A clickstream analytics pipeline:

  Ingestion:  raw click events read from Kafka
  Processing: parse JSON, filter bot traffic,
              aggregate into per-minute counts
  Storage:    aggregated counts written to a
              data warehouse for dashboards

Remember: Ingestion (bring data in) → processing (transform it) → storage/output (where it lands) — each stage has one job and feeds the next.

See also: event driven architecture · pattern tradeoffs

Advertisement

Choosing between them

The discipline that matters more than memorizing any one pattern: naming its trade-off before adopting it.

Understand pattern trade-offs, not universal superiority

coreintermediate

None of the six patterns in this section is universally best. Each trades something for something else — event-driven buys decoupling at the cost of ordering guarantees, microservices buy independent scaling at the cost of network complexity. Choosing one means choosing its trade-off, not just its benefit.

Think of it as

Every architecture pattern is a answer to a specific set of forces — team size, coupling tolerance, consistency needs, operational maturity. A pattern that is the right call at one company's scale (microservices at a 200-engineer org with independent teams) is often the wrong call at another's (microservices at a 3-person startup, where the network complexity outweighs any scaling benefit nobody needs yet). Naming the trade-off explicitly — "we are choosing X, which costs us Y" — is what separates a deliberate architecture decision from cargo-culting whatever pattern is currently popular.

text
no pattern is free — each one:
  BUYS  a specific property (decoupling, scaling, simplicity)
  COSTS a specific price (network calls, ordering, shared deploys)

choosing a pattern = choosing which cost you can afford

What we're doing: Walk through choosing between two patterns for the same problem, naming the trade-off explicitly rather than picking the "better" one in the abstract.

pattern-tradeoffs.txttext
Problem: a checkout flow needs to notify
shipping, billing, and loyalty-points systems
after an order is placed.

Option A - direct synchronous calls:
  Buys: immediate confirmation all three succeeded
  Costs: checkout is only as fast/available as
         the slowest of the three; adding a 4th
         system means changing checkout's code

Option B - event-driven (publish "order.placed"):
  Buys: checkout returns fast; new consumers
        subscribe without checkout changing
  Costs: no immediate confirmation shipping/
         billing/loyalty actually succeeded;
         needs a plan for a consumer that fails

Decision: event-driven, because checkout latency
matters more here than immediate cross-system
confirmation — and consumer failures are handled
with retries and a dead-letter queue.
5
Option A is named honestly with both what it buys and what it costs — not just its upside.
10
Option B is named the same way — its upside (fast checkout) is paired with its real cost (no immediate confirmation).
17
The decision states which cost the team is choosing to accept and why — this is what makes it a reasoned trade-off instead of a default.

Why this works: A pattern chosen for its benefits alone, without naming its cost, tends to surprise a team later when that cost shows up as an incident or a hard-to-debug failure mode nobody planned for.

Choosing microservices because it is "the modern, scalable choice"

Wrong

text
"We should use microservices — it's what
scales, and it's the industry standard now."

Better

text
"We have 3 engineers and no scaling problem
yet. Microservices would cost us network
complexity and multiple deploy pipelines for a
benefit we don't need — a modular monolith fits
better today, and can split into services later
if a specific module actually needs to scale
independently."

What you see: A small team adopts microservices, then spends most of its engineering time on deployment pipelines, service discovery, and debugging network failures — problems a monolith at their scale would never have had.

Why: Reputation ("industry standard") is not a trade-off analysis — it names a benefit without naming the cost, and the cost (operational complexity, network failure modes) is exactly what determines whether the pattern fits a given team's actual situation.

Every pattern trades simplicity for a specific property
Three-tier / layered
simple, but scales/deploys as one unit
Modular monolith
boundary discipline, one shared deploy
Pipeline
clean stages, added latency
Event-driven
decoupled, weaker ordering
Microservices
independent scaling, network cost
  • Three-tier / layered: Simple to operate, Tightly coupled — simple, but scales/deploys as one unit
  • Modular monolith: Simple to operate, between Tightly coupled and Fully decoupled — boundary discipline, one shared deploy
  • Pipeline: between Simple to operate and Complex to operate, between Tightly coupled and Fully decoupled — clean stages, added latency
  • Event-driven: Complex to operate, Fully decoupled — decoupled, weaker ordering
  • Microservices: Complex to operate, Fully decoupled — independent scaling, network cost

What each pattern trades away

What each pattern trades away
PatternBuysCosts
Three-tier / layeredsimplicity, easy reasoningthe whole system scales and deploys as one unit
Modular monolithboundary discipline, no network costone shared deploy and failure domain across all modules
Microservicesindependent scaling and deploymentnetwork calls, partial failure, operational overhead
Event-drivendecoupled producers and consumersweaker ordering and consistency guarantees
Pipelineclean stage separation, replaceable stagesadded latency between ingestion and final output

Together

text
A 4-person startup building an MVP:
  microservices would cost: network complexity,
  multiple deploy pipelines, distributed debugging
  -> for a team with no scaling problem yet, this
     cost is paid for a benefit not needed

  Better fit: a modular monolith. Boundary
  discipline for a codebase that will grow,
  without the network cost the team can't
  afford to operate yet.

Remember: Every pattern buys a specific property and costs a specific price — name both before choosing, and match the choice to the actual constraints, not to reputation.

See also: microservices pattern · when not to split

Advertisement