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 focusAI 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 pipelineWhat 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.
One-week plan
| Day | Do |
|---|---|
| 1 | Read the Kotlin brush-up tab end-to-end. Re-type (don't copy) the Dasher Pay solution. |
| 2 | Build the Order/Menu pricing problem from scratch, timed 45 min. Speak out loud. |
| 3 | Rate limiter + in-memory key-value store with TTL, timed. Add unit tests after. |
| 4 | Debugging drills: the buggy snippets in the Debugging tab, then re-do without notes. |
| 5 | Mock the full hour with a friend/AI: 5 min clarify, 45 code, 10 questions. |
| 6 | System 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. |
| 7 | Rest + 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).
1 · Dasher Pay Calculator
Flagship №1OOPvalidationGiven 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."
2 · Resilient Bootstrap API (fan-out & merge)
Flagship №2failure handlingCurrent 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.
3 · AI Code Craft — Workflow Engine
New roundAI-assistedOnsite 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:
Condition→Actionrules, ordered; aWorkflowEngine.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 exhaustivewhen— 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.
4 · Validate a Shopping Cart
Medium freqvalidationImplement 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)
}
}
5 · Menu / Catalog Tree Comparison
DSA roundrecursionTwo 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.
6 · API Rate Limiter
Possible / legacystatefollow-upsBuild 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.)
7 · Order Workflow / State Machine
Possible / legacyenumdesign patternsModel 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.
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.nsmallestwith 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
- Min 0–5: restate the problem, ask 2–3 clarifying questions (input format? malformed data? scale? what to return?). Write requirements as comments.
- Min 5–15: sketch classes (data model → rules/config → service). Get the happy path running.
- Min 15–30: run it with a demo in
main(). Fix, then add validation + custom exceptions. - Min 30–40: take the follow-up ("now add peak bonus") — this is why config/rules are separate objects.
- 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.
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
- The cursor is a local.
var i = 0resets on every call — no rotation, every request hits pod 0. (The reported real bug.) Persist it as a field. - Never wraps.
i += 1walks off the list →IndexOutOfBoundsException. Modulo, and bound the scan to one full cycle so all-pods-down raises instead of spinning. - String typo.
"AVAILABL"never matches — the condition is always false. (Also a reported real bug.) The durable fix is anenum class: the compiler makes this bug impossible. - Uninitialized status map. A pod that never called
reportAvailabilityhas anullstatus — 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
- Unit mismatch:
ageis 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 injava.time.Duration.) - 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. - (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 withcoerceAtLeast(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
| Pattern | Symptom | Fix |
|---|---|---|
Missing map default + !! | NullPointerException | merge(k, 1, Int::plus) / getOrPut / (m[k] ?: 0) + 1 |
String-typed states with a typo ("AVAILABL") | condition never true | enum class — the compiler catches it; grep the literal to prove the bug |
| Unit mismatch (ms vs s, m vs km) | thresholds absurdly strict/loose | suffix variables with units: ageSec, distKm; or use Duration |
| Loop state re-initialized inside the call | round-robin always returns pod 0 | persist the cursor as a field |
| No index wrap | IndexOutOfBoundsException | i = (i + 1) % n, bound the scan |
== vs === | identity compared instead of value | in Kotlin == is structural (calls equals) — the right default; === almost never |
| Mutating a list while iterating | ConcurrentModificationException | iterator.remove(), removeIf, or build a new list with filterNot |
Int division / Double for money | 7 / 2 == 3; off-by-cents | 7.0 / 2 or toDouble(); round at the end, cents as Long, BigDecimal if they push |
| Off-by-one in windows | boundary events missed | decide open/closed (.. vs until), test the boundary |
Swallowed exceptions catch (e: Exception) {} | silent corruption | catch narrow types, log, re-throw; beware runCatching (catches Throwable) |
| Shared mutable state, no lock | race under threads | synchronized, AtomicInteger, or single-writer design |
LocalDateTime for absolute time | pay windows wrong by hours across zones | Instant (UTC) internally, convert to zones at the edges |
assert() in quick tests | silently does nothing | JVM 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 reportedIngest 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.
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)
| Pillar | Values |
|---|---|
| We are leaders | Be an owner · Dream big, start small · Choose optimism and have a plan |
| We are doers | Bias for action · Operate at the lowest level of detail · "And", not "either/or" |
| We are learners | Truth seek · 1% better every day · Customer-obsessed, not competitor-focused |
| We are one team | Make 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
- Recruiter call — background, motivation, logistics.
- Hiring-team interview (~1.5 h) — behavioral, with team leads.
- Take-home assignment — realistic scope, reviewed anonymously on correctness and maintainability.
- 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.
- 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.
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"
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.