Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

System Design for Java Backend Engineers

A 45-minute structure that works: clarify and estimate, data model first, then the API, then scale what the numbers say to scale — plus idempotency, the outbox pattern and talking in numbers.

Advanced8 min readUpdated
On this page

System design is the round that decides senior offers, and the most common failure is not lack of knowledge — it is starting to draw boxes before establishing what the system has to do.

Key Takeaways

  • Spend the first five to eight minutes on requirements and numbers. Write them down.
  • Go data model first, then API, then architecture. The data model constrains everything after it.
  • Estimate, then use the estimate. A number you do not act on is decoration.
  • Every distributed system needs idempotency; every event-publishing service needs the outbox.
  • Say the trade-off for every choice. There are no free wins, and pretending otherwise is the tell.

A 45-minute structure

Two thirds of the value is in the first half. A design that follows from stated requirements beats a bigger diagram.

1. Clarify

Ask until you can state the problem in one sentence. Useful questions, roughly in order of value:

  • Who uses this, and what are the two or three core operations?
  • What is the scale — users, writes per second, reads per second?
  • What is the read-to-write ratio? This decides more architecture than anything else.
  • How fresh must reads be? Can they be seconds stale?
  • What is explicitly out of scope?

Then state it back: "So: a URL shortener, 100 million new links a year, read-heavy at roughly 100:1, redirects must be fast globally, and analytics can lag by a minute. Authentication and billing are out of scope." Getting explicit agreement on that sentence is what stops you designing the wrong thing for forty minutes.

2. Estimate

worked, and used
100M new links/year
  -> 100M / 3.15e7 s  ≈ 3 writes/s average, say 30/s at peak
 
Reads at 100:1        ≈ 300/s average, 3,000/s peak
 
Storage: 500 bytes/link x 100M = 50GB/year
  -> 5 years = 250GB. One Postgres instance. No sharding needed.
 
Bandwidth: 3,000 x 500B ≈ 1.5 MB/s. Trivial.
 
Cache: the hot 20% of a year's links = 20M x 500B = 10GB.
  -> fits comfortably in one Redis instance.

Now use it. Thirty writes per second means a single relational database, no queue, no sharding. Three thousand reads per second with a 10GB working set means a cache in front and nothing exotic. Saying "I would not shard here — 250GB and 30 writes per second is a single Postgres instance" is a much stronger answer than a diagram with six shards, because it demonstrates that your architecture is derived rather than recalled.

Numbers worth knowing: an SSD read is ~100µs, a memory read ~100ns, a same-datacentre round trip ~500µs, a cross-continent round trip ~150ms. A single Postgres instance handles thousands of simple writes per second; Redis handles ~100,000 operations per second.

3. Data model first

the schema constrains everything downstream
CREATE TABLE links (
    code         VARCHAR(8) PRIMARY KEY,      -- the short code IS the key
    target_url   TEXT NOT NULL,
    owner_id     BIGINT,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at   TIMESTAMPTZ
);
 
CREATE INDEX idx_links_owner ON links (owner_id, created_at DESC);

Doing this before the boxes forces you to answer the questions that actually matter: what is the access pattern, what is the primary key, which queries need an index, what is the cardinality. An architecture drawn before the data model is a guess.

Say which database and why. Relational for transactions, joins and constraints — which is most things. A document store when the shape varies per record. A key-value store for a pure lookup at enormous scale. Wide-column when you have time-series data with a known partition key. "Postgres, because I need a transaction across two tables and the volume does not justify anything else" is a complete answer.

4. API

the operations, with the details that matter
POST /v1/links
  Idempotency-Key: 7f3a2b91-...
  { "targetUrl": "https://...", "expiresAt": "2027-01-01T00:00:00Z" }
  -> 201 { "code": "aB3xK9", "shortUrl": "https://sho.rt/aB3xK9" }
 
GET /aB3xK9
  -> 301 Location: https://...        (cacheable, and cached by browsers)
 
GET /v1/links?cursor=eyJpZCI6MTIzfQ&limit=50
  -> 200 { "items": [...], "nextCursor": "..." }

Three details that earn credit unprompted: an idempotency key on the write; cursor pagination rather than offset, because OFFSET 100000 scans a hundred thousand rows; and a deliberate choice of 301 versus 302 — permanent is cacheable and fast, temporary lets you count every redirect.

5. Scale what the numbers say to scale

in order of cost
1. Index and query tuning          — free, usually the biggest win
2. Cache reads                     — cheap, huge for read-heavy loads
3. Read replicas                   — moderate; introduces replication lag
4. Vertical scaling                — simple, and there is a ceiling
5. Async processing (a queue)      — decouples, and adds eventual consistency
6. Sharding                        — expensive and hard to undo. Last.

Interviewers are watching for whether you reach for sharding immediately. Most systems described in an interview do not need it, and saying "I would not shard at this volume" while showing the arithmetic is a stronger signal than drawing shards.

Where does state live? Application instances should be stateless so any of them can serve any request. Sessions go to Redis, files to object storage, and anything in a local field is a bug the moment there are two instances.

Idempotency

every write endpoint in a distributed system needs this
@PostMapping("/v1/payments")
public ResponseEntity<Payment> create(
        @RequestHeader("Idempotency-Key") String key,
        @RequestBody PaymentRequest request) {
 
    // Atomic insert-if-absent. A retry of the same key returns the first result.
    Optional<Payment> existing = idempotency.find(key);
    if (existing.isPresent()) {
        return ResponseEntity.ok(existing.get());
    }
 
    Payment payment = processor.charge(request);
    idempotency.store(key, payment, Duration.ofHours(24));
    return ResponseEntity.status(CREATED).body(payment);
}

The client retried because it did not get a response — not because the request failed. Without idempotency, a timeout on a payment call means either losing the payment or charging twice, and there is no way to tell which. Raising this before being asked is one of the clearest senior signals available in this round.

The outbox

The dual-write problem: without the outbox, a crash between committing and publishing loses the event silently.
the shape
@Transactional
public void markPaid(String orderId) {
    Order order = repository.findById(orderId).orElseThrow();
    order.setStatus(PAID);
    // Same transaction. Both land, or neither does.
    outbox.save(new OutboxEvent("OrderPaid", orderId, toJson(order)));
}

Consumers must then be idempotent, because the poller guarantees at-least-once delivery — a crash after publishing but before marking published republishes the event. Saying "at-least-once, so consumers deduplicate on the event id" completes the answer.

Consistency

Rather than reciting CAP, answer the practical question: what does this system do during a network partition?

per operation, not per system
Placing an order        -> strong consistency. Refuse rather than double-sell.
Order history           -> read-your-writes. A user must see their own order.
Product recommendations -> eventual. Stale by a minute is fine.
Global order count      -> eventual. Nobody notices a lag.

Different operations in the same system get different answers, and demonstrating that is worth more than any amount of CAP terminology. A read replica gives you cheap reads and replication lag, so route "my orders" to the primary and "browse products" to a replica.

Failure

Cover four things, briefly:

What happens when each dependency fails? Timeout, circuit breaker, and a fallback or a clean error — see Cascading failure.

What is the blast radius? Bulkheads and rate limits keep one failing feature from taking the rest down.

How do you know? RED metrics per service, alerts on saturation not just errors, distributed tracing.

How do you deploy safely? Canary or blue-green, feature flags, and backward-compatible schema migrations — expand, migrate, contract.

Say the trade-off

Every choice costs something. Naming the cost is the difference between an answer and a recitation.

ChoiceBuysCosts
CacheLatency, database loadStaleness, invalidation complexity
Read replicasRead capacityReplication lag, read-your-writes problems
Async queueDecoupling, spike absorptionEventual consistency, ordering, DLQ handling
ShardingWrite capacityCross-shard queries, rebalancing, operational load
MicroservicesIndependent deploysNetwork failures, distributed transactions, tracing

"I would add a read replica for the product catalogue and accept up to a second of replication lag, but route 'my orders' to the primary so users always see their own writes" is what a senior answer sounds like: a decision, a cost, and a boundary.

The failures that lose the round

Drawing boxes before agreeing the requirements. Estimating and then ignoring the estimate. Reaching for Kafka and sharding at a thousand requests per day. Presenting a design with no downsides. Going silent while thinking — say what you are weighing. And running out of time because you spent twenty minutes on the data model; watch the clock and say "let me move on to scaling and come back if we have time".

Frequently Asked Questions

How much of a system design interview should be spent on requirements?
About five to eight minutes of a forty-five minute session, and it is the highest-leverage part. Designing the wrong system beautifully scores worse than designing the right system adequately. Establish the functional scope, the scale, the read-to-write ratio and the consistency requirements, and write the numbers on the board so you can refer back to them when you justify a decision.
What is the transactional outbox pattern?
Writing an event into an outbox table inside the same database transaction as the business change, then having a separate poller or change-data-capture process publish it to the message broker. It solves the dual-write problem: without it, a crash between the database commit and the broker publish loses the event, and publishing first risks announcing something that then rolls back.
How deep should the estimation be?
Deep enough to drive a decision, and no deeper. Compute requests per second, storage per year and bandwidth, then use them: one hundred writes per second is a single Postgres instance, one hundred thousand is not. Interviewers are not checking arithmetic — they are checking whether your architecture follows from the numbers rather than from a diagram you memorised.

Related tutorials