Interview prep

Interview prep · 22 min

Low-level design questions

Parking lots, elevators and friends: the classes, states and patterns each one needs.

High-level design is about boxes and arrows between machines. Low-level design (LLD), sometimes called object-oriented design or machine coding, zooms into one program: which classes exist, what each one owns, how state changes, and what happens when two threads touch the same thing.

How LLD rounds work

There are two common formats:

  • Design discussion (45 to 60 minutes). You talk through entities, relationships and key methods, draw a class diagram, and write code for the trickiest part.
  • Machine coding (90 to 120 minutes). You write a working, runnable program in your language of choice, often with a fixed set of commands to support. Indian product companies in particular like this format.

Either way, the interviewer is listening for the same things:

  1. Clear entities. Nouns from the problem become classes, and each class has one job. "Book" and "copy of a book" are different things.
  2. State made explicit. Most of these problems have something with a lifecycle (a seat, an order, a vending machine). Name the states and the legal transitions.
  3. Extension without surgery. "Now add EV charging spots" or "now add WhatsApp as a channel" should mean adding a class, not editing ten if statements. This is where patterns like Strategy and State earn their keep.
  4. Concurrency. Two people book the same seat. Two threads hit the same cache. Senior candidates are expected to spot the race before being asked.

Patterns are tools, not goals. Name one only when it solves a real problem in the question. Saying "I'll use a Factory here" with no reason is a red flag, not a bonus.

A process that works

The same steps fit almost every question:

  1. Pin down the use cases. Ask what the system must do and list them: "park a car, pay, leave; show free spots per floor". For machine coding, list the exact commands you'll support.
  2. Find the entities. Underline the nouns in the use cases. Each one that holds data or has rules becomes a class.
  3. Decide who owns what. A Floor has Spots; a Ticket refers to a Spot but doesn't own it. Write the relationships down before any methods.
  4. Draw the lifecycle. Pick the thing with states (the spot, the order, the seat), name the states and the allowed moves between them.
  5. Separate what varies. Anything with several versions that may grow (pricing rules, payment methods, dispatch policies) goes behind an interface.
  6. Look for the race. Ask where two users or threads can touch the same thing, and decide how it's protected.
  7. Code the core. Write the hardest piece properly (the booking, the state change, the eviction), not the getters.

The patterns, in one line each

  • Strategy: put each version of an algorithm in its own class behind one interface, so you can swap or add one without touching the rest (pricing rules, split types).
  • State: give each state of an object its own class that decides what's allowed next, instead of a growing switch (a vending machine that's idle, has money, or is dispensing).
  • Observer: let objects subscribe to events from another object, so it can notify them without knowing who they are (tell every display when a spot frees up).
  • Command: turn an action into an object, so it can be queued, logged or undone (a chess move).
  • Chain of Responsibility: pass a request along a line of handlers, where each one handles what it can and passes the rest on (an ATM paying out ₹2,000 notes, then ₹500, then ₹100).

The sketches use Java or TypeScript, whichever shows the idea more clearly. In the interview, use the language you're fastest in.

Each question below lists the core entities, the key pattern or state machine, the part interviewers dig into, and a short sketch of the most interesting piece.

Parking lot

Core entities: ParkingLot, Floor, Spot (with a size: bike, car, truck, plus flags like EV), Vehicle, Ticket, Gate, PricingStrategy.

Key pattern: Strategy for two things that change independently: how a spot is chosen (nearest to the gate, lowest floor first) and how the fee is calculated (hourly, flat weekend rate).

What interviewers probe: Two entry gates issuing tickets at the same moment must never assign the same spot. Then extensibility: add EV spots, or let a bike park in a car spot when bike spots are full.

Keep a pool of free spots per size. Taking from a concurrent queue is atomic, so two gates can't get the same spot without any explicit lock:

class SpotPool {
  private final Map<SpotSize, ConcurrentLinkedQueue<Spot>> free = new EnumMap<>(SpotSize.class);

  SpotPool(List<Spot> spots) {
    for (SpotSize s : SpotSize.values()) free.put(s, new ConcurrentLinkedQueue<>());
    for (Spot spot : spots) free.get(spot.size()).add(spot);
  }

  // Try the vehicle's own size first, then larger ones.
  Optional<Spot> take(SpotSize needed) {
    for (SpotSize s : SpotSize.values()) {
      if (s.compareTo(needed) < 0) continue;     // enum order: BIKE, CAR, TRUCK
      Spot spot = free.get(s).poll();            // atomic: one gate wins
      if (spot != null) return Optional.of(spot);
    }
    return Optional.empty();
  }

  void release(Spot spot) { free.get(spot.size()).add(spot); }
}

Elevator system

Core entities: Building, Elevator, Controller (or dispatcher), Request (floor plus direction for hall calls, floor only for car calls), Door, Direction.

Key state machine: each elevator is IDLE, MOVING_UP, MOVING_DOWN or DOORS_OPEN. Movement follows the classic elevator algorithm (strictly, the LOOK variant of SCAN): keep going in the current direction while there are stops ahead, then reverse.

What interviewers probe: Which elevator should answer a hall call? A Strategy for dispatch (nearest idle car, or nearest car already heading that way) lets you swap policies. Also: requests arriving while the car moves, and keeping the controller thread-safe.

type Dir = 'up' | 'down' | 'idle'

class Elevator {
  floor = 0
  dir: Dir = 'idle'
  stops = new Set<number>()

  addStop(f: number) { if (f !== this.floor) this.stops.add(f) }  // same floor: just reopen the doors

  // SCAN: continue in the current direction if anything is ahead, else turn around.
  nextStop(): number | undefined {
    const above = [...this.stops].filter((f) => f > this.floor).sort((a, b) => a - b)
    const below = [...this.stops].filter((f) => f < this.floor).sort((a, b) => b - a)
    if (this.dir !== 'down' && above.length) { this.dir = 'up'; return above[0] }
    if (below.length) { this.dir = 'down'; return below[0] }
    if (above.length) { this.dir = 'up'; return above[0] }
    this.dir = 'idle'
    return undefined
  }

  arrive(f: number) { this.floor = f; this.stops.delete(f) }  // then open doors
}

Movie ticket booking (BookMyShow)

Core entities: Movie, Theatre, Screen, Seat, Show (a movie on a screen at a time), ShowSeat (a seat for one show, with a status), Booking, Payment.

Key state machine: a ShowSeat goes AVAILABLE -> HELD -> BOOKED, and HELD -> AVAILABLE when the hold expires or payment fails.

What interviewers probe: Two users select the same seat at the same second. And a user who holds seats and walks away must not block them forever. The cleanest answer pushes the race into the database with one conditional update, so it works across many app servers:

async function holdSeats(showId: string, seatIds: string[], userId: string) {
  return db.transaction(async (tx) => {
    const { rowCount } = await tx.query(
      `UPDATE show_seat
          SET status = 'HELD', held_by = $3, hold_until = now() + interval '8 minutes'
        WHERE show_id = $1 AND seat_id = ANY($2)
          AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_until < now()))`,
      [showId, seatIds, userId],
    )
    // All or nothing: if any seat was taken, roll back the ones we grabbed.
    if (rowCount !== seatIds.length) throw new SeatsUnavailable()
  })
}

Expired holds are reclaimed by the hold_until < now() condition itself, so no cleanup job is needed for correctness. The flip side: confirming a booking must check held_by = $user AND hold_until > now() in the same conditional update, or a user whose hold lapsed could pay for a seat someone else now holds.

Splitwise (expense sharing)

Core entities: User, Group, Expense (payer, amount, participants), Split (EqualSplit, ExactSplit, PercentSplit), BalanceSheet.

Key pattern: Strategy for split types, each validating its own input (percentages must add to 100, exact amounts must add to the total).

What interviewers probe: Money as floating point (don't: use integer paise or cents), rounding (₹100 split three ways), and "simplify debts" so a group settles in as few payments as possible.

// Amounts in paise. Spread the leftover paise so the total is exact.
function equalSplit(total: number, users: string[]): Map<string, number> {
  const base = Math.floor(total / users.length)
  let leftover = total - base * users.length
  return new Map(users.map((u) => [u, base + (leftover-- > 0 ? 1 : 0)]))
}

// net[u] > 0 means u is owed money. Greedy: sort both sides by size, then settle pairwise.
function simplify(net: Map<string, number>) {
  const payments: [string, string, number][] = []
  const debtors = [...net].filter(([, v]) => v < 0).map(([u, v]) => [u, -v] as [string, number])
  const creditors = [...net].filter(([, v]) => v > 0)
  debtors.sort((a, b) => b[1] - a[1]); creditors.sort((a, b) => b[1] - a[1])
  let i = 0, j = 0
  while (i < debtors.length && j < creditors.length) {
    const amt = Math.min(debtors[i][1], creditors[j][1])
    payments.push([debtors[i][0], creditors[j][0], amt])
    debtors[i][1] -= amt; creditors[j][1] -= amt
    if (debtors[i][1] === 0) i++
    if (creditors[j][1] === 0) j++
  }
  return payments
}

The greedy settle-up gives at most n - 1 payments. Finding the true minimum is NP-hard, which is worth saying out loud.

Vending machine

Core entities: VendingMachine, Inventory (slot to product and count), Product, Coin or Note, CashBox, and one class per state.

Key pattern: State. The same button press means different things in different states, and a switch on a status field in every method quickly becomes a mess.

What interviewers probe: Every illegal action in every state (pressing "dispense" with no money, inserting money while dispensing), refunds, and making change with the coins actually in the machine.

interface State {
  default void insert(Machine m, int paise) { throw new IllegalStateException("not now"); }
  default void select(Machine m, String slot) { throw new IllegalStateException("not now"); }
  default void cancel(Machine m) { }
}

class Idle implements State {
  public void insert(Machine m, int paise) { m.balance += paise; m.state = new HasMoney(); }
}

class HasMoney implements State {
  public void insert(Machine m, int paise) { m.balance += paise; }
  public void select(Machine m, String slot) {
    Product p = m.inventory.peek(slot);
    if (p == null) return;                         // sold out, stay here
    if (m.balance < p.price()) return;             // ask for more money
    m.state = new Dispensing();
    m.inventory.take(slot);
    m.returnChange(m.balance - p.price());         // may fail if no coins: check first in real code
    m.balance = 0;
    m.state = new Idle();
  }
  public void cancel(Machine m) { m.returnChange(m.balance); m.balance = 0; m.state = new Idle(); }
}

ATM

Core entities: ATM, CardReader, Card, Account, BankService (the remote bank), CashDispenser, Transaction (Withdrawal, BalanceEnquiry, Deposit).

Key state machine: IDLE -> CARD_INSERTED -> AUTHENTICATED -> TRANSACTION -> IDLE, with an exit to IDLE (and card ejection or retention) from any state.

What interviewers probe: The bank debited the account but the dispenser jammed. You need a reversal, so the order of operations and the failure path matter more than the classes. Also: dispensing an amount from available note denominations, often done with Chain of Responsibility.

class NoteHandler {
  private final int note; private int count; private NoteHandler next;
  NoteHandler(int note, int count, NoteHandler next) { this.note = note; this.count = count; this.next = next; }

  // Returns notes to dispense, or null if this amount can't be made.
  Map<Integer, Integer> plan(int amount) {
    int use = Math.min(amount / note, count);
    for (; use >= 0; use--) {                       // back off if the rest can't be made
      int rest = amount - use * note;
      Map<Integer, Integer> tail = rest == 0 ? new HashMap<>() : next == null ? null : next.plan(rest);
      if (tail != null) { if (use > 0) tail.put(note, use); return tail; }
    }
    return null;
  }
}
// Chain: 500 -> 200 -> 100. Plan first, debit via the bank, then dispense; on jam, reverse the debit.

Tic-tac-toe and chess

Core entities: Game, Board, Player, Move, GameStatus. For chess add Piece with subclasses (King, Knight...), each knowing its own move rules.

Key pattern: polymorphism for pieces (each Piece implements legalMoves(board)), and often Command for moves so you can undo them.

What interviewers probe: For tic-tac-toe on an n by n board, checking for a win in O(1) per move instead of scanning the board. For chess, the rules that cut across pieces: check, castling, en passant, and a move being illegal because it leaves your own king in check.

class TicTacToe {
  private rows: number[]; private cols: number[]
  private diag = 0; private anti = 0
  constructor(private n: number) {
    this.rows = Array(n).fill(0); this.cols = Array(n).fill(0)
  }

  // player is 1 or 2. Returns the winner, or 0. Assumes the cell was empty.
  move(r: number, c: number, player: 1 | 2): number {
    const d = player === 1 ? 1 : -1
    this.rows[r] += d; this.cols[c] += d
    if (r === c) this.diag += d
    if (r + c === this.n - 1) this.anti += d
    const won = [this.rows[r], this.cols[c], this.diag, this.anti].some((v) => Math.abs(v) === this.n)
    return won ? player : 0
  }
}

LRU cache

Core entities: LRUCache with a hash map from key to node, and a doubly linked list of nodes from most to least recently used.

Key idea: the map gives O(1) lookup, the list gives O(1) "move to front" and "remove from back". Neither can do both alone.

What interviewers probe: Getting every pointer update right, then making it thread-safe. A single lock around get and put is the honest first answer. Note that even get writes (it moves a node), so a read-write lock doesn't help. Striping the cache into segments, each with its own lock, is the scalable answer.

class ListNode { prev!: ListNode; next!: ListNode; constructor(public key = 0, public val = 0) {} }

class LRUCache {
  private map = new Map<number, ListNode>()
  private head = new ListNode(); private tail = new ListNode()      // sentinels
  constructor(private cap: number) { this.head.next = this.tail; this.tail.prev = this.head }

  get(key: number): number {
    const n = this.map.get(key)
    if (!n) return -1
    this.unlink(n); this.pushFront(n)
    return n.val
  }

  put(key: number, val: number) {
    const old = this.map.get(key)
    if (old) this.unlink(old)
    const n = new ListNode(key, val)
    this.map.set(key, n); this.pushFront(n)
    if (this.map.size > this.cap) {
      const lru = this.tail.prev
      this.unlink(lru); this.map.delete(lru.key)
    }
  }

  private unlink(n: ListNode) { n.prev.next = n.next; n.next.prev = n.prev }
  private pushFront(n: ListNode) {
    n.next = this.head.next; n.prev = this.head
    this.head.next.prev = n; this.head.next = n
  }
}

Rate limiter class

Core entities: RateLimiter interface with allow(key), implementations TokenBucket, SlidingWindowCounter, a LimiterRegistry that holds one bucket per client key, and a Rule (limit, period) per endpoint or plan.

Key pattern: Strategy for the algorithm, so the API layer doesn't care which one is used.

What interviewers probe: Thread-safety when many requests for one client arrive together, refilling lazily on each call rather than with a timer per bucket, and memory: evicting buckets for clients who have gone quiet.

class TokenBucket {
  private final long capacity;
  private final double refillPerNano;
  private double tokens;
  private long last = System.nanoTime();

  TokenBucket(long capacity, long perSecond) {
    this.capacity = capacity;
    this.refillPerNano = perSecond / 1e9;
    this.tokens = capacity;
  }

  synchronized boolean allow() {
    long now = System.nanoTime();
    tokens = Math.min(capacity, tokens + (now - last) * refillPerNano);  // lazy refill
    last = now;
    if (tokens < 1) return false;
    tokens -= 1;
    return true;
  }
}

class LimiterRegistry {
  private final ConcurrentHashMap<String, TokenBucket> buckets = new ConcurrentHashMap<>();
  boolean allow(String key) {
    return buckets.computeIfAbsent(key, k -> new TokenBucket(20, 10)).allow();
  }
}

Locking is per bucket, so different clients never wait on each other.

Notification service

Core entities: Notification (recipient, template, data, priority), Channel interface (EmailChannel, SmsChannel, PushChannel), Template, UserPreferences, Dispatcher, DeliveryAttempt.

Key pattern: Strategy (or plain polymorphism) for channels, so adding WhatsApp means adding one class and registering it. Observer if notifications are triggered by domain events.

What interviewers probe: Extensibility, retries with backoff when a provider fails, falling back to another channel, user opt-outs and quiet hours, and not sending the same notification twice when a retry overlaps a slow success.

interface Channel {
  name: 'email' | 'sms' | 'push' | 'whatsapp'
  send(userId: string, body: string): Promise<void>
}

class Dispatcher {
  // In production: Redis SET NX with a TTL, keyed by notification id, so it works across
  // workers and a crashed worker's claim eventually expires.
  private claimed = new Set<string>()
  constructor(private channels: Channel[], private prefs: Preferences) {}

  async dispatch(n: Notification) {
    if (this.claimed.has(n.id)) return           // already sent, or being sent right now
    this.claimed.add(n.id)                       // claim BEFORE sending, so an overlapping retry skips
    const allowed = this.channels.filter((c) => this.prefs.allows(n.userId, c.name, n.type))
    for (const ch of allowed) {                  // ordered by preference: first that works wins
      try {
        await ch.send(n.userId, render(n.template, n.data))
        return
      } catch (e) {
        log.warn(`${ch.name} failed for ${n.id}, trying next`, e)
      }
    }
    this.claimed.delete(n.id)                    // nothing went out: let the retry try again
    throw new RetryLater(n.id)                   // a queue redelivers with backoff
  }
}

The claim has to happen before the send. Checking "already sent?" and recording it only after a slow send succeeds leaves a window where a retry passes the check too, which is exactly the duplicate the interviewer asked about. The remaining gap (the provider accepted the message but the call timed out, so we fall through and retry) can't be closed on our side alone. That's why providers that accept an idempotency key are worth preferring.

Library management

Core entities: Book (title, author, ISBN), BookCopy (one physical item with a barcode), Member, Loan (copy, member, due date), Reservation, Fine, Catalogue for search.

Key insight: the difference between a Book and a BookCopy. Members search for books but borrow copies. Mixing the two is the most common mistake in this question.

What interviewers probe: Borrowing limits per member type, a reservation queue per book (the first returned copy goes to the first person waiting), and fine calculation as a Strategy so student and staff rules can differ.

class Library {
  checkout(member: Member, copy: BookCopy, today: Date): Loan {
    if (copy.status === 'on_loan') throw new Error('copy is out')
    if (member.activeLoans.length >= member.plan.maxLoans) throw new Error('loan limit reached')
    if (member.unpaidFines > 0) throw new Error('clear fines first')

    if (copy.status === 'on_hold') {                     // set aside on return for the first person waiting
      const queue = this.reservations.get(copy.book.isbn) ?? []
      if (queue[0]?.memberId !== member.id) throw new Error('held for someone else')
      queue.shift()
    }

    const loan = new Loan(copy, member, addDays(today, member.plan.loanDays))
    copy.status = 'on_loan'
    member.activeLoans.push(loan)
    return loan
  }

  return(loan: Loan, today: Date) {
    loan.copy.status = (this.reservations.get(loan.copy.book.isbn)?.length ?? 0) > 0 ? 'on_hold' : 'available'
    loan.member.unpaidFines += this.finePolicy.fine(loan, today)   // Strategy
    loan.member.activeLoans = loan.member.activeLoans.filter((l) => l !== loan)
  }
}

Food ordering: cart and order lifecycle

Core entities: Restaurant, MenuItem, Cart, CartItem, Order, OrderItem (with the price copied at order time), Payment, Rider.

Key state machine: PLACED -> ACCEPTED -> PREPARING -> READY -> PICKED_UP -> DELIVERED, with CANCELLED allowed only from some states and REJECTED from PLACED.

What interviewers probe: Illegal transitions (you can't cancel a delivered order), a cart that may hold items from only one restaurant, menu prices that change after the item went into the cart, and who is allowed to trigger each transition.

type Status = 'PLACED' | 'ACCEPTED' | 'PREPARING' | 'READY' | 'PICKED_UP' | 'DELIVERED' | 'CANCELLED' | 'REJECTED'
type Actor = 'customer' | 'restaurant' | 'rider' | 'system'

const transitions: Record<Status, Partial<Record<Status, Actor[]>>> = {
  PLACED:    { ACCEPTED: ['restaurant'], REJECTED: ['restaurant'], CANCELLED: ['customer', 'system'] },
  ACCEPTED:  { PREPARING: ['restaurant'], CANCELLED: ['system'] },
  PREPARING: { READY: ['restaurant'] },
  READY:     { PICKED_UP: ['rider'] },
  PICKED_UP: { DELIVERED: ['rider'] },
  DELIVERED: {}, CANCELLED: {}, REJECTED: {},
}

class Order {
  history: { status: Status; at: Date; by: Actor }[] = []
  constructor(public status: Status = 'PLACED') {}

  moveTo(next: Status, by: Actor) {
    const allowed = transitions[this.status][next]
    if (!allowed?.includes(by)) throw new Error(`${by} cannot move ${this.status} -> ${next}`)
    this.status = next
    this.history.push({ status: next, at: new Date(), by })
  }
}

The table is the design. Adding a state means adding one line, and every rule is visible in one place.

Logger framework

Core entities: Logger, Level (DEBUG < INFO < WARN < ERROR), Appender (ConsoleAppender, FileAppender, RemoteAppender), Formatter, LogRecord.

Key patterns: Strategy for appenders and formatters, often a Singleton-like registry for named loggers, and sometimes Chain of Responsibility where a record passes through handlers by level.

What interviewers probe: Logging must never slow down or crash the caller. That means level checks before building the message, async writing through a bounded queue, and a decision about what to do when the queue is full (drop and count, never block the request thread).

enum Level { DEBUG, INFO, WARN, ERROR }

interface Appender { write(line: string): void }

class Logger {
  private queue: string[] = []
  dropped = 0

  constructor(private name: string, private min: Level, private appenders: Appender[], private maxQueue = 10_000) {
    setInterval(() => this.flush(), 100)
  }

  log(level: Level, msg: () => string) {           // lazy message: no cost if filtered out
    if (level < this.min) return
    if (this.queue.length >= this.maxQueue) { this.dropped++; return }   // never block the caller
    this.queue.push(`${new Date().toISOString()} ${Level[level]} [${this.name}] ${msg()}`)
  }

  private flush() {
    const batch = this.queue.splice(0)
    for (const a of this.appenders) {
      try { batch.forEach((l) => a.write(l)) } catch { /* one bad appender must not stop the others */ }
    }
  }
}

Inventory with concurrent reservations

Core entities: Product (SKU), Warehouse, StockLevel (on hand, reserved), Reservation (SKU, quantity, expiry, order id), InventoryService.

Key state machine: a reservation is ACTIVE -> COMMITTED (paid, stock leaves) or ACTIVE -> RELEASED (cancelled or expired, stock returns).

What interviewers probe: The classic race: two checkouts both read "1 left" and both reserve it. Then expiry: a reservation for an abandoned cart must give the stock back. In one process, a compare-and-set loop works. Across servers, use a conditional update in the database or an atomic decrement in Redis.

class StockLevel {
  private final AtomicInteger available;          // on hand minus reserved
  StockLevel(int onHand) { available = new AtomicInteger(onHand); }

  boolean reserve(int qty) {
    while (true) {
      int cur = available.get();
      if (cur < qty) return false;                  // not enough: fail fast, never go negative
      if (available.compareAndSet(cur, cur - qty)) return true;
      // someone else changed it between get and set: loop and re-check
    }
  }

  void release(int qty) { available.addAndGet(qty); }
}

class InventoryService {
  private final Map<String, StockLevel> stock = new ConcurrentHashMap<>();
  private final DelayQueue<ExpiringReservation> expiries = new DelayQueue<>();

  Optional<Reservation> reserve(String sku, int qty, Duration ttl) {
    if (!stock.get(sku).reserve(qty)) return Optional.empty();
    Reservation r = Reservation.active(sku, qty, ttl);
    expiries.add(new ExpiringReservation(r));     // a sweeper thread releases it if not committed in time
    return Optional.of(r);
  }
}

One more race hides in the expiry: the sweeper and the payment's commit() can both act on the same reservation. Make each one move the reservation out of ACTIVE with a compare-and-set, and only the winner touches stock. Otherwise a reservation can be both committed and released, and the unit is sold twice.

The database version of the same idea is UPDATE stock SET available = available - :qty WHERE sku = :sku AND available >= :qty, then check that one row changed.

Snake and ladder

Core entities: Game, Board (size, plus a map of jumps), Snake and Ladder (or just one Jump with a start and end), Dice, Player, a turn queue.

Key design choice: snakes and ladders are the same thing, a jump from one cell to another, so one map handles both. Dice is an interface so tests can use a fixed sequence and variants can roll two dice.

What interviewers probe: Extensibility (multiple dice, "roll a 6 to start", "three 6s in a row forfeits the turn"), rules at the edge (a roll that overshoots the last cell), and making the game testable without randomness.

interface Dice { roll(): number }

class Game {
  private pos = new Map<string, number>()
  private turn: string[]

  constructor(private size: number, private jumps: Map<number, number>, players: string[], private dice: Dice) {
    this.turn = [...players]
    players.forEach((p) => this.pos.set(p, 0))
  }

  // Plays one turn. Returns the winner's name, or null if the game goes on.
  playTurn(): string | null {
    const player = this.turn.shift()!
    let next = this.pos.get(player)! + this.dice.roll()
    if (next > this.size) next = this.pos.get(player)!    // overshoot: stay put
    next = this.jumps.get(next) ?? next                   // snake or ladder
    this.pos.set(player, next)
    if (next === this.size) return player
    this.turn.push(player)
    return null
  }
}

// Test with a fixed dice: const dice = { rolls: [3, 4], i: 0, roll() { return this.rolls[this.i++ % 2] } }

Spot the race

A junior engineer writes seat booking like this: read the seat; if its status is AVAILABLE, set it to HELD and save. Two app servers run this code. Describe exactly how two users end up holding the same seat, and give two different fixes.

Show one answer

User A's request reads the seat: AVAILABLE. User B's request, on another server, reads it a millisecond later: still AVAILABLE. Both set HELD and save. The second save overwrites the first, and both users are told they hold the seat.

Fix 1: make the check and the write one atomic statement, UPDATE ... SET status = 'HELD' WHERE id = ? AND status = 'AVAILABLE', and treat "0 rows updated" as "someone beat you to it".

Fix 2: lock the row while deciding, with SELECT ... FOR UPDATE inside a transaction, so B's read waits until A commits and then sees HELD.

An in-memory lock (synchronized) does not fix it, because the two requests are on different servers.