Interviews

System design basics for SDE interviews

These notes are original practice material. amazon-oa.com is not affiliated with or endorsed by Amazon. The examples were written for this page. They are not confidential assessment content, and they are not a forecast of what any interview will ask.

A system design conversation asks whether you can shape a vague product into a design another engineer could argue with. It is less common in a first online screen than in a later loop, and it is more common for experienced candidates than for new graduates. Treat that sentence as a hedge, not a rule. If your recruiter says the loop includes design, believe the recruiter. This page is a way to practice the conversation. The example is original. It is not a prompt from an employer, and copying a famous walkthrough of a social network will not help you as much as doing one small design yourself.

The Leadership Principles show up here as ordinary engineering taste. Customer Obsession means you name who is on the other end of the API. Frugality means you do not add a queue, a cache, and three services because a diagram looked empty. Dive Deep means you can point at the number in your sketch that is a guess.

Start with the user and the scale you actually have

Spend the first few minutes on requirements, and write them down. Separate what the system must do from what would be nice. Ask who calls it, how often, and what happens when it is wrong. If the interviewer says "you pick the scale," pick a modest number and label it as a choice. A design for ten requests a second is a different design from a design for ten thousand. Pretending you were assigned the larger number is how people justify machinery they cannot explain.

Original product, for practice: a feature-flag service used by one company's backend. A flag has a name and a boolean. Servers ask, many times a second in total across the fleet, "is flag new_checkout on?" Operators turn a flag on or off and expect new reads to notice within about a minute. Flags are not per user in this version. There are about two hundred flags. The read path matters more than the write path, because operators change a flag a few times a day and servers read constantly.

Those numbers are made up for the exercise. Say that in the room if you invented them. Then design to them. Two hundred flags that change rarely is a tiny data set. The interesting problem is fresh reads and a boring failure story, not storage.

API and data, before boxes

Reads: GET /flags/{name} returns {"name": "...", "enabled": true} or a 404 if the name does not exist. Writes: PUT /flags/{name} with a boolean, restricted to operators. You do not need a query language. You do not need a flag-expression compiler. Those are future requirements, and you should park them in a "not this version" list so the interviewer sees that you saw them.

The data model is one row per flag: name as the primary key, boolean, and an updated-at timestamp. A single relational table is enough. If you jump to a distributed key-value store, explain which requirement forced it. At two hundred rows, none did. Frugality is allowed to sound like this.

The read path that matches the freshness requirement: each server keeps a copy of all flags in memory and refreshes it about every thirty seconds, which is comfortably inside the one-minute budget. A refresh is GET /flags, a list endpoint you should add once you notice that two hundred single-key reads are silly. The list response is small. A failed refresh keeps the last good copy and logs the error. Operators still write to the source of truth.

Here is a sketch of the in-process cache, not a production client. It is here so the design has code you can talk about, with a complexity you can quote.

import time


class FlagCache:
    def __init__(self, refresh_after: float = 30.0):
        self.refresh_after = refresh_after
        self._flags: dict[str, bool] = {}
        self._loaded_at = 0.0

    def replace_all(self, flags: dict[str, bool]) -> None:
        self._flags = dict(flags)
        self._loaded_at = time.monotonic()

    def needs_refresh(self) -> bool:
        return time.monotonic() - self._loaded_at >= self.refresh_after

    def enabled(self, name: str) -> bool | None:
        if name not in self._flags:
            return None
        return self._flags[name]


cache = FlagCache()
cache.replace_all({"new_checkout": True})
assert cache.enabled("new_checkout") is True
assert cache.enabled("missing") is None

enabled is a dictionary lookup, expected constant time, after the cache has been filled. Filling the cache is linear in the number of flags, which is about two hundred, and it happens on a timer rather than on the user request if you refresh in the background. Say that split. A design that refreshes inside the user request will add the refresh latency to a checkout, which violates the spirit of the requirement even if you never wrote it down. Background refresh has its own bug: the first request before the first successful load. Return a defined behavior. This sketch returns None for an unknown name, and an empty cache makes every name unknown. The caller must decide whether unknown means off. Write that decision down. "Unknown means off" is a product choice, and it should be explicit because it fails closed.

Failure and the diagram you can defend

Draw four boxes and stop: the operator, the flag service with its table, the server process with the cache, and the user request that consults the cache. An edge from the server to the flag service labeled "refresh every 30 seconds" is the interesting edge. Ask what happens when that edge breaks. With the rule above, servers keep serving the last snapshot. Flags can be stale past the one-minute goal until the edge recovers. Say that out loud. A design that claims "no stale reads" needs a different mechanism, such as a push, and you should not claim it with a polling arrow on the board.

You do not need a message bus for two hundred flags. You might mention one as a future option if operators wanted sub-second freshness. Mentioning it and not adopting it is a stronger signal than adopting it and being unable to explain ordering. The graphs page is a reminder that once you add a network of services, failure and retries create cycles in the dependency sense: A waits on B, and B's health check waits on A. Do not draw that graph by accident.

Capacity, still with the made-up numbers: two hundred flags, a few hundred bytes each, refreshed twice a minute by each server. If you have a hundred servers, that is a few hundred list requests a minute against a table that fits in memory. Any ordinary web process can serve that. The bottleneck is not the database. The bottleneck is a bug in the client's refresh logic, which is why the code sketch exists. Interviewers who wanted you to shard a database will usually raise the numbers. Then you update the design. You do not start from the shard.

How to practice

Set a thirty-minute timer. Spend five minutes writing requirements and non-goals. Spend ten on API and data. Spend ten on the read path and one failure. Spend five reviewing which numbers were guesses. Record yourself if you can. Listen for technology names that are not attached to a requirement, and delete them.

Do a second session with a different original product, still small: a service that stores text notes for one team, or a URL redirect list of a few thousand rows. The muscle is the sequence, not the flag domain. The four-week plan schedules these late, after you can already talk about hash maps and queues, because design conversations punish hand-waving about basic structures. The common mistakes version of a design interview is a diagram that cannot answer "what happens when this box is down?"

If a live interviewer pushes into an area you do not know, say what you do know and stop. Guessing a consensus protocol is worse than saying you would look up the guarantee you need. Earn Trust, from the principles page, is partly this: do not decorate the design with words you cannot define.