Backend Engineer · Interview Prep Kit · 2026

DoorDash × Wolt
Interview Field Guide

Every round, the most frequently reported questions (Glassdoor · Blind · 1point3acres · Prepfully · PracHub), worked Kotlin solutions, and a Kotlin brush-up sheet. Built for the Code Craft format — practical API coding, not Leetcode.

Stop 1
Recruiter screen
Stop 2 · You are here
Code Craft — 60 minRound 1
Stop 3
Onsite: (AI) Craft + Debugging
Stop 4
System design
Stop 5
Behavioral / HM

How the loop works

DoorDash (which owns Wolt and Deliveroo) runs a decentralized, practical loop. Round 1 is your Code Craft Challenge (60 min), usually on HackerRank CodePair (some screens now run on CoderPad). Pass that and the virtual onsite (~4–5 hours, 3–5 rounds of 60–75 min) is typically: Code Craft or the new AI Code Craft, Debugging, System Design, and a Behavioral/HM round.

Round 1 — Code Craft (60 min)

5 min intro · 45 min coding · 10 min your questions. Language is your choice (Python, Java, Kotlin, Go all reported). Explicitly not Leetcode — you build a small working service/module (e.g., a Dasher pay calculator) with clean OOP, validation, and error handling. The bank is small and stable: candidates repeatedly report the same two prompts (Dasher pay calculator, resilient bootstrap API).

This kit's focus

AI Code Craft (onsite, new since late 2025)

Bring a local IDE with an AI assistant (Cursor, VS Code + Copilot, Claude Code — "a ChatGPT tab is not enough"). Reported task: build a workflow engine (parse a text workflow description → data structures → execution, e.g., delayed delivery → partial/full refund), plus AI-generated test cases. Graded on how you direct the AI, verify its output, and stay in control. AI use is strictly prohibited in every other round.

Debugging round (onsite)

You get an unfamiliar buggy codebase with a test suite (round-robin load balancer / traffic router, dasher map, pick-dasher are the classics). Bugs are planted in both the code and the tests. Fix surgically, then follow-ups: more tests, thread safety, productionize — often extend to consistent hashing.

System design (onsite)

Delivery-flavored distributed systems: dasher payout pipelines, live location tracking, dispatch/matching, notifications. Payment idempotency and event-driven design come up constantly. This round sets your level — a weak SD performance is the reported down-leveling mechanism.

Behavioral / HM (onsite)

Built around one value: ownership. STAR stories of projects you drove end-to-end, including a failure or hard trade-off. The HM round doubles as a sell/close call — a flat performance there can sink an otherwise-strong loop.

Interviewing at Wolt instead?

Wolt does not use Code Craft. Its Helsinki-run process is: recruiter call → team interview → take-home assignment (reviewed CV-blind) → technical discussion of your take-home with two engineers → director call. No live coding, no Leetcode. See the Wolt Track tab.

Different pipeline

What graders actually score in Code Craft

The recruiter-prep email candidates quote verbatim on Blind lists five graded dimensions:

  • API design & development — sensible method signatures, clean inputs/outputs.
  • OOP + design patterns — data model → rules/config → service separation.
  • Maintainability, readability, testability — sensible names, small units, and you actually ran/tested it.
  • Scalability + failure handling — input validation, custom exceptions, edge cases, what breaks at 10× volume.
  • Handling ambiguity & iterating on feedback — clarify for 2–3 minutes up front (this is scored), take hints, course-correct out loud. Follow-ups like "now add peak-hour bonuses" test extensibility.
Time trap (most common failure): candidates burn 20 minutes on design/TDD ceremony and never finish. Strategy from people who passed: clarify for ~5 min, get the happy path running, then layer edge cases, validation and tests. Working code beats beautiful stubs.
Language: Kotlin. DoorDash's backend is Kotlin-first (they famously migrated their Python monolith to Kotlin microservices), so idiomatic Kotlin reads as stack-native to your interviewer — and strong types score directly against the "API design" and "failure handling" rubric lines. The Blind horror stories ("too many classes, impossible in the time") were about Java ceremony; Kotlin data classes + stdlib avoid that. One tax to manage: the compile-run loop is slower than Python's, so run early and often. Print-debugging is fine.

One-week plan

DayDo
1Read the Kotlin brush-up tab end-to-end. Re-type (don't copy) the Dasher Pay solution.
2Build the Order/Menu pricing problem from scratch, timed 45 min. Speak out loud.
3Rate limiter + in-memory key-value store with TTL, timed. Add unit tests after.
4Debugging drills: the buggy snippets in the Debugging tab, then re-do without notes.
5Mock the full hour with a friend/AI: 5 min clarify, 45 code, 10 questions.
6System design skim (payout pipeline + live tracking) — useful vocabulary even in Round 1. If your onsite has AI Code Craft: set up your IDE + assistant and build the workflow-engine drill once.
7Rest + re-read brush-up + prepare your STAR ownership stories (aim for 8–12 mapped to named values).

Sources: interviewing.io DoorDash guide · TechPrep · Lodely Code Craft guide 2025 · Blind "Code Craft & Debugging" threads · LeetCode Discuss · 1point3acres · PracHub question bank (2026) · Glassdoor · Wolt engineering blog. Frequencies reflect candidate reports, not official banks.

Code Craft — the question bank

The real bank is smaller than prep sites suggest: across Blind, LeetCode Discuss and 1point3acres, candidates repeatedly describe "usually two questions" — the Dasher Pay Calculator and the Resilient Bootstrap API. Master those two cold, then know the shape of the rest. Requirements are revealed in stages — expect "now add X" follow-ups. Full Kotlin solutions are collapsible (all compiled and tested).

asked constantly

1 · Dasher Pay Calculator

Flagship №1OOPvalidation

Given delivery records (delivery_id, dasher_id, start/end time, distance, tips) and a rules config (base pay, per-mile, per-minute, peak bonus), compute each dasher's pay. A current phrasing gives you the day's order events instead (accepted / picked_up / delivered / cancelled) and asks you to derive pay from them. Reported follow-ups: peak-hour multipliers, minimum-pay guarantee, weekly aggregation, malformed records, cancelled orders pay $0, fetching rates from an external rate service (adds failure handling), and the hard one — a concurrent-delivery bonus locked in at acceptance time: payout = base × (1 + bonus_factor × concurrent_count), where concurrent_count is how many deliveries the dasher held when accepting. Track active intervals with a sweep over events.

▸ Kotlin solution (structure the interviewer wants)
import java.time.Duration
import java.time.LocalDateTime

class PayRuleException(message: String) : IllegalArgumentException(message)

data class Delivery(
    val deliveryId: String,
    val dasherId: String,
    val start: LocalDateTime,
    val end: LocalDateTime,
    val distanceMiles: Double,
    val tip: Double = 0.0,
) {
    init {                              // validation at the boundary
        if (end <= start) throw PayRuleException("$deliveryId: end must be after start")
        if (distanceMiles < 0 || tip < 0) throw PayRuleException("$deliveryId: negative distance/tip")
    }
    val minutes: Double get() = Duration.between(start, end).seconds / 60.0
}

data class PayRules(
    val base: Double = 2.00,
    val perMile: Double = 0.75,
    val perMinute: Double = 0.20,
    val peakBonus: Double = 3.00,        // flat bonus if the delivery STARTS in a peak window
    val minPerDelivery: Double = 5.00,   // guarantee before tip
    val peakWindows: List<IntRange> = listOf(11 until 13, 17 until 20),
) {
    fun isPeak(d: Delivery) = peakWindows.any { d.start.hour in it }
}

fun round2(x: Double): Double = Math.round(x * 100) / 100.0

class PayCalculator(private val rules: PayRules) {
    fun deliveryPay(d: Delivery): Double {
        var pay = rules.base + d.distanceMiles * rules.perMile + d.minutes * rules.perMinute
        if (rules.isPeak(d)) pay += rules.peakBonus
        pay = maxOf(pay, rules.minPerDelivery)   // guarantee BEFORE tip
        return round2(pay + d.tip)
    }

    fun payoutByDasher(deliveries: List<Delivery>): Map<String, Double> =
        deliveries.groupBy { it.dasherId }
            .mapValues { (_, ds) -> round2(ds.sumOf(::deliveryPay)) }
}

fun main() {
    val calc = PayCalculator(PayRules())
    val d1 = Delivery("D1", "dash_1", LocalDateTime.of(2026, 7, 1, 11, 30),
                      LocalDateTime.of(2026, 7, 1, 11, 55), 3.2, tip = 4.0)
    val d2 = Delivery("D2", "dash_1", LocalDateTime.of(2026, 7, 1, 15, 0),
                      LocalDateTime.of(2026, 7, 1, 15, 10), 0.5, tip = 0.0)
    println(calc.deliveryPay(d1))              // 16.4 = base+miles+mins+peak + tip
    println(calc.payoutByDasher(listOf(d1, d2)))
}

Why this shape wins: immutable data classes with validation in init, a custom exception, rules separated from the calculator (the "add a rule" follow-up is a one-field change — and peakWindows as data means no code change at all for new windows), groupBy/sumOf aggregation, and a runnable main() demo you execute in front of them. Narrate the types: "distance can't be negative, so the constructor refuses it — illegal states are unrepresentable."

asked constantly

2 · Resilient Bootstrap API (fan-out & merge)

Flagship №2failure handling

Current phrasing: "when the client app loads, fetch everything needed to render the first screen in one call" — compose several mock downstream APIs (user/menu/pricing/ETA), merge responses into one payload, and stay correct when a downstream 500s or is slow. Follow-ups: timeouts, retries with backoff, fallbacks/defaults, partial responses, a clear exception strategy, parallelizing the calls, caching.

▸ Kotlin solution
class DownstreamException(message: String) : RuntimeException(message)

fun <T> withRetry(retries: Int = 2, baseDelayMs: Long = 100, fn: () -> T): T {
    repeat(retries) { attempt ->
        try { return fn() }
        catch (e: DownstreamException) { Thread.sleep(baseDelayMs * (1L shl attempt)) }
    }
    return fn()   // final attempt — let the exception propagate
}

data class StorePage(
    val storeId: String,
    val menu: Map<String, Double>?,      // nullable = "may be degraded", the type says so
    val pricing: Map<String, Double>?,
    val etaMinutes: Int,                 // non-null: always present (defaulted on failure)
    val errors: List<String>,
)

class BootstrapService(
    private val menuApi: (String) -> Map<String, Double>,     // inject as function types
    private val pricingApi: (String) -> Map<String, Double>,  // -> trivial to mock in tests
    private val etaApi: (String) -> Int,
) {
    fun getStorePage(storeId: String): StorePage {
        val errors = mutableListOf<String>()

        fun <T> fetchOrNull(label: String, fn: () -> T): T? =
            try { withRetry { fn() } }
            catch (e: DownstreamException) { errors += "${label}_unavailable"; null }

        val menu = fetchOrNull("menu") { menuApi(storeId) }
        val pricing = fetchOrNull("pricing") { pricingApi(storeId) }
        val eta = try { etaApi(storeId) }
                  catch (e: DownstreamException) { errors += "eta_defaulted"; 45 }
        return StorePage(storeId, menu, pricing, eta, errors)
    }
}

// Follow-up "make it faster": fan the calls out with coroutines
// coroutineScope {
//     val menu    = async { fetchOrNull("menu")    { menuApi(storeId) } }
//     val pricing = async { fetchOrNull("pricing") { pricingApi(storeId) } }
//     StorePage(storeId, menu.await(), pricing.await(), ...)
// }   // mention withTimeout(...) per call so one slow dependency can't eat the budget

Talking points: the nullable fields are the degradation contract — callers must handle absence, the compiler enforces it. Retry only transient errors (5xx/timeouts — never validation failures), add jitter in real systems, never blind-retry non-idempotent writes. Avoid runCatching here — it catches Throwable, and you want narrow catches; saying that out loud scores points.

onsite, since late 2025

3 · AI Code Craft — Workflow Engine

New roundAI-assisted

Onsite round where you must use a local IDE with an AI assistant (Cursor, VS Code + Copilot, Claude Code). Reported task: build a workflow engine — parse a natural-language workflow description into data structures, then execute it (e.g., "if delivery delayed > 30 min → partial refund; > 60 min → full refund"). Also asked: generate test cases with the AI and defend them.

  • Model it as: ConditionAction rules, ordered; a WorkflowEngine.evaluate(event) that returns the first (or all) matching actions. In Kotlin this is sealed interfaces (sealed interface Action; data class Refund(val fraction: Double) : Action) with an exhaustive when — the compiler proves you handled every case.
  • Graded on how you direct the AI: crisp prompts, reviewing generated code, catching its mistakes out loud, keeping the design yours.
  • Practice once before the onsite: same 45-minute discipline, but narrate what you delegate to the AI vs write yourself.
reported Jun 2026

4 · Validate a Shopping Cart

Medium freqvalidation

Implement cart validation for a food-delivery app: reject malformed input, check item availability against the store catalog, enforce quantity/price rules. The shape that wins: a validator that collects all errors (not fail-fast), custom exception types, and rules kept in config so "now add a max-items rule" is a two-line change.

▸ Kotlin skeleton
class CartValidationException(val errors: List<String>) :
    IllegalArgumentException(errors.joinToString("; "))

data class CartItem(val itemId: String, val quantity: Int, val unitPrice: Double)
data class CatalogEntry(val price: Double, val available: Boolean)

class CartValidator(
    private val catalog: Map<String, CatalogEntry>,
    private val maxQtyPerItem: Int = 20,          // rules as config -> follow-ups are cheap
) {
    fun validate(items: List<CartItem>) {
        val errors = buildList {
            if (items.isEmpty()) add("cart is empty")
            for (item in items) {
                val entry = catalog[item.itemId]
                if (entry == null) { add("${item.itemId}: unknown item"); continue }
                if (!entry.available) add("${item.itemId}: unavailable")
                if (item.quantity !in 1..maxQtyPerItem) add("${item.itemId}: bad quantity ${item.quantity}")
                if (Math.abs(item.unitPrice - entry.price) > 1e-9) add("${item.itemId}: stale price")
            }
        }
        if (errors.isNotEmpty()) throw CartValidationException(errors)
    }
}
phone screen / DSA round

5 · Menu / Catalog Tree Comparison

DSA roundrecursion

Two rooted menu trees (nodes have a key unique among siblings + a value, e.g. price or availability). Return the number of changed nodes (added, removed, or value-modified) between old and new menus. The classic DoorDash algorithm question — reported on phone screens and DSA rounds rather than Code Craft proper, but so famous it's worth having cold. A variant adds an active flag with soft-delete semantics.

▸ Kotlin solution
data class Node(val key: String, val value: Int, val children: List<Node> = emptyList())

/** Added, removed, or modified nodes between two menu trees. */
fun countChanges(old: Node?, new: Node?): Int {
    fun subtreeSize(n: Node?): Int =
        if (n == null) 0 else 1 + n.children.sumOf { subtreeSize(it) }

    if (old == null) return subtreeSize(new)   // whole subtree added
    if (new == null) return subtreeSize(old)   // whole subtree removed

    var changes = if (old.value != new.value) 1 else 0
    val oldKids = old.children.associateBy { it.key }
    val newKids = new.children.associateBy { it.key }
    for (key in oldKids.keys + newKids.keys) {
        changes += countChanges(oldKids[key], newKids[key])
    }
    return changes
}

Key trick: index children by key with associateBy, union the key sets (+ on sets), and recurse with plain map indexing — map[key] is null for a missing side, which falls straight into the add/remove branches. The Node? signature makes that flow type-checked.

prep-site staple, unconfirmed 2024–26

6 · API Rate Limiter

Possible / legacystatefollow-ups

Build allow_request(client_id) -> bool: max N requests per sliding window per client. Appears in prep-site pattern lists but no first-person 2024–26 Code Craft report — still a great 45-minute drill and a plausible variant. Follow-ups: token bucket vs sliding window, thread safety, per-endpoint limits.

▸ Kotlin solution (sliding window + token bucket)
import java.util.ArrayDeque

class SlidingWindowLimiter(private val maxRequests: Int, private val windowSec: Double) {
    init { require(maxRequests > 0 && windowSec > 0) { "limits must be positive" } }
    private val hits = mutableMapOf<String, ArrayDeque<Double>>()

    fun allowRequest(clientId: String, now: Double = System.nanoTime() / 1e9): Boolean {
        val q = hits.getOrPut(clientId) { ArrayDeque() }
        while (q.isNotEmpty() && q.peekFirst() <= now - windowSec) q.pollFirst()  // evict old
        if (q.size < maxRequests) { q.addLast(now); return true }
        return false
    }
}

/** Alternative: smooth rate with burst capacity. */
class TokenBucket(
    private val ratePerSec: Double,
    private val capacity: Int,
    now: Double = System.nanoTime() / 1e9,   // injectable clock
) {
    private var tokens = capacity.toDouble()
    private var lastSec = now

    fun allowRequest(now: Double = System.nanoTime() / 1e9): Boolean {
        tokens = minOf(capacity.toDouble(), tokens + (now - lastSec) * ratePerSec)
        lastSec = now
        if (tokens >= 1) { tokens -= 1; return true }
        return false
    }
}

Injecting now as a default parameter makes it unit-testable without sleeping — say that out loud, it scores points. (Use System.nanoTime(), monotonic, not currentTimeMillis(), which can jump backwards on clock sync.)

prep-site staple, unconfirmed 2024–26

7 · Order Workflow / State Machine

Possible / legacyenumdesign patterns

Model an order lifecycle: CREATED → ACCEPTED → PICKED_UP → DELIVERED (+ CANCELLED). Reject illegal transitions; support querying history. Not in recent first-person Code Craft reports, but the pattern is load-bearing: the event-based pay-calculator phrasing and the AI Code Craft workflow engine both build on exactly this.

▸ Kotlin solution
import java.time.Instant

enum class OrderState { CREATED, ACCEPTED, PICKED_UP, DELIVERED, CANCELLED }

val VALID_TRANSITIONS: Map<OrderState, Set<OrderState>> = mapOf(
    OrderState.CREATED to setOf(OrderState.ACCEPTED, OrderState.CANCELLED),
    OrderState.ACCEPTED to setOf(OrderState.PICKED_UP, OrderState.CANCELLED),
    OrderState.PICKED_UP to setOf(OrderState.DELIVERED),
    OrderState.DELIVERED to emptySet(),
    OrderState.CANCELLED to emptySet(),
)

class InvalidTransition(from: OrderState, to: OrderState) :
    IllegalStateException("${from.name} -> ${to.name}")

class Order(val orderId: String) {
    var state = OrderState.CREATED
        private set                          // read anywhere, written only via transition()
    val history = mutableListOf(state to Instant.now())   // Instant = UTC by construction

    fun transition(new: OrderState) {
        if (new !in VALID_TRANSITIONS.getValue(state)) throw InvalidTransition(state, new)
        state = new
        history += new to Instant.now()
    }

    val isTerminal get() = VALID_TRANSITIONS.getValue(state).isEmpty()
}

var state; private set is the Kotlin encapsulation flex: callers can read the state but the only write path is the guarded transition(). Mention the sealed-class upgrade — states carrying data (e.g. Cancelled(reason)) — if they push on modeling.

DSA rounds / phone screens

8 · The DSA-round questions (know the shape)

These are real DoorDash questions, but from the algorithm rounds, not Code Craft:

  • K nearest restaurants/dashers to a coordinate — heapq.nsmallest with distance key; follow-up: nearest marts that serve given customers.
  • Grid: distance to nearest source cell (Walls & Gates pattern) — multi-source BFS.
  • Dasher Max Profit — pick non-overlapping delivery windows maximizing pay (interval-scheduling DP; sort by end, binary-search the last compatible interval).
  • Merchant rating service — add ratings, get average, top-K merchants (use heapq).
  • Order assignment under capacity constraints — assign deliveries to drivers minimizing distance; handle empty lists and duplicate IDs.

Dropped from earlier versions of this list: the "KV store with TTL" prompt circulating online is attributed to The Trade Desk, not DoorDash, and the expression evaluator has no recent DoorDash sourcing.

The 45-minute playbook

  1. Min 0–5: restate the problem, ask 2–3 clarifying questions (input format? malformed data? scale? what to return?). Write requirements as comments.
  2. Min 5–15: sketch classes (data model → rules/config → service). Get the happy path running.
  3. Min 15–30: run it with a demo in main(). Fix, then add validation + custom exceptions.
  4. Min 30–40: take the follow-up ("now add peak bonus") — this is why config/rules are separate objects.
  5. Min 40–45: add 2–3 quick asserts/tests, state complexity, name what you'd do with more time (logging, persistence, concurrency).

Debugging round

~45 minutes. You're handed an unfamiliar multi-class "service" with planted bugs plus a test file, framed as fixing a colleague's broken code. Workflow: run the tests → find why they fail → fix surgically. Graded on reading speed, verbalizing the root cause before touching code, small-PR fixes, and the follow-ups (more tests, thread safety, productionize). The classics: round-robin load balancer / traffic router, pick dasher, dasher map — with consistent hashing as the big extension.

Trap: bugs are reportedly planted in the tests too. Don't treat the test file as ground truth — read it as suspiciously as the implementation.
Language note: candidate reports say the handed codebase is usually Python — you read theirs, you don't pick. The drills below are in Kotlin so the patterns stick, but skim enough Python to read it comfortably (dict/KeyError, None, indentation blocks). The planted bug families are identical.

Drill 1 · Round-robin traffic router (the classic)

This is the reported onsite problem: a colleague's TrafficRouter distributes requests to pods and "doesn't work." The buggy version below plants the same bug families candidates report — spot all four before expanding.

// BUGGY — find 4 issues
class TrafficRouter(private val pods: List<String>) {
    private val status = mutableMapOf<String, String>()   // pod -> "AVAILABLE"/"UNAVAILABLE"

    fun reportAvailability(pod: String, s: String) { status[pod] = s }

    fun getPod(): String {
        var i = 0                                  // bug?
        while (true) {
            val pod = pods[i]
            i += 1                                 // bug?
            if (status[pod] == "AVAILABL") {       // bug?
                return pod
            }
        }
    }
}
▸ Bugs & fixed version
  1. The cursor is a local. var i = 0 resets on every call — no rotation, every request hits pod 0. (The reported real bug.) Persist it as a field.
  2. Never wraps. i += 1 walks off the list → IndexOutOfBoundsException. Modulo, and bound the scan to one full cycle so all-pods-down raises instead of spinning.
  3. String typo. "AVAILABL" never matches — the condition is always false. (Also a reported real bug.) The durable fix is an enum class: the compiler makes this bug impossible.
  4. Uninitialized status map. A pod that never called reportAvailability has a null status — treated as down forever. Initialize all pods AVAILABLE.
class TrafficRouter(pods: List<String>) {
    enum class Status { AVAILABLE, UNAVAILABLE }   // no typo bugs, compiler-checked

    init { require(pods.isNotEmpty()) { "need at least one pod" } }
    private val pods = pods.toList()
    private val status = pods.associateWith { Status.AVAILABLE }.toMutableMap()
    private var cursor = 0                          // persists across calls

    fun reportAvailability(pod: String, s: Status) {
        require(pod in status) { "unknown pod: $pod" }
        status[pod] = s
    }

    fun getPod(): String {
        repeat(pods.size) {                         // at most one full cycle
            val pod = pods[cursor]
            cursor = (cursor + 1) % pods.size
            if (status[pod] == Status.AVAILABLE) return pod
        }
        error("no available pods")                  // IllegalStateException
    }
}

Follow-up they ask: tests (all-down, wrap-around, recovery), then "how would you weight pods?" (weighted RR) and thread safety (synchronized(this) around getPod, or an AtomicInteger cursor) — and the big one, consistent hashing (Drill 5).

Drill 2 · Uninitialized accumulator (assignment map)

// BUGGY
fun assignRoundRobin(tasks: List<String>, workers: List<String>): Map<String, Int> {
    val load = mutableMapOf<String, Int>()
    for ((i, _) in tasks.withIndex()) {
        val w = workers[i % workers.size]
        load[w] = load[w]!! + 1          // NPE on first hit
    }
    return load
}
▸ Fix
fun assignRoundRobin(tasks: List<String>, workers: List<String>): Map<String, Int> {
    require(workers.isNotEmpty()) { "no workers" }        // also fixes i % 0
    val load = mutableMapOf<String, Int>()
    for ((i, _) in tasks.withIndex()) {
        load.merge(workers[i % workers.size], 1, Int::plus)   // or (load[w] ?: 0) + 1
    }
    return load
}

The !! was the tell — in a debugging round, every !! is a suspect. merge/getOrPut/?: 0 are Kotlin's defaultdict.

Drill 3 · Pick dasher (unit mismatch + tie-breaking)

The reported onsite version: a service that picks the best dasher for an order is assigning the wrong ones. Two planted bug families — find both.

// BUGGY — exclude dashers whose location is stale (older than 60 seconds)
fun pickDasher(dashers: List<Dasher>, nowMs: Long): Dasher? {
    var best: Dasher? = null
    for (d in dashers) {
        val age = nowMs - d.lastUpdateMs
        if (age > 60) continue                                     // bug?
        if (best == null || d.distanceKm < best.distanceKm) best = d   // bug?
    }
    return best
}
▸ Bugs & fixed version
  1. Unit mismatch: age is in milliseconds, the threshold is in seconds — so almost every dasher looks stale. This exact ms-vs-s bug is the reported centerpiece. (Name the durable fix: suffix the variable — ageSec — or wrap in java.time.Duration.)
  2. Unstable tie-breaking under GPS jitter: raw distance comparison flaps between near-equal dashers. Reported expected order: nearest within a jitter tolerance → most recent update → smallest dasherId.
  3. (Edge cases to name: no candidates → the Dasher? return type already forces callers to handle it; clock skew — a future timestamp gives negative age, clamp with coerceAtLeast(0).)
const val STALE_AFTER_SEC = 60
const val JITTER_KM = 0.05                 // distances within 50 m are "equal"

fun pickDasher(dashers: List<Dasher>, nowMs: Long): Dasher? =
    dashers
        .filter { (nowMs - it.lastUpdateMs).coerceAtLeast(0) / 1000 <= STALE_AFTER_SEC }
        .minWithOrNull(compareBy(
            { Math.round(it.distanceKm / JITTER_KM) },   // bucket ties
            { -it.lastUpdateMs },                        // then freshest
            { it.dasherId },                             // then deterministic
        ))

Follow-ups they ask: observability (structured logs + a metric for "no dasher found"), timeouts/retries when the location service is down, circuit breakers.

Drill 4 · Dasher map (O(1) removal, contiguous indices)

Maintain a registry of dashers with indices 0..N-1 that stay contiguous after random removals, all ops O(1). The trick is swap-with-last:

class DasherMap {
    private val dashers = mutableListOf<String>()   // index -> dasherId
    private val pos = mutableMapOf<String, Int>()   // dasherId -> index

    fun add(dasherId: String): Int {
        require(dasherId !in pos) { "$dasherId already registered" }
        pos[dasherId] = dashers.size
        dashers += dasherId
        return pos.getValue(dasherId)
    }

    fun remove(dasherId: String) {
        val i = pos.remove(dasherId) ?: throw NoSuchElementException(dasherId)
        val last = dashers.removeAt(dashers.size - 1)
        if (last != dasherId) {                     // move last into the hole
            dashers[i] = last
            pos[last] = i
        }
    }
}

Follow-ups: thread safety (one lock vs fine-grained), and async remote failures — make removes idempotent, retry, and reconcile periodically when a downstream deregistration call fails.

Drill 5 · The consistent-hashing extension (where the round is won)

The standard follow-up to the load balancer: "now make it stateful — the same user must always hit the same pod, with minimal remapping when pods join/leave." Multiple reports say this extension is substantial — if you're short on time, negotiate scope with the interviewer out loud.

import java.util.TreeMap
import java.security.MessageDigest

class ConsistentHashRing(pods: List<String> = emptyList(), private val vnodes: Int = 100) {
    private val ring = TreeMap<Long, String>()      // the structure they expect on the JVM

    init { pods.forEach(::add) }

    private fun hash(s: String): Long {
        val d = MessageDigest.getInstance("MD5").digest(s.toByteArray())
        var h = 0L
        for (i in 0 until 8) h = (h shl 8) or (d[i].toLong() and 0xff)
        return h
    }

    fun add(pod: String) {
        for (v in 0 until vnodes) ring[hash("$pod#$v")] = pod   // vnodes smooth distribution
    }

    fun remove(pod: String) {
        ring.entries.removeIf { it.value == pod }
    }

    fun get(requestId: String): String {
        check(ring.isNotEmpty()) { "no pods on the ring" }
        val h = hash(requestId)
        return (ring.ceilingEntry(h) ?: ring.firstEntry()).value   // clockwise, wrap
    }
}

Talking points: virtual nodes fix hot-spotting; only ~1/N of keys remap when a pod leaves; TreeMap.ceilingEntry + wrap to firstEntry is exactly the JVM idiom candidate reports say interviewers expect — you get to use it natively in Kotlin.

How this round is actually lost (from candidate post-mortems)

  • Skipping the read. Spend the first ~10 minutes reading requirements + code and clarifying before changing anything.
  • Fixing the symptom. Don't patch where the error appears — follow the stack trace up to the real cause.
  • Rewriting. "Give your reviewer a small PR, not a gigantic one" — wholesale rewrites are an explicit negative signal.
  • Trusting the tests. Bugs are planted there too.
  • Ignoring the follow-up budget. Rejected candidates mostly found the bugs fine — the round was decided in the optimize/productionize/consistent-hashing discussion. Leave 15+ minutes for it.

Bug patterns to have in muscle memory

PatternSymptomFix
Missing map default + !!NullPointerExceptionmerge(k, 1, Int::plus) / getOrPut / (m[k] ?: 0) + 1
String-typed states with a typo ("AVAILABL")condition never trueenum class — the compiler catches it; grep the literal to prove the bug
Unit mismatch (ms vs s, m vs km)thresholds absurdly strict/loosesuffix variables with units: ageSec, distKm; or use Duration
Loop state re-initialized inside the callround-robin always returns pod 0persist the cursor as a field
No index wrapIndexOutOfBoundsExceptioni = (i + 1) % n, bound the scan
== vs ===identity compared instead of valuein Kotlin == is structural (calls equals) — the right default; === almost never
Mutating a list while iteratingConcurrentModificationExceptioniterator.remove(), removeIf, or build a new list with filterNot
Int division / Double for money7 / 2 == 3; off-by-cents7.0 / 2 or toDouble(); round at the end, cents as Long, BigDecimal if they push
Off-by-one in windowsboundary events misseddecide open/closed (.. vs until), test the boundary
Swallowed exceptions catch (e: Exception) {}silent corruptioncatch narrow types, log, re-throw; beware runCatching (catches Throwable)
Shared mutable state, no lockrace under threadssynchronized, AtomicInteger, or single-writer design
LocalDateTime for absolute timepay windows wrong by hours across zonesInstant (UTC) internally, convert to zones at the edges
assert() in quick testssilently does nothingJVM asserts are off without -ea — use check() / require()

Method: read like a PR review — say what's wrong before touching anything, fix surgically (no rewrites), then run and prove it.

System design round

Prompts are delivery-flavored, 60–75 min. This round sets your level: E5 candidates are expected to surface all requirements and trade-offs unprompted; a weak SD round is the reported down-leveling mechanism. The four that dominate candidate reports:

1 · Dasher payout system (event-driven)

Most reported

Ingest ACCEPT/FULFILL order-lifecycle events at high volume, compute each dasher's pay, expose payout APIs. What they probe:

  • Idempotency & dedup: at-least-once delivery + idempotent consumers (dedupe table keyed by event_id) ≈ effective exactly-once.
  • Separation: compute immutable earning components per delivery → aggregate into payout batch → move money only after batch finalized. Calculation ≠ disbursement.
  • Retries: exponential backoff + jitter for 5xx/timeouts; never blind-retry declines or validation failures.
  • Reconciliation: audit jobs comparing events vs computed pay; alert on drift.
  • Timezones/DST for "pay period" queries — store UTC, convert at the edge.

2 · Real-time dasher location tracking

  • Do the load math out loud — prep sources cite the expected envelope: ~2.5M dashers × 1 ping/2s ≈ 1.25M writes/sec; polling customers ≈ 2M reads/sec.
  • GPS pings every few seconds → stateless ingestion service → Kafka partitioned by order/region → workers compute ETA & geofences.
  • Latest location in Redis (geo index) with short TTL; history to cold storage.
  • Fan-out to customers via WebSocket/SSE pub-sub; rate-limit and smooth jitter.
  • Eventual consistency is fine here (vs payments) — say this trade-off explicitly.

3 · Dispatch / order-to-dasher matching

  • Geospatial query (Redis Geo / S2 / geohash) for nearby dashers; score by distance + prep time + load.
  • Offer with strict timeout → decline/expiry retries with next candidate.
  • Atomic conditional update on assignment so two drivers never get the same order.
  • Batching/stacking orders from one restaurant; regional sharding to limit blast radius.

4 · Notification system (multi-channel, multi-tenant)

  • Alert events in → user subscription rules → fan-out to push/SMS/email workers via queues.
  • Dedup, rate limiting per user, retries with DLQ, template rendering, preference service.

5 · Also in the 2026 bank (PracHub + candidate reports)

  • 3-day donation/charity platform — appears in four variants, so likely frequent: short campaign window, donation ingestion spike, 3-day rolling totals, donor selection. Probes: hot-partition writes, aggregation strategy, consistency of running totals.
  • Food rating / customer review system — aggregate averages at scale; the senior variant adds voting, rewards, and fraud/abuse handling. Sometimes paired with driver payouts in one prompt.
  • Resilient bootstrap API — the Code Craft flagship reappears as a design prompt: SLA protection when a dependency fails, storage choice.
  • Distributed cron job scheduler (multi-tenant, rated Hard) · real-time metrics/monitoring system · ads click aggregator.
  • Senior variant: deep-dive your own project's architecture — be ready to draw a system you actually built.
Format that scores: 5 min clarifying requirements + SLAs → back-of-envelope QPS/storage → APIs & data model → high-level diagram → deep-dive one component → failure modes & monitoring. They grade scoping and trade-off narration more than the final diagram — and resilience during the dinner-rush peak is the recurring theme to name explicitly.

Behavioral / HM round

DoorDash's behavioral loop is built around ownership. Advice repeated across sources: prepare 8–12 STAR stories mapped explicitly to named values, with quantified outcomes. The HM round doubles as a sell/close call — bring energy and genuine questions; a flat HM conversation has sunk otherwise-strong loops.

The 12 values (verified current, Dec 2025)

PillarValues
We are leadersBe an owner · Dream big, start small · Choose optimism and have a plan
We are doersBias for action · Operate at the lowest level of detail · "And", not "either/or"
We are learnersTruth seek · 1% better every day · Customer-obsessed, not competitor-focused
We are one teamMake room at the table · Think outside the room · One team, one fight

Name the value when you tell the story ("this is my bias for action example") — interviewers write feedback against these labels.

Most-reported questions

  • Walk through one project in depth: business problem, your role, technical approach, trade-offs, outcome.
  • Tell me about a project you drove end-to-end (idea → production) — and what went wrong.
  • A time you failed / your biggest failure and what you learned.
  • A time you moved fast and broke something.
  • A decision made with incomplete information.
  • A project where priorities shifted mid-flight.
  • Disagreement with your manager, a teammate, or a stakeholder — when did you push back, and how did it resolve?
  • Influencing without authority.
  • Why DoorDash / Wolt? (Know the three-sided marketplace: consumers, merchants, dashers/couriers.)

Tip: quantify results, and always include what you did — "we" stories land badly here.

Questions worth asking them (your last 10 min in Round 1)

  • "What does the team own in the order lifecycle, and what's the messiest failure mode you deal with?"
  • "How does the Wolt–DoorDash platform integration affect this team's roadmap?"
  • "What separates the engineers who ramp fastest here?"

Wolt track — a different pipeline entirely

Wolt (DoorDash-owned, run from Helsinki) does not use Code Craft. Per Wolt's own engineering blog: no live coding, no Leetcode, no whiteboarding (a whiteboard only ever as a visual aid). The centerpiece is a take-home assignment, reviewed CV-blind — the reviewers don't see your name, CV, or photo. End-to-end the process runs ~2 months.

The loop

  1. Recruiter call — background, motivation, logistics.
  2. Hiring-team interview (~1.5 h) — behavioral, with team leads.
  3. Take-home assignment — realistic scope, reviewed anonymously on correctness and maintainability.
  4. Technical interview (~1.5 h, two engineers) — a discussion of your take-home: design choices, trade-offs, and "how would you scale this / run it in production?" — this is effectively their system design round.
  5. Director call — values and close.

The canonical backend take-home: Delivery Order Price Calculator (DOPC)

A small web API that computes the total price and a breakdown for a delivery order: distance-based delivery fee, small-order surcharge, venue data fetched from Wolt's public Home Assignment API. (The public reference is the woltapp/backend-internship-2025 repo — senior take-homes vary but rhyme with it.)

  • Graded on correctness and maintainability — production-ready beats clever.
  • Their explicit guidance: don't expand scope; robustness over fancy libraries.
  • What wins: clean domain model, validation at the boundary, sensible error responses, tests, a README explaining trade-offs — exactly the Code Craft virtues, minus the clock.
  • Prepare for the review interview by knowing every line: why this framework, what breaks first under load, what you'd change with more time.

Culture & what they screen for

Wolt does not interview against DoorDash's 12 values. Their stated culture: "we get things done," "we do common things uncommonly well," "think big but stay humble" — low-ego, no-blame, learn-and-teach. Interviewers assess humbleness, vulnerability, the ability to listen, and interest in others. The bragging-forward style that works in US loops lands badly here — show curiosity and credit-sharing instead.

If your loop is DoorDash-run (US/global platform roles, including post-acquisition "Wolt" reqs run through DoorDash recruiting), everything else on this site applies. If it's Helsinki-run Wolt recruiting, this tab is your track — confirm with your recruiter which pipeline you're in.

Kotlin brush-up for Code Craft

Exactly the Kotlin you need for this interview format: modeling data with types, defensive programming, collections, time handling, and quick tests. Skim daily; re-type the snippets once. Strong types are your differentiator — narrate them ("this can't be null, the compiler proves it").

1 · Data classes — your data model in 5 lines

data class Delivery(
    val deliveryId: String,
    val distanceMiles: Double,
    val tip: Double = 0.0,                    // defaults come last
    val tags: List<String> = emptyList(),
) {
    init {                                    // validation hook
        require(distanceMiles >= 0) { "distance cannot be negative" }
    }
}

val d = Delivery("D1", 3.2, tip = 4.0)
d == Delivery("D1", 3.2, tip = 4.0)   // true — equals/hashCode/toString for free
val cheaper = d.copy(tip = 0.0)       // "modified" record without mutation
val (id, miles) = d                   // destructuring (declaration order!)

val everywhere = immutable records by default. Use copy() instead of mutating; a plain class with vars only for genuinely stateful things (the calculator, the limiter). require() in init is the Kotlin __post_init__.

2 · Enums & sealed classes for states

enum class OrderState { CREATED, ACCEPTED, PICKED_UP, DELIVERED, CANCELLED }

OrderState.CREATED.name          // "CREATED"
OrderState.valueOf("ACCEPTED")   // lookup by name (throws on unknown)
OrderState.entries               // iterate all states

// The upgrade: states that carry data — sealed = compiler-checked exhaustiveness
sealed interface PayComponent
data class BasePay(val amount: Double) : PayComponent
data class PeakBonus(val multiplier: Double) : PayComponent

fun describe(c: PayComponent): String = when (c) {   // no else needed:
    is BasePay   -> "base $${c.amount}"              // compiler proves every
    is PeakBonus -> "×${c.multiplier} peak"          // case is handled
}

Pair the enum with a transition map Map<OrderState, Set<OrderState>> and throw a custom exception on illegal transitions — this pattern nails the "order workflow" prompt. Reach for sealed when states carry data (Cancelled(reason)).

3 · Collections you'll actually use

val totals = mutableMapOf<String, Double>()
totals.merge("dash_1", 12.5, Double::plus)       // no null dance — Kotlin's defaultdict
val q = totals.getOrPut("dash_2") { 0.0 }        // insert-if-missing, return it

deliveries.groupBy { it.dasherId }               // grouping -> Map<String, List<Delivery>>
orders.groupingBy { it.store }.eachCount()       // Counter
    .entries.sortedByDescending { it.value }.take(3)   // top-3 stores

val byId = deliveries.associateBy { it.deliveryId }    // index by unique key
oldKids.keys + newKids.keys                      // set union (the tree-diff trick)
deliveries.sumOf { it.tip }                      // no temp list
val (peak, offPeak) = deliveries.partition { rules.isPeak(it) }

val ring = ArrayDeque<Int>()                     // O(1) addLast/removeFirst
ring.addLast(1); ring.removeFirst()

4 · Sorting & top-K (nearest-K)

deliveries.sortedWith(compareBy({ it.start }, { -it.tip }))   // multi-key: asc, then desc
val top5 = dashers.sortedByDescending { it.rating }.take(5)

// K nearest restaurants
val nearest = restaurants
    .sortedBy { (it.x - px) * (it.x - px) + (it.y - py) * (it.y - py) }
    .take(k)                                     // O(n log n) — fine at interview scale

// True heap when they ask for it:
val heap = java.util.PriorityQueue<Pair<Int, String>>(compareBy { it.first })
heap.add(3 to "order"); val (prio, item) = heap.poll()

5 · Exceptions & defensive programming (they grade this)

class PayRuleException(message: String, cause: Throwable? = null) :
    IllegalArgumentException(message, cause)      // custom, specific exceptions

fun parseRecord(raw: Map<String, String>): Delivery =
    try {
        Delivery(
            raw.getValue("id"),                   // throws NoSuchElementException if missing
            raw.getValue("distance").toDouble(),  // throws NumberFormatException if junk
            raw["tip"]?.toDouble() ?: 0.0,        // optional field, typed default
        )
    } catch (e: NoSuchElementException) { throw PayRuleException("bad record $raw", e) }
      catch (e: NumberFormatException)  { throw PayRuleException("bad record $raw", e) }

// the three built-in guards — know which exception each throws:
require(maxRequests > 0) { "must be positive" }   // IllegalArgumentException (bad input)
check(ring.isNotEmpty()) { "no pods" }            // IllegalStateException (bad state)
error("unreachable")                              // always throws ISE

// catch NARROW, act, re-throw — never catch (e: Exception) {} to move on
for (raw in records) {
    try { totals.merge(parseRecord(raw).dasherId, 1, Int::plus) }
    catch (e: PayRuleException) { log.warn("skipping record: ${e.message}") }
}

Chain the cause (PayRuleException(msg, e)) so the stack trace keeps the original error. Skip runCatching in interviews — it catches Throwable, including the errors you never want swallowed.

6 · java.time without pain

import java.time.*

val now = Instant.now()                            // absolute time, UTC by construction
val t = LocalDateTime.parse("2026-07-01T11:30")    // "wall clock" — fine within one zone
val mins = Duration.between(start, end).toMinutes()          // whole minutes (Long)
val precise = Duration.between(start, end).seconds / 60.0    // fractional
val later = start.plusMinutes(30)
start.hour                                         // peak-hour checks
start <= end                                       // Comparable — operators just work

Rule: Instant for absolute/stored time, LocalDateTime only for wall-clock logic like peak windows, convert at the edges. Money: Double drifts — Math.round(x * 100) / 100.0 at the end, keep cents as Long, or BigDecimal if they push.

7 · Null safety — the strong-types edge

fun find(key: String): Delivery? = index[key]     // absence is IN the signature

val tip = find("D1")?.tip ?: 0.0                  // safe call + elvis default
find("D1")?.let { process(it) }                   // run only if present
val d = find("D1") ?: throw NoSuchElementException("D1")   // assert presence, loudly

// smart casts — check once, typed after:
var best: Dasher? = null
for (cand in candidates)
    if (best == null || cand.distanceKm < best.distanceKm) best = cand  // best smart-cast

// nullable fields as API contract (bootstrap problem):
data class StorePage(val menu: Menu?, val etaMinutes: Int)
// menu can degrade -> callers MUST handle null; eta always present. The type is the doc.

Never !! in interview code — every !! is a latent NPE, and in the debugging round it's usually the bug. Say that out loud.

8 · Function types & small design moves (cheap credibility)

// inject dependencies as function types -> mocked in one line, no framework
class BootstrapService(private val menuApi: (String) -> Menu)
val svc = BootstrapService { storeId -> fakeMenu }        // test double

fun <T> withRetry(retries: Int = 2, fn: () -> T): T { /* ... */ }   // generic helper

fun Double.round2(): Double = Math.round(this * 100) / 100.0   // extension function
pay.round2()

typealias DasherId = String        // intent in signatures: Map<DasherId, Double>

fun payoutByDasher(ds: List<Delivery>): Map<String, Double> =   // expression body,
    ds.groupBy { it.dasherId }.mapValues { (_, v) -> v.sumOf(::pay).round2() }

9 · Quick tests in the last 5 minutes (no JUnit needed)

fun testPay() {
    val calc = PayCalculator(PayRules(base = 2.0, perMile = 1.0, perMinute = 0.0,
                                      peakBonus = 0.0, minPerDelivery = 0.0))
    val t0 = LocalDateTime.of(2026, 7, 1, 9, 0)
    val d = Delivery("D1", "x", t0, t0.plusMinutes(10), 3.0)
    check(calc.deliveryPay(d) == 5.0) { "expected 5.0, got ${calc.deliveryPay(d)}" }
}

fun testNegativeDistanceRejected() {
    val t0 = LocalDateTime.of(2026, 7, 1, 9, 0)
    try {
        Delivery("D2", "x", t0, t0.plusMinutes(10), -1.0)
        error("should have thrown")
    } catch (e: PayRuleException) { /* expected */ }
}

fun main() {
    testPay(); testNegativeDistanceRejected()
    println("all tests pass")
}

Use check(), not assert() — JVM assertions are disabled unless the runner passes -ea, so assert() can silently pass in HackerRank. Name the edge cases out loud: empty input, zero/negative values, peak-window boundary, unknown ids, all-pods-down.

10 · Gotchas that bite under pressure

7 / 2 == 3                        // Int division! 7.0 / 2 or 7 / 2.0 for 3.5
"a" == "a"                        // structural equality (equals) — the right default
// `===` is identity — almost never what you want (opposite instinct from Java)

val xs = listOf(1, 2, 3)          // read-only VIEW, not deep-immutable
val ms = mutableListOf(1, 2, 3)   // choose intentionally; expose List, keep MutableList private

11..13                            // includes 13
11 until 13                       // excludes 13 — peak windows want `until`
x in 1..maxQty                    // range checks read like the spec

Int.MAX_VALUE / Double.POSITIVE_INFINITY   // sentinels
xs.withIndex()                    // enumerate
a.zip(b)                          // pairwise
buildList { add(1) }              // collect-then-freeze pattern
list.removeIf { it.stale }        // safe removal while "iterating"
Final checklist before you hit run: inputs validated with require()? custom exception exists? happy path demoed in main()? rules/config a separate data class so the follow-up is a one-field change? two check()s? no !! anywhere? Then you're done.