Live Pair Coding — 40 min
Komoju is a payments shop. Likely problems:
- Polling / retry / backoff with
FlowandrunTest(very likely — they saw your take-home). - State machine (PENDING → PAID/EXPIRED/FAILED with rules).
- Concurrency tool (debounce, throttle, single-flight, dedup-by-key).
- Small algorithm with a twist (e.g., "build an idempotency-key cache with TTL").
First 60 seconds
"Before I write code: let me clarify the inputs and outputs, sketch the idea in pseudocode, then code, then test. Sound good?"
Always:
- Repeat the problem in your own words.
- Ask about edge cases: empty input, concurrency, cancellation, error.
- Ask about limits: time/space, can I add a library, target Kotlin version.
- Talk while you code. Silence is the killer.
Cheat-sheet — Flow / coroutines patterns
Polling that respects cancellation
fun poll(intervalMs: Long): Flow<Status> = flow {
while (currentCoroutineContext().isActive) {
val s = api.status()
emit(s)
if (s.isTerminal) return@flow
delay(intervalMs)
}
}
Retry with exponential backoff
fun <T> Flow<T>.retryWithBackoff(
maxAttempts: Int = 5,
initial: Long = 1_000L,
factor: Double = 2.0,
max: Long = 30_000L,
): Flow<T> = retryWhen { cause, attempt ->
if (cause is CancellationException || attempt >= maxAttempts) return@retryWhen false
val delayMs = (initial * factor.pow(attempt.toInt())).toLong().coerceAtMost(max)
delay(delayMs)
true
}
Single-flight / dedup-by-key
One in-flight call per key. New callers join the same call.
class SingleFlight<K, V> {
private val mutex = Mutex()
private val inflight = mutableMapOf<K, Deferred<V>>()
suspend fun run(key: K, block: suspend () -> V): V {
val deferred = mutex.withLock {
inflight[key] ?: coroutineScope { async { block() } }.also { inflight[key] = it }
}
return try { deferred.await() } finally { mutex.withLock { inflight.remove(key) } }
}
}
runTest with virtual time
@Test
fun `polling stops on PAID`() = runTest {
val api = FakeApi(scripted = listOf(PENDING, PENDING, PAID))
val emissions = repo.poll().toList()
assertEquals(listOf(PENDING, PENDING, PAID), emissions)
}
StateFlow updates without races
_state.update { current -> current.copy(loading = false, data = data) } // atomic CAS
Per-key mutex (when one global mutex is too coarse)
class KeyedMutex<K> {
private val mutexes = ConcurrentHashMap<K, Mutex>()
suspend fun <V> withLock(key: K, block: suspend () -> V): V {
val m = mutexes.computeIfAbsent(key) { Mutex() }
return m.withLock { block() }
}
}
Debounce with Flow
input.debounce(300.milliseconds).distinctUntilChanged().collect { search(it) }
Throttle (latest wins) with conflate
upstream.conflate().collect { /* skips middle values when slow */ }
Channel-based producer-consumer
val channel = Channel<Job>(Channel.BUFFERED)
launch { for (job in channel) process(job) } // single consumer
launch { repeat(N) { channel.send(makeJob()) } }
Common traps to avoid
- Don't catch
CancellationException— always rethrow. They WILL test this.} catch (e: CancellationException) { throw e } catch (e: Exception) { /* handle */ } - Don't
runBlockinginside a suspend function — show you knowwithContext. - Don't use
GlobalScope— instant red flag. - Avoid
!!— use?.let,requireNotNull("msg"), or a sealed-class state. - Avoid
delayfor sync — useChannel,Flow, orMutex. - Test with
runTest, notrunBlocking— virtual time matters. - Don't hold a mutex across
await()— risk of deadlock if the awaited work needs the same mutex. - Pick the right dispatcher — IO for blocking, Default for CPU. Say you thought about it.
Likely problem variants — quick mental rehearsal
1. "Write a function that polls until terminal, with backoff, given a suspend () -> Status."
This is your repo code, simpler. Be ready to write it from scratch in 10 minutes. Cover: cancellation, terminal stop, backoff cap, mention jitter.
2. "Given two flows, emit only when both have a value, and re-emit on either change."
Answer: combine(flowA, flowB) { a, b -> Pair(a, b) }.
3. "Build a rate limiter: at most N calls per second, queue the rest."
Channel + ticker, or Semaphore(N), or a sliding-window timestamp queue.
class RateLimiter(private val permits: Int, private val perMillis: Long) {
private val timestamps = ArrayDeque<Long>()
private val mutex = Mutex()
suspend fun acquire() = mutex.withLock {
val now = System.currentTimeMillis()
while (timestamps.isNotEmpty() && now - timestamps.first() > perMillis) timestamps.removeFirst()
if (timestamps.size >= permits) {
delay(perMillis - (now - timestamps.first()))
}
timestamps.addLast(System.currentTimeMillis())
}
}
4. "Detect and dedup duplicate events that arrive within X ms."
debounce(X.milliseconds) for last-wins, or a custom seen-set with TTL.
5. "Cache results with TTL, single-flight on miss."
Mix SingleFlight + a TTL map:
class TtlCache<K, V>(private val ttl: Duration, private val source: suspend (K) -> V) {
private data class Entry<V>(val value: V, val expiresAt: Long)
private val map = ConcurrentHashMap<K, Entry<V>>()
private val flight = SingleFlight<K, V>()
suspend fun get(key: K): V {
val cached = map[key]
if (cached != null && System.currentTimeMillis() < cached.expiresAt) return cached.value
return flight.run(key) {
source(key).also { map[key] = Entry(it, System.currentTimeMillis() + ttl.inWholeMilliseconds) }
}
}
}
6. "Build an idempotency-key wrapper for an API call."
Server side: dedup by key, return cached response. Client side: keep the same key across retries.
suspend fun <T> withIdempotencyKey(key: String, block: suspend (key: String) -> T): T {
return block(key) // server stores the response under `key` for some time window
}
7. "Two coroutines append to a list — make it safe."
Mutex().withLock { list.add(x) }- Or
Channelto serialize - Or
MutableStateFlow.update - NOT just
synchronized— show you know coroutine-friendly tools.
8. "Race two API calls and take whichever finishes first; cancel the loser."
suspend fun <T> race(a: suspend () -> T, b: suspend () -> T): T = coroutineScope {
select {
async { a() }.onAwait { it }
async { b() }.onAwait { it }
}.also { coroutineContext.cancelChildren() }
}
If you're stuck
"Let me think out loud. I'm choosing between A and B. A is simpler but doesn't handle X. B handles X but is more code. Which one do you care about more?"
Asking for a tradeoff is not weakness. It's a senior signal.
What they're scoring
- Communication: do you narrate? Do you ask before you assume?
- Correctness: does it work for the obvious test cases?
- Edge cases: empty, single, concurrent, cancelled, error.
- Concurrency: cancellation, dispatcher, structured concurrency.
- Testing: do you write or at least sketch a test?
- Refactor after it works: spot duplication, naming, helpers — and don't over-refactor.