lumo

Module 01 · Lesson 8 of 14Free

Design tradeoffs: making and explaining decisions

Level
Intermediate
Topic
System design fundamentals
Reading time
18 min

When you finish this page you can

  1. Compare designs against explicit constraints instead of general advantages.
  2. Explain a decision through its mechanism, benefits, costs and failure behavior.
  3. Identify the assumptions and measurements that would change a decision.
  4. Record a decision so another engineer can understand and revisit it.

Builds on 3 lessons

Start with lesson 515 min
  1. Functional and non-functional requirements15 min
  2. Capacity estimation: users, storage and traffic14 min
  3. Core system properties: availability, durability, consistency18 min

Video lesson · coming soon

The festival sale begins on . Traffic reaches 1,000 requests a second, and product descriptions stop loading. One engineer proposes saving recent results in memory; another wants a larger database.

Both changes could help. How do you choose a design when improving one property creates a cost somewhere else?

For this lesson’s scenario, a test database handles 600 description reads a second within the agreed target. Checkout work already has its own reserved allowance. These are illustrative test results, not PostgreSQL limits.

Check yourselfAnswer in your head first, then reveal
One design reads every description from the database. Another reuses recent descriptions and sends fewer reads. Is the second design necessarily better?Show answerHide answer
No. It might show older descriptions or overload the database when its saved results disappear. Fewer reads are useful only if the design still meets the product’s other requirements.

Diagram not drawn yet: tradeoffs-constraints-before-ranking.svg

Three candidate designs enter a constraint check: direct database reads, local saved descriptions and a shared cache. Designs that fail a required property leave through a rejected path. Feasible designs continue to a comparison of database load, operating effort and failure behavior.

First remove designs that break a required property. Then compare the costs of the remaining designs.

A benefit needs a constraint

A is a choice that improves something you value at the expense of something else you value. Here, fewer database reads may cost freshness, memory and recovery work.

But not every improvement requires accepting a loss. Suppose a query accidentally reads unused columns. Removing them might reduce work without changing the result. Investigate such improvements before accepting a harder compromise.

Start with the product questions from Functional and non-functional requirements. For this scenario, the team agrees:

Product question Scenario answer Effect on the decision
Can a description be reused? Yes, for up to 30 seconds after its read completes Recent descriptions may be saved
Can checkout trust that description copy for prices? No Prices stay outside this change
Can browsing consume checkout’s reserved database capacity? No Description reads need a limit
Must every description load during overload? No; show a retry message if necessary Rejecting some work is allowed
Must this serve several app servers now? No; Kartly still has one A shared service is not yet required

The distinctions between freshness, and come from Core system properties. Here, your task is to decide which property matters for this particular operation.

A description that briefly shows an older material specification is different from charging an older price. The team must approve each behavior separately. “Some stale data is acceptable” is too broad to guide an implementation.

Google’s SRE guidance makes a related point: reliability investment must reflect business needs and the costs of achieving it. The target is not maximum reliability at any price.1

These constraints narrow the choices. They do not yet tell you which feasible design costs least.

Compare mechanisms, not product names

The simplest implementation reads a description from PostgreSQL for every request. Each result comes from that request’s database read. There is no second copy for the app to manage.

Under our assumed workload, each product-page request needs one description read. The database therefore receives the same rate as incoming product-page requests.

One alternative is a : a bounded collection of recent description results in the app server’s memory. Another is a : a separate service holding those results.

Both can avoid repeated reads, but their operational consequences differ. Local memory disappears with the app. A shared adds another service to operate. AWS describes both benefits and limitations of these approaches, including empty caches after app starts.2

Where should Kartly get product descriptions during this sale?

Keep direct database reads without limiting demand.

The app sends every description request to PostgreSQL. This keeps one read path, but the scenario’s peak exceeds the tested description-read allowance.

Add a shared cache service.

The app asks a separate cache service before PostgreSQL. Several app servers could share results, but Kartly must operate and recover another dependency.

Why this choice

The local cache addresses repeated reads without adding a service. Kartly has one app server, so sharing results between app servers solves no current requirement.

The choice depends on repetition. If nearly every request asks for a different description, saving results will avoid few reads. A representative test must measure how often saved results can actually be reused.

Direct reads become preferable if query improvements or additional database capacity meet the target at a lower total cost. A shared cache becomes worth reconsidering when several app servers repeatedly fetch the same descriptions.

The local cache is the smallest promising change, not a complete overload solution. Its empty state still needs a safe design.

The app owns the local cache. Each entry stores a product ID, description text and an expiration time. On a usable entry, the app returns the text. Otherwise, it reads PostgreSQL and saves the completed result.

The app checks expiration on every lookup and caps total memory use. Under this scenario’s policy, an entry stops being reusable after 30 seconds. That is a reuse rule, not proof that the text reflects every seller update within that interval.

We leave implementation details to Caching patterns. The important decision here is accepting a second copy of descriptions to reduce repeated database work.

Now you need evidence that this cost buys enough capacity.

Calculate the margin you need

Use the workload estimates from Capacity estimation, then expose the assumptions that control the result.

Assume a representative sale test finds that 90% of description requests reuse local entries. A means a request uses a saved result without reading PostgreSQL.

Estimate description demand

1,000 page requests/s × 1 description read/request = 1,000 reads/s

Without reuse, demand exceeds the scenario’s tested allowance.

Estimate database reads with reuse

1,000 requests/s × (1 − 0.90) = 100 reads/s

Under this assumption, the cache leaves substantial room below the allowance of 600 reads a second.

Find the minimum hit rate

1,000 × (1 − hit rate) ≤ 600 → hit rate ≥ 40%

This is only the arithmetic boundary. Running exactly at the tested allowance leaves no margin for variation.

Change the uncertain assumption

1,000 requests/s × (1 − 0.20) = 800 reads/s

At a 20% hit rate, the same design exceeds the allowance. The component has not changed; its workload has.

These calculations assume one database read per miss. They exclude retries and extra database operations. Record those assumptions beside the estimate.

Notice what the calculation does not establish. It does not prove a page latency target or predict every short burst. It only estimates description-read demand for a given hit rate.

Avoid collapsing this into “the cache makes the system ten times faster.” In the assumed warm workload, it cuts description reads by a factor of ten. Other request work still exists.

You can compare plausible conditions without inventing a precise forecast:

Condition Assumed hit rate Description reads/s Consequence
Repeated popular products 90% 100 Fits with margin
More varied browsing 50% 500 Fits with little margin
Mostly new products 20% 800 Exceeds allowance
Empty cache 0% 1,000 Exceeds allowance

A good decision should survive the plausible range, or state how it behaves outside that range. The empty-cache row is therefore part of the decision, not an exception to discuss later.

Failure changes the preferred option

Suppose the app restarts during the sale. Its local entries disappear. Every request initially needs PostgreSQL.

AWS warns about this transition: a system that works with a warm cache can overwhelm its dependencies when cached results disappear. Testing without cached results exposes that dependency.2

In Kartly’s hypothetical restart, shoppers see descriptions stall. Some retry. If the app accepts all that work, it has already broken the requirement to stay within browsing’s database allowance.

The tempting response is to wait until database capacity becomes free. Waiting is useful for short disturbances. Sustained excess demand is different.

Calculate excess demand

1,000 requested reads/s − 600 admitted reads/s = 400 waiting reads/s

Assume a controlled drain of 600 reads a second, with no retries or abandoned work.

Keep the cache empty for this failure exercise

400 waiting reads/s × 10 s = 4,000 waiting reads

Imagine result reuse is unavailable throughout this interval. Waiting does not remove the work; it moves the cost into delay and memory.

A stores work until it can proceed. In this model, any finite queue eventually fills if arrivals keep exceeding departures. A larger queue only postpones that moment.

What should happen when description misses exceed their database allowance?

Keep an unlimited waiting queue.

Every request remains pending until capacity becomes free. Brief bursts may succeed, but sustained excess demand creates increasing delay and retained work.

Serve expired descriptions without a limit.

The app continues returning descriptions beyond the agreed reuse period. More shoppers get text, but the system violates the current product policy.

Why this choice

The product allows description failures but protects checkout capacity. Bounded admission matches that priority. An unlimited queue hides the rejection decision inside long waits.

The app needs both a request-rate limit and an in-flight limit for description database calls. The rate limit controls how quickly work starts. The in-flight limit bounds unfinished calls when the database slows.

Serving older descriptions could be a better emergency policy if the product team explicitly approves an age limit and user-visible behavior. It is not an engineering-only substitution for the current requirement.

If every description must succeed, this choice no longer meets the requirements. The team must provide capacity or reduce demand through another approved design.

Diagram not drawn yet: tradeoffs-warm-and-empty-envelope.svg

Two workload panels compare a warm local cache and an empty cache. The warm case receives 1,000 description requests a second, serves 900 locally and sends 100 database reads. The empty case needs 1,000 database reads; admission allows at most 600 starts a second and rejects excess requests without usable entries.

The same design needs two explanations: what it gains during normal operation and what it protects when reuse disappears.

For this scenario, make the admission limiter app-local because there is one app server. Reject excess requests immediately rather than adding a waiting queue. Choose the in-flight bound from representative database tests.

A shopper then sees a clear “Description temporarily unavailable” message instead of an indefinite spinner. Monitor rejected requests, active database calls and database latency. Recover by admitting new work as capacity becomes free; do not retain rejected work for later replay.

This protects a resource boundary, not a guarantee that checkout always succeeds. Shared hardware and other database work still require validation.

The decision is now explicit about who pays: some browsing requests fail so checkout retains its reserved capacity.

Make uncertainty visible in the record

An , or ADR, preserves the context, alternatives and consequences of an important choice. Microsoft recommends recording confidence and superseding an accepted record rather than rewriting its history.3

For Kartly, a useful record could fit on one screen:

Field Example record
Decision Reuse descriptions in bounded app memory
Required behavior Exclude prices; reuse completed reads for at most 30 seconds
Evidence Scenario test allowance: 600 description reads/s
Assumption Representative warm-cache hit rate: 90%
Accepted cost Older descriptions within policy; some browsing failures
Failure behavior Limit database call starts and unfinished calls; reject excess work
Rejected alternative Shared cache adds a dependency without a current sharing requirement
Confidence Moderate; real sale traffic may differ from the test
Revisit when Hit rate remains below 50% during the sale, or app-server count changes
Owner Catalog team

The 50% trigger is a scenario policy, not a mathematical limit. It starts a review before reaching the calculated boundary. It does not automatically justify a shared cache.

A low hit rate might mean shoppers request more distinct products. A larger cache will not necessarily help. First determine whether entries disappear too early, memory fills, or results simply do not repeat.

The record should also name evidence that would overturn the decision. For example: an improved direct query meets peak demand, or product policy no longer permits reused descriptions.

Keeping accepted records and linking replacement decisions lets future engineers distinguish a mistaken assumption from a changed requirement.3

Your explanation should therefore end with a condition, not a verdict: “We choose this because these constraints hold; we will reconsider when these measurements change.”

Common mistakes

Treating requirements as weighted preferences. A high performance score cannot compensate for using an unapproved price. Remove infeasible options before ranking them.

Calling the newest design the best. A shared cache may solve a future coordination problem. That does not establish a benefit for today’s single app server.

Counting only infrastructure cost. Include maintenance, recovery work and engineering time. Reliability improvements consume both machines and opportunities to build other features.1

Describing only normal operation. “Most requests hit memory” does not explain a restart. State what happens when the assumed benefit disappears.

Changing the priority without saying so. Serving expired descriptions trades freshness for availability. It requires a product decision, not merely a fallback branch.

Where this shows up

Kartly’s next question is whether to add capacity to its current machine or distribute work across machines, explored in Vertical and horizontal scaling.

Check your understanding

Check yourselfAnswer in your head first, then reveal
The warm cache avoids 90% of reads, but a restart overloads PostgreSQL. Has the design met the capacity requirement?Show answerHide answer
Not across its required operating conditions. The warm result shows a benefit, but the empty-cache path still needs bounded demand or enough database capacity.
Check yourselfAnswer in your head first, then reveal
Traffic reaches 2,000 description requests a second. At a 50% hit rate, does the design fit an allowance of 600 database reads a second?Show answerHide answer
No. Half the requests still need the database, producing 1,000 reads a second. The previous hit-rate threshold depended on the previous traffic assumption.
Check yourselfAnswer in your head first, then reveal
The team proposes a much larger waiting queue instead of rejecting description requests. What changes under sustained excess demand?Show answerHide answer
Rejection happens later, after more waiting and memory use. Not the service capacity: enlarging the queue does not increase how quickly the database completes work.
Check yourselfAnswer in your head first, then reveal
Kartly adds app servers. Should the team immediately replace local caches with a shared cache?Show answerHide answer
No. Reconsider the decision using repeated reads across servers, measured database demand and the operating cost of another dependency. More servers change the assumptions, not automatically the answer.
Check yourselfAnswer in your head first, then reveal
A later query improvement lets direct reads meet the peak target. What should happen to the original decision record?Show answerHide answer
Write a replacement decision with the new evidence and link it to the original. Preserve the original reasoning so readers can see why the earlier choice made sense.

Summary

  1. Choose a design by filtering against required behavior, then comparing the costs of feasible options.
  2. Explain the mechanism behind each benefit and name the cost someone accepts.
  3. Calculate outcomes across plausible workloads, including the conditions where an optimization disappears.
  4. Describe failure behavior explicitly; waiting, rejection and older data impose different costs.
  5. Record assumptions, confidence and revisit conditions so a decision can change with evidence.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.