Part 1 was about what system design is: drawing how a system is, in order to turn requirements into a plan. This part is the first drawing you'll actually make.
It's also the one people skip past because it looks too obvious to matter.
Presentation Domain Data
Laptop (Client) ──request──▶ Server ──query──▶ DB
Laptop (Client) ◀─response── Server ◀─rows─── DB
Three tiers. Client, server, database. Every developer has seen it. Far fewer can say what the boundaries actually buy them — which is exactly what an interviewer is listening for.
The three tiers#
Presentation — what the user touches. Your Android app, a web front end, a smart TV client. Its job is to render state and capture intent. It should not know how data is stored.
Domain — the rules. What's allowed, what a valid order looks like, what happens when a payment fails. This is where the business actually lives, and it's the layer that changes for business reasons rather than technical ones.
Data — where state persists. Postgres, Redis, S3, a search index. Its job is to still be correct after a power cut.
If that split feels familiar, it's because it's the same idea as the layering inside a well-built Android app — UI, domain, data — just drawn at the scale of a whole system instead of one process. Same principle, different altitude.
Why bother separating them#
Separation of concerns, and that phrase gets said so often it's stopped meaning anything. Here's what it actually buys you.
You can use the best tool for each layer. The presentation tier wants Kotlin and Compose. The domain tier might want Go, or Java, or Node — whatever your team ships fastest. The data tier wants Postgres for relational integrity, and maybe Redis alongside it for sessions. Fuse the layers together and you're stuck making one language and one runtime serve three very different jobs.
You can scale only where it hurts. This is the argument that wins interviews. Traffic isn't uniform — one layer usually saturates first:
- Read-heavy product catalogue? The data tier is the bottleneck. Add read replicas or a cache. The clients and the domain logic don't change.
- Expensive image processing? The domain tier is the bottleneck. Add more application servers behind a load balancer. The database is untouched.
- Ten million idle mobile clients? Neither is the bottleneck, and the fix is a CDN in front.
With separated tiers, each of those is a targeted change. In a monolith where the layers bleed into each other, "we need more read capacity" means scaling everything, and paying for all of it.
You can replace a layer. Swapping Postgres for a managed service should be invisible to your Android app. If it isn't, the boundary was never real.
Three tiers is a logical split, not a physical one. All three can run on one machine and it's still three-tier. Microservices are what happens when you cut the domain tier into pieces — a different decision, and one that costs you operational complexity. Don't reach for it in an interview unless the requirements justify it.
Synchronous or asynchronous#
Once the tiers are drawn, every arrow between them is a choice.
Synchronous — you wait, because you need the answer.
Submitting a registration form. The user has to know whether it worked before anything else can happen. You block, you wait, you show success or failure. The waiting isn't a flaw here; it's the requirement.
// the caller cannot continue without this result
val result = api.register(email, password) // suspends until the server answers
when (result) {
is Success -> navigateToHome()
is Failure -> showError(result.message)
}
Asynchronous — you don't block the user.
Uploading a video, sending an analytics event, generating a report. Accept the work, return immediately, deliver the outcome later. The user carries on.
The clearest example is a WebSocket: the connection stays open and both sides can speak whenever they have something to say. A chat message arrives without the client asking. That's the real distinction — not just "no waiting", but bidirectional. HTTP is a client asking and a server answering. A WebSocket is a conversation.
Choosing between them is a trade-off, which is what you should say out loud:
| Synchronous | Asynchronous | |
|---|---|---|
| Good for | The user needs the result now | Slow, expensive, or fire-and-forget work |
| Cost | Ties up a connection; upstream slowness becomes user-facing latency | Delivery, retries, ordering and failure reporting are now your problem |
| Failure mode | Timeouts, cascading stalls | Silent loss, duplicates, out-of-order arrival |
Async isn't free — it moves complexity rather than removing it. "Everything should be async" is as junior as "everything should be sync".
Requests should be stateless#
Strictly speaking, a request should carry everything needed to serve it. The server shouldn't have to remember you from last time.
This is the rule that looks pedantic until it breaks something.
Imagine a server holding your shopping cart in memory after login. Works perfectly with one server. Now you're successful, and you add a second one behind a load balancer:
Request 1 → Server A (cart lives in A's memory)
Request 2 → Server B (what cart?)
Nothing is wrong with the code. The architecture assumed one machine and now there are two. The bug appears only under the load that made you scale.
Stateless means:
- Any server can serve any request. Add or remove machines freely.
- A crashed server loses nothing that matters. Traffic reroutes.
- Testing is honest — no hidden dependency on what happened before.
So where does state go? Into persistence. A database, a cache like Redis, a token the client carries. Somewhere every server can reach and any server can lose without consequence.
That's the pattern: the request carries identity, and the shared store holds state.
❌ Session lives in Server A's memory
✅ Session ID travels with the request; session data lives in Redis
Same for authentication. A signed token the client presents on every request beats a server-side session table you have to synchronise — the server verifies the signature and needs no memory of you at all.
The interview version of this: "I'd keep the application tier stateless so it scales horizontally, and push session state into Redis. That costs a network hop per request, but it means any instance can serve any user and I can lose an instance without losing carts."
That sentence has the decision and the cost. That's the whole game.
Wrapping up#
Three tiers is the first thing to draw and the last thing to over-complicate. Presentation renders, domain decides, data remembers.
Separate them and you can pick the right tool per layer, scale only the layer under pressure, and replace one without touching the others. Then make each arrow between them a conscious sync-or-async choice, and keep your request path stateless so adding a second server is boring instead of catastrophic.
Next in this series: what happens when one server isn't enough — load balancers, horizontal versus vertical scaling, and why the stateless rule we just set is what makes any of it possible.
Until then, let's make this work.

