IterationGENERALSCALE-SPECIFICCONTESTEDILLUSTRATIVE

Add Complexity Only When Required

Single server and database. Repeated expensive reads → maybe a cache. Long-running work in the request → maybe a queue and a worker. Each "maybe" waits for the symptom it treats; complexity should have a reason, and the reason should be something you observed.

The situation, the reflex, and why it stalls

Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.

The question

The store runs on one server and one database, and you can already see the components it will need eventually. When does "eventually" arrive for each one, and what has to be true before you add it?

The situation

The store works. It is one process and one database, and it feels fragile — every architecture you have read about has more. You are fairly sure it will need a cache, and you are fairly sure sending the confirmation email inside the checkout request is wrong. You would like to fix both before anyone notices.

The reflex

Add them now while the system is small and the change is cheap. A cache and a queue are standard; adding them later means adding them under pressure, and adding them now means never having the outage that would have prompted it. It feels like maturity.

Why it stalls

The cache has nothing to cache. There is no read load, so there is no measured slow read, so the cache is configured by guessing what will be hot and for how long — and the first bug it produces is a stale price on a product page, a problem the store did not have yesterday.

What the reflex produces — and fails to produce
  • The cache has nothing to cache. There is no read load, so there is no measured slow read, so the cache is configured by guessing what will be hot and for how long — and the first bug it produces is a stale price on a product page, a problem the store did not have yesterday.
  • The queue has one consumer and introduces its own questions: what happens when the worker is down, what happens when the same order is processed twice, where the dead letters go. Every one of those is real and none was a requirement. The store now has failure modes that were bought, not found.
  • Neither addition can be observed as an improvement, because nothing was measured before it. The system is more complex and there is no evidence it is better; the feeling of maturity is the whole return.
  • The next engineer inherits two components with no reason attached. Removing them requires proving they are unnecessary, which is harder than proving they were never necessary would have been.
ProblemUnderstandRequirementsConstraintsUnknownsDecompositionSmallest StepModelExperimentObserveDebugLearnIterate

The move

Precisely enough to apply it to a problem you have never seen — not a slogan.

  • Start from the smallest architecture the requirements derive — for the store, one server and one database — and hold every additional component to a symptom it treats. A cache treats repeated expensive reads; a queue and a worker treat work that must not hold the request; a replica treats read load the primary cannot serve. If the symptom has not been observed, the component is waiting, not forbidden (Architecture From Requirements).
  • Make "observed" mean measured on this system. A synthetic load on staging counts; a production graph counts; an article about caches does not; a feeling that email in the request is wrong does not — although the feeling is a good reason to run the measurement that would settle it (Measure Before You Optimize).
  • Ask of every proposed component what it costs in failure modes, not only what it costs in time. A cache adds staleness and invalidation; a queue adds at-least-once delivery, retries and a worker that can be down. Those are the ledger entries, and the component is justified only when the symptom it treats is worse than the entries it adds (The Complexity Ledger).
  • Make the slogan "don't over-engineer" precise: a component is over-engineering when it treats a symptom that has not been observed on this system, and it is not over-engineering — however large — when it treats one that has. The test is the observation, not the size of the component.

How the architecture grows, one symptom at a time

The pipeline is the store's growth as a sequence of observations, not a sequence of components. Each step names the symptom that would move the system to the next one and the way the step is faked — a component added because the diagram looked incomplete. The lab at /thinking/grow runs exactly this: raise the traffic, read the bottleneck, add only what the reading justifies.

From one server to more, on evidence
  1. 1
    One server, one database

    The derived architecture. Everything in the request; email written to a pending table and sent by a job.

    fails by Treated as a placeholder for the "real" architecture instead of as the real architecture until a reading says otherwise.

  2. 2
    Symptom: reads are slow

    A measured burst names the product query. First response: an index or a query fix.

    fails by A cache added before the index, hiding a slow query behind a staleness bug.

  3. 3
    Symptom: reads are slow after the index

    The database saturates on reads of a small, rarely-changing set. Now the cache — with invalidation written down before it ships.

    fails by Caching everything, including the stock count that changes at every checkout.

  4. 4
    Symptom: work is holding the request

    Checkout waits on the sender, or on video processing. First response: pending table plus scheduled job.

    fails by A queue and worker for one consumer that a job could serve.

  5. 5
    Symptom: the job cannot keep up

    The pending table shows wait times customers do not tolerate. Now the queue and a worker pool, with retry and duplicate-processing entries in the ledger.

    fails by Adding a queue without deciding what happens when the worker processes an order twice.

  6. 6
    Symptom: one server is the limit

    CPU or memory on the single server saturates under measured load. Now a second server behind a balancer — and the session and cache decisions that come with it.

    fails by Horizontal scaling before the database, which is usually the actual limit, was measured.

Every step waits for the one before it to be observed, and every step tries the smaller change first. A system can live at step one for years; that is not stagnation, it is the absence of symptoms.

"We need a cache" on the ladder

The cache is the most common early addition, and the ladder is how to interrogate it. Notice that the ladder does not conclude "no cache"; it concludes "index first, then measure, then cache if the reading still names reads", and it names the condition under which the cache was right from the start.

"We need a cache in front of the product pages"

We need a cache in front of the product pages.

  1. Why do the product pages need a cache? Because they will be the most-read pages and the database will be slow.
  2. Why will the database be slow serving them? Because every page load runs the product query.
  3. Why is running the product query on every load a problem? We have not measured it. Under the staging burst it was slow — and it was slow because there was no index on the lookup.
  4. Why, after the index, would the query still be too slow? It might not be. If it is, it will be because the burst is large enough to saturate the database on reads even when each read is cheap.
real requirement Product pages stay responsive under the largest burst of reads the store is expected to receive.
simpler Index the lookup; re-run the burst; read the timings. Most stores stop here.

the claim was right when The burst saturates the database on reads with the query already cheap, and the pages read a small set of products that change rarely — then a cache with explicit invalidation on price and stock change is the component the reading asks for, and adding it is iteration.

What the early component actually costs

Each row is a component added without its symptom, the way it surfaced, and the response — which is usually removal, and is sometimes "keep it, the symptom has now arrived". The response column is the ledger being read backwards.

Complexity without a symptom
TriggerSymptomCauseResponse
Cache added before any read loadA customer sees yesterday's price on a product page after the admin changed it.Invalidation was never designed because no measured symptom forced the question of what to cache.Remove the cache until a reading names reads; when it does, write invalidation before the cache (Cache Invalidation, Stampedes and Hot Keys).
Queue and worker for the confirmation emailA customer receives two confirmations; another receives none because the worker was down and nobody watched the dead letters.At-least-once delivery and worker liveness were bought with the queue and never handled.A pending-email table and a scheduled job treat the symptom the queue was meant to; keep the queue only if the job cannot keep up (A Dead-Letter Queue Is a Workflow, Not a Bin).
Three services instead of oneDisplaying a product crosses two network hops; a checkout fails partway because the cart service timed out on the catalog service.A boundary was drawn where the code had no reason to be separate.Merge back into one deployable until a team or scaling symptom asks for a boundary (Modular Monolith).

How to do it

Most important first.

  • Write the current architecture in one line and, under it, every component you expect to add, each with the symptom it would treat. This is the pending list; its entries wait for symptoms.
  • For each entry, decide what measurement would show the symptom, and whether that measurement can be made now. Slow reads: a load test and a query timing. Email in the request: the checkout latency with the sender slow or down.
  • Run the cheap measurements. Some will show the symptom already exists — then the component is justified today. Most will not, and the entry stays pending with a number beside it.
  • Before adding a justified component, try the smaller change that treats the same symptom: an index before a cache; a pending-email table and a scheduled job before a queue and a worker. The smaller change often suffices (The Simplest Thing That Could Work).
  • When a component is added, record the symptom, the measurement and the ledger entries beside it, so the next engineer knows why it is there and what would make it removable (The Decision Journal).

Worked on a concrete problem

The move has to produce something. This is what it produced.

  • The cache, held to its symptom. Symptom: repeated expensive reads. Measurement: a burst of product-page reads against staging; the product query took most of the request time, and it was slow because it was unindexed. Smaller change: the index. After the index the query was cheap and the burst was served by the database alone. The cache stayed pending, with the burst size written beside it as the point at which to measure again.
  • The email, held to its symptom. Symptom: work that must not hold the checkout request. Measurement: stub the sender to take a long time; the checkout waits for it and the customer sees a spinner. That is a real symptom. Smaller change: write a pending-email row inside the checkout transaction and send it from a scheduled job. Long-running work is out of the request; the order is created whether or not the sender is up; no queue, no worker, no at-least-once semantics to reason about yet (Request or Background?).
  • The case where the component is right. The file-upload service: processing an uploaded video takes long enough that a scheduled job every minute leaves users waiting and a single process cannot keep up when uploads cluster. The symptom — a backlog measured in the pending table — is observed; the smaller change has been tried and is insufficient; a queue and a pool of workers are what the ledger now justifies, with retry and duplicate-processing entries accepted because the symptom is worse than they are (Job Queues, Worker Processes).
  • The store in month four. Order volume rose and the confirmation-email job began to lag; the pending table showed rows waiting longer than customers tolerate. Now the queue treats a symptom the table itself revealed, and the migration is a planned loop: the queue replaces the scheduled job, the table becomes the audit log, and the ledger entries are recorded on the day.

How you know it worked

What now exists that did not before, and what question you can now ask.

  • Every component beyond the derived minimum has a written symptom, the measurement that showed it, and the smaller change that was tried first.
  • The pending list has numbers beside its entries — the load at which to measure again — rather than dates.
  • The email is out of the request and there is still no queue, and you can say why both halves of that sentence are true.
  • A new engineer can read why each component exists and what observation would make it removable.

The questions you can now ask

The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.

Next questions
  • ?Which symptom would this component treat, and has that symptom been observed on this system?
  • ?What is the smaller change that treats the same symptom, and has it been tried?
  • ?What failure modes does the component add, and is the symptom worse than they are?
  • ?At what load or volume should I measure again, and who will?

What can go wrong

How the move itself fails
  • Symptoms demanded for components a requirement already asks for. A persistent connection for the chat app is a requirement, not a symptom; waiting to measure "users complain that messages do not appear" is refusing a sentence in the list.
  • The measurement made once and never repeated. The cache was unjustified at the launch burst; at ten times the traffic nobody measured again, and the symptom arrived in production. The pending list carries the point at which to re-measure, and someone has to do it.
  • The smaller change tried forever. The pending-email table grows a retry column, then a priority column, then a locking scheme, until it is a worse queue than the queue. When the smaller change starts acquiring the failure modes of the component it avoided, the component is justified.
  • Minimalism as identity. "We do not use queues" becomes a team value, and the video-processing backlog is tolerated because adding the queue would feel like losing. The rule is observation, in both directions.
What the move costs
  • Waiting for a symptom means sometimes meeting it in production. Where that is unacceptable, the measurement must be synthetic and ahead of time, and building the synthetic load is work.
  • Adding a component later, under load, is harder than adding it early. The pending list with re-measure points is how the "later" is kept from becoming "under pressure", and it only works if the measurements are actually repeated.
  • The smaller change — the table and the job — has to be replaced eventually, and the replacement is a migration that the early queue would have avoided at the cost of carrying the queue's failure modes all along.
Misreads
  • "Never add a cache or a queue." Add either when the symptom it treats has been observed and the smaller change is insufficient; the video-processing service adds a queue on its first week for exactly that reason.
  • "Small architecture is a sign of a small team or a small product." Small architecture is the sign of a system that has only the components its symptoms have asked for; a large product with large symptoms derives a large architecture the same way.
  • "Complexity should have a reason" means any reason will do. The reason must be an observation on this system or a sentence in its requirements. "We might need it" is not a reason; it is an entry on the pending list.

Where this applies

Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.

  • GENERALEvery component in any architecture can be asked which observed symptom it treats and what smaller change was tried; the answer differs by system, the question does not.
  • SCALE-SPECIFICAt the store's launch scale nothing beyond one server and one database is justified by any reading. The flip is a measured one: when a burst of reads saturates the database with the query already indexed, the cache is justified; when the pending table's wait times exceed what customers tolerate, the queue is. Where the flip happens is a number, and it belongs on the pending list.
  • CONTESTEDSome practitioners hold that the loop is naive about migration cost: that adding a queue to a system built around synchronous calls is a rewrite, that a cache added under production load is added badly, and that a team which knows the symptom is coming — from a signed contract, a launch plan or experience — should build the component early while it is cheap and the failure modes can be learned calmly. Their strongest form: a symptom that is certain to arrive is as good as one observed, and insisting on observation is deferring known work to the worst possible moment. That view is strongest where the load is contractually known and the migration genuinely is a rewrite.
  • ILLUSTRATIVEThe unindexed query, the slow email sender, the video-processing backlog and month four are invented to show the shape of the gating; the numbers and timings are for the argument only.

Where the depth lives

This domain asks the question and hands the answer off by name.

Further
  • The Technology Decision Tool at /thinking/decide asks, for Redis or Kafka, which symptom you are treating; every leaf names the simpler thing to try first.