โšก Coding Round ยท Thursday ยท Kotlin

Kotlin Coding Round Prep

60-min CoderPad in Kotlin. Personio-flavored problem (time-off, recurrence, org, etc.). They grade: idiomatic Kotlin ยท clarity > cleverness ยท tests ยท communication while coding.

โฐ
2 days out. Don't relearn Kotlin โ€” drill the patterns below, write the solutions in IntelliJ once, then sleep.

The 60-min Playbook

Don't dive into code at minute 0. They watch the first 5 minutes hardest โ€” it's where seniority shows.

0โ€“4 min

Clarify, don't code

Read the problem out loud, restate it. Ask 2โ€“3 clarifying questions even if "obvious." Inputs, outputs, edge cases, scale. Take notes.

4โ€“8 min

Talk through the approach

"My plan: data class for X, โ€ฆ, complexity O(n log n)." Sketch the function signatures BEFORE bodies. Get a ๐Ÿ‘ before coding.

8โ€“12 min

Write the type signatures + 1 happy-path test

fun findOverlaps(requests: List<TimeOff>): List<Pair<TimeOff, TimeOff>> first. Then 1 test. THIS IS HUGE for them.

12โ€“35 min

Implement

Verbalize as you go. Pause every 5 min to summarize what you have. If stuck for 90s, say so out loud โ€” they often help.

35โ€“45 min

Edge cases & tests

Empty input, single element, overlapping at boundary, null/missing fields, very long inputs. Add 2โ€“3 more tests. Run them.

45โ€“55 min

Refactor pass + complexity

Rename anything cryptic. Extract obvious helpers. Verbalize Big-O. Mention what you'd change for scale.

55โ€“60 min

Their questions + your questions

Expect follow-ups: "what if N is 1M?" "what about timezones?" Have a calm answer. End with: "What would you push back on?"

What they actually grade

1. Clarifying questions

"Are start dates inclusive?"
"Can ranges span midnight?"
"What about cancelled requests?"

2. Idiomatic Kotlin

// Not Java-with-no-semicolons
events.sortedBy { it.ts }
      .windowed(2)
      .filter { (a, b) -> a.overlaps(b) }

3. Tests written

@Test
fun `empty list returns empty`() {
    assertEquals(emptyList<Pair<TimeOff, TimeOff>>(),
                 findOverlaps(emptyList()))
}

4. Naming & structure

data class TimeOff(
    val id: UUID,
    val employeeId: UUID,
    val start: LocalDate,
    val endInclusive: LocalDate
)

Phrases to drop while coding

"I'm going to use a data class here for value-semantics and to get equals/hashCode for free."
"For money I'd use BigDecimal โ€” never Double. Let me set up a Money type."
"This is O(n log n) because of the sort. We could do O(n) with a counting approach but the dates are sparse, so this is cleaner."
"Let me write a test for the boundary case before I trust this."
"In production I'd want this idempotent โ€” let me show how the key would look."
"I'm using sealed class here because the state space is closed โ€” adds exhaustiveness in when."

Kotlin Idioms Cheat Sheet 5-min refresher

Data classes โ€” your default for value types

data class Employee(
    val id: UUID,
    val name: String,
    val managerId: UUID? = null,    // nullable field
    val salary: BigDecimal,
    val hiredAt: LocalDate
)
// Free: equals, hashCode, toString, copy(), destructuring
val raised = emp.copy(salary = emp.salary + BigDecimal("500"))
val (id, name) = emp  // destructuring

Null safety โ€” the senior moves

// Safe call + Elvis = the pair you use 90% of the time
val mgrName = emp.manager?.name ?: "(no manager)"

// let โ€” run a block only if non-null
emp.managerId?.let { findEmployee(it) }?.let { sendEmail(it) }

// requireNotNull โ€” fail fast with message
val mgr = requireNotNull(emp.manager) { "${emp.id} has no manager" }

// Avoid !! unless you've already validated

Collections โ€” the fluent style

employees
    .filter { it.salary > threshold }
    .groupBy { it.department }
    .mapValues { (_, list) -> list.sumOf { it.salary } }

// associateBy โ€” index by key
val byId: Map<UUID, Employee> = employees.associateBy { it.id }

// partition โ€” split into matching/not
val (active, terminated) = employees.partition { it.endDate == null }

// windowed โ€” sliding pairs
sortedEvents.zipWithNext { a, b -> b.ts - a.ts }

// fold โ€” reduce with seed
amounts.fold(BigDecimal.ZERO) { acc, x -> acc + x }

Sealed classes for closed state spaces

sealed class RequestStatus {
    data object Draft : RequestStatus()
    data object Submitted : RequestStatus()
    data class Approved(val by: UUID, val at: Instant) : RequestStatus()
    data class Rejected(val reason: String) : RequestStatus()
}

// when is EXHAUSTIVE โ€” compiler enforces all branches
val label = when (status) {
    RequestStatus.Draft -> "draft"
    RequestStatus.Submitted -> "awaiting"
    is RequestStatus.Approved -> "approved by ${status.by}"
    is RequestStatus.Rejected -> "rejected: ${status.reason}"
}

Scope functions โ€” the right one for the job

FunctionReturnsUse when
letblock resultNull-safe ops on non-null receiver: x?.let { ... }
alsoreceiverSide effects while keeping the chain: list.also { log.info(it) }
applyreceiverConfigure a new object: Person().apply { name = "Anna" }
runblock resultCompute on receiver, return something else
with(x)block resultReads better when receiver isn't the obvious subject

Money โ€” always BigDecimal, always from String

import java.math.BigDecimal
import java.math.RoundingMode

// CORRECT
val salary = BigDecimal("4825.50")
val taxRate = BigDecimal("0.215")
val tax = salary.multiply(taxRate)
    .setScale(2, RoundingMode.HALF_EVEN)

// WRONG โ€” picks up float imprecision
val bad = BigDecimal(0.215)  // โ†’ 0.2149999...

// Wrap as Money for type safety
data class Money(val amount: BigDecimal, val currency: String) {
    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "currency mismatch" }
        return copy(amount = amount + other.amount)
    }
    operator fun times(scalar: BigDecimal): Money =
        copy(amount = (amount * scalar).setScale(2, RoundingMode.HALF_EVEN))
}

java.time โ€” what to reach for

TypeUse for
LocalDateCalendar date, no time, no zone (start_date, hiredAt)
LocalDateTimeโš  Date+time WITHOUT zone โ€” rarely correct. Avoid for stored times.
ZonedDateTimeDate+time + zone. Use for user-facing scheduling.
InstantUTC moment. Use for "when did this happen?" and DB timestamps.
PeriodDate arithmetic ("2 months"). DST-safe via LocalDate.
DurationTime arithmetic ("30 minutes"). For Instants.
ZoneIdZoneId.of("Europe/Berlin")
// Convert wall time + zone โ†’ UTC Instant
val wall = LocalDateTime.of(LocalDate.parse("2026-05-28"),
                            LocalTime.of(14, 5))
val zone = ZoneId.of("Europe/Berlin")
val instant: Instant = wall.atZone(zone).toInstant()

// Date range iteration
val days = LocalDate.parse("2026-01-01")..LocalDate.parse("2026-01-07")
// Note: LocalDate.. needs Kotlin 1.7+ + import; if not available:
DateTimeRange(start, end).forEach { ... }

Testing โ€” JUnit 5 idioms

import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class TimeOffOverlapTest {

    @Test
    fun `empty list has no overlaps`() {
        assertEquals(emptyList<Pair<TimeOff, TimeOff>>(),
                     findOverlaps(emptyList()))
    }

    @Test
    fun `adjacent ranges do not overlap`() {
        val a = timeOff("alice", "2026-01-05", "2026-01-10")
        val b = timeOff("bob", "2026-01-11", "2026-01-15")
        assertTrue(findOverlaps(listOf(a, b)).isEmpty())
    }

    @Test
    fun `single-day overlap on boundary is detected`() {
        val a = timeOff("alice", "2026-01-05", "2026-01-10")
        val b = timeOff("bob", "2026-01-10", "2026-01-15")
        assertEquals(1, findOverlaps(listOf(a, b)).size)
    }

    private fun timeOff(name: String, s: String, e: String) =
        TimeOff(UUID.randomUUID(), name,
                LocalDate.parse(s), LocalDate.parse(e))
}
Test naming convention: Kotlin lets you use backticks for test names with spaces: fun `empty list has no overlaps`(). This is the idiomatic style. Use it โ€” it signals "I know Kotlin."

Pitfalls that lose points

  • Using var everywhere. Default to val. Mutation should be a deliberate choice.
  • Forgetting null safety. If something can be null, the type must say so. No defensive if (x != null) blocks where the type was already nullable.
  • Using Double for money. Instant fail.
  • Java-style getters. Don't write fun getName() = name. Just expose val name.
  • Wrapping everything in classes. Top-level functions are fine in Kotlin. Don't make a TimeOffOverlapDetector class with one method.
  • Not using data class. Forgetting data means no equals/hashCode โ†’ broken tests.
  • Mutable collections by accident. List is immutable. MutableList is the explicit opt-in.
  • Catching Exception broadly. Use sealed result types or specific catches.

Likely Problems Kotlin solutions

These are the patterns I'd bet on. Solve each in IntelliJ tonight; you'll recognize the shape on Thursday.

1
Time-off conflict detection
โ˜… Top likely Medium Kotlin

Problem

Given a list of approved time-off requests for a team, find all overlapping pairs. Bonus: detect when more than N people are off on the same day.

Approach

  1. O(nยฒ) pairwise is acceptable for small N (typically < 1000 requests/team). Verbalize this.
  2. For large N: sweep line. Sort by start, walk through events.

Solution (sweep line)

import java.time.LocalDate
import java.util.UUID

data class TimeOff(
    val id: UUID,
    val employee: String,
    val start: LocalDate,
    val endInclusive: LocalDate
) {
    fun overlaps(other: TimeOff): Boolean =
        start <= other.endInclusive && other.start <= endInclusive
}

fun findOverlaps(requests: List<TimeOff>): List<Pair<TimeOff, TimeOff>> {
    val sorted = requests.sortedBy { it.start }
    val overlaps = mutableListOf<Pair<TimeOff, TimeOff>>()
    val active = mutableListOf<TimeOff>()

    for (req in sorted) {
        active.removeAll { it.endInclusive < req.start }
        for (a in active) overlaps += a to req
        active += req
    }
    return overlaps
}

Follow-up: capacity rule

fun violatesCapacity(
    requests: List<TimeOff>,
    maxConcurrent: Int
): List<LocalDate> {
    val deltas = sortedMapOf<LocalDate, Int>()
    requests.forEach {
        deltas[it.start] = (deltas[it.start] ?: 0) + 1
        deltas[it.endInclusive.plusDays(1)] =
            (deltas[it.endInclusive.plusDays(1)] ?: 0) - 1
    }
    var active = 0
    return deltas.mapNotNull { (day, delta) ->
        active += delta
        if (active > maxConcurrent) day else null
    }
}

Things to verbalize

  • "start <= other.endInclusive && other.start <= endInclusive โ€” the classic two-condition overlap check."
  • "I'm using endInclusive in the name because half-open ranges confuse HR-domain users."
  • "O(nยฒ) worst case for the result but the sort makes the active-list small in practice."
2
Recurrence expansion (matches take-home)
โ˜… Top likely Medium Kotlin

Problem

Given a recurrence rule (DAILY / WEEKLY / MONTHLY / YEARLY + interval) and a start date, return all occurrences within a window.

Solution (sealed class for type safety)

import java.time.LocalDate
import java.time.Period

sealed class RecurrenceRule {
    abstract val interval: Int
    abstract fun step(): Period

    data class Daily(override val interval: Int = 1) : RecurrenceRule() {
        override fun step(): Period = Period.ofDays(interval)
    }
    data class Weekly(override val interval: Int = 1) : RecurrenceRule() {
        override fun step(): Period = Period.ofWeeks(interval)
    }
    data class Monthly(override val interval: Int = 1) : RecurrenceRule() {
        override fun step(): Period = Period.ofMonths(interval)
    }
    data class Yearly(override val interval: Int = 1) : RecurrenceRule() {
        override fun step(): Period = Period.ofYears(interval)
    }
}

fun expand(
    start: LocalDate,
    rule: RecurrenceRule,
    windowEnd: LocalDate
): Sequence<LocalDate> = sequence {
    val step = rule.step()
    var cur = start
    while (!cur.isAfter(windowEnd)) {
        yield(cur)
        cur = cur.plus(step)
    }
}

// Usage
val dates = expand(
    start = LocalDate.parse("2026-01-31"),
    rule = RecurrenceRule.Monthly(),
    windowEnd = LocalDate.parse("2026-06-30")
).toList()
// โ†’ [Jan 31, Feb 28, Mar 31, Apr 30, May 31, Jun 30]
//   (java.time handles month-end correctly!)

What to call out

  • Sealed class + interval as val โ€” exhaustiveness on when, easy to add SECONDLY/HOURLY later.
  • Sequence not List โ€” lazy, doesn't materialize all occurrences if caller only takes a few.
  • java.time.Period handles month-end correctly โ€” Jan 31 + 1 month = Feb 28 (or 29). Don't reimplement.
  • Unbounded protection: window is required. Never return an infinite generator.

If they push: "support iCalendar RRULE"

"I'd adopt RFC 5545 RRULE strings โ€” there are battle-tested expanders. For this exercise, I'm keeping the sealed class which covers the same surface."
3
Org chart traversal
Likely Medium Kotlin

Problem

Employees have a managerId. Implement chainOfCommand, allReports, commonManager (LCA).

data class Employee(val id: String, val name: String, val managerId: String?)

class OrgChart(employees: List<Employee>) {
    private val byId: Map<String, Employee> = employees.associateBy { it.id }
    private val directReports: Map<String, List<Employee>> =
        employees.filter { it.managerId != null }
                 .groupBy { it.managerId!! }

    fun chainOfCommand(empId: String): List<Employee> {
        val chain = mutableListOf<Employee>()
        var cur = byId[empId] ?: return emptyList()
        while (cur.managerId != null) {
            cur = byId[cur.managerId] ?: break
            chain += cur
        }
        return chain
    }

    fun allReports(empId: String): List<Employee> {
        val result = mutableListOf<Employee>()
        val queue = ArrayDeque(directReports[empId].orEmpty())
        while (queue.isNotEmpty()) {
            val e = queue.removeFirst()
            result += e
            queue += directReports[e.id].orEmpty()
        }
        return result
    }

    fun commonManager(a: String, b: String): Employee? {
        val chainA = (listOf(byId[a]!!) + chainOfCommand(a))
                         .map { it.id }.toSet()
        var cur: Employee? = byId[b]
        while (cur != null) {
            if (cur.id in chainA) return cur
            cur = cur.managerId?.let { byId[it] }
        }
        return null
    }
}

Senior touches

  • Precompute directReports once in the constructor โ€” saves repeated O(n) scans.
  • Use ArrayDeque for BFS, not LinkedList.
  • Cycle protection: if they push, add a visited: MutableSet in chainOfCommand.
  • For DB-backed: mention WITH RECURSIVE CTE in Postgres.
4
RBAC permission resolver
Likely Medium Kotlin

Problem

Users have roles. Roles have permissions. Roles inherit. Implement can(user, action, resource).

data class Permission(val action: String, val resource: String) {
    fun matches(action: String, resource: String) =
        (this.action == action || this.action == "*") &&
        (this.resource == resource || this.resource == "*")
}

class RBAC(
    private val userRoles: Map<String, Set<String>>,
    private val roleParents: Map<String, Set<String>>,
    private val rolePerms: Map<String, Set<Permission>>
) {
    private fun effectiveRoles(user: String): Set<String> {
        val visited = mutableSetOf<String>()
        val queue = ArrayDeque(userRoles[user].orEmpty())
        while (queue.isNotEmpty()) {
            val r = queue.removeFirst()
            if (r in visited) continue
            visited += r
            queue += roleParents[r].orEmpty()
        }
        return visited
    }

    fun effectivePermissions(user: String): Set<Permission> =
        effectiveRoles(user).flatMap { rolePerms[it].orEmpty() }.toSet()

    fun can(user: String, action: String, resource: String): Boolean =
        effectivePermissions(user).any { it.matches(action, resource) }
}

Follow-ups

  • Negative permissions: add data class Deny(...) sealed alongside Permission. Walk both; deny wins.
  • Caching: memoize effectivePermissions(user) โ€” invalidate on role change.
  • Query-time filter: for list endpoints, push the perm filter into SQL via WHERE โ€ฆ AND user_perms @> required.
5
Money & BigDecimal (currency-safe)
โ˜… Sneaky important Concept Kotlin

Why this matters

Personio is payroll. If any money problem comes up, the first thing they look for is whether you reach for BigDecimal. Using Double = senior red flag.

Solution

import java.math.BigDecimal
import java.math.RoundingMode

data class Money(
    val amount: BigDecimal,
    val currency: String
) {
    init {
        require(amount.scale() <= 2) { "max 2 decimals; got $amount" }
    }

    operator fun plus(other: Money): Money {
        require(currency == other.currency) {
            "Cannot add $currency + ${other.currency}"
        }
        return copy(amount = amount + other.amount)
    }

    operator fun minus(other: Money): Money {
        require(currency == other.currency)
        return copy(amount = amount - other.amount)
    }

    operator fun times(scalar: BigDecimal): Money =
        copy(amount = (amount * scalar).setScale(2, RoundingMode.HALF_EVEN))

    companion object {
        fun of(amount: String, currency: String) =
            Money(BigDecimal(amount).setScale(2, RoundingMode.HALF_EVEN),
                  currency)
    }
}

// Usage
val salary = Money.of("4825.50", "EUR")
val tax = salary * BigDecimal("0.215")
val net = salary - tax

Drop these one-liners into the conversation

"I'm wrapping money in a Money data class โ€” currency mismatches become compile errors when used right."
"BigDecimal(0.215) picks up float imprecision. Always construct from string."
"I'm using HALF_EVEN โ€” Banker's rounding. It's the right default for tax to avoid systematic bias upward."
"Quantize at the LAST step, not intermediates โ€” keeps precision through the chain."
6
Approval workflow state machine
Likely Medium Kotlin

Solution (sealed classes = compile-time safety)

sealed class State {
    data object Draft : State()
    data object Submitted : State()
    data class   ManagerApproved(val by: String) : State()
    data class   HrApproved(val by: String) : State()
    data object Done : State()
    data class   Rejected(val reason: String) : State()
    data object Cancelled : State()
}

sealed class Action {
    data class Submit(val by: String) : Action()
    data class ManagerApprove(val by: String) : Action()
    data class HrApprove(val by: String) : Action()
    data object Finalize : Action()
    data class Reject(val reason: String) : Action()
    data object Cancel : Action()
}

class InvalidTransition(state: State, action: Action) :
    IllegalStateException("$action not allowed in $state")

fun State.transition(action: Action): State = when (this) {
    is State.Draft -> when (action) {
        is Action.Submit -> State.Submitted
        is Action.Cancel -> State.Cancelled
        else -> throw InvalidTransition(this, action)
    }
    is State.Submitted -> when (action) {
        is Action.ManagerApprove -> State.ManagerApproved(action.by)
        is Action.Reject          -> State.Rejected(action.reason)
        is Action.Cancel          -> State.Cancelled
        else -> throw InvalidTransition(this, action)
    }
    is State.ManagerApproved -> when (action) {
        is Action.HrApprove -> State.HrApproved(action.by)
        is Action.Reject    -> State.Rejected(action.reason)
        is Action.Cancel    -> State.Cancelled
        else -> throw InvalidTransition(this, action)
    }
    is State.HrApproved -> when (action) {
        is Action.Finalize -> State.Done
        is Action.Cancel   -> State.Cancelled
        else -> throw InvalidTransition(this, action)
    }
    is State.Done, is State.Rejected, is State.Cancelled ->
        throw InvalidTransition(this, action)
}

Senior touches to mention

  • Sealed for both State and Action โ€” closed sets, exhaustive when.
  • Carry data on transitions (the approver's id) โ€” captures audit info in the type.
  • Extension function on State โ€” reads naturally: request.state.transition(action).
  • Authorization separate from transition validity โ€” manager_approve is "valid action from Submitted" regardless of who tries; you check actor permission in a wrapping layer.
7
Reminder dashboard query (mini-take-home)
Likely Medium Kotlin

Problem

Given a list of reminders + their per-day "done" status, return the set of reminders that should appear on a given employee's dashboard today: scheduled for today OR scheduled for any past day where they're not yet marked done.

import java.time.LocalDate

data class Reminder(
    val id: String,
    val employeeId: String,
    val text: String,
    val scheduledDate: LocalDate
)

data class Occurrence(
    val reminderId: String,
    val occursOn: LocalDate,
    val doneAt: java.time.Instant? = null
)

fun dashboardFor(
    employeeId: String,
    reminders: List<Reminder>,
    occurrences: List<Occurrence>,
    today: LocalDate
): List<Occurrence> {
    val remByEmp = reminders
        .filter { it.employeeId == employeeId }
        .associateBy { it.id }
    return occurrences
        .filter { it.reminderId in remByEmp }
        .filter { it.doneAt == null }
        .filter { !it.occursOn.isAfter(today) }
        .sortedBy { it.occursOn }
}

What to talk about

  • Pure function โ€” no I/O. Easy to test.
  • Sorted output โ€” UI gets stable order.
  • Filter chain reads top-to-bottom like the spec.
  • For scale: "I'd push this into SQL โ€” WHERE employee_id = ? AND occurs_on <= today AND done_at IS NULL ORDER BY occurs_on."

Bonus: writing the markDone function (idempotency-aware)

fun markDone(
    occurrences: MutableList<Occurrence>,
    reminderId: String,
    occursOn: LocalDate,
    now: java.time.Instant
): Occurrence {
    val idx = occurrences.indexOfFirst {
        it.reminderId == reminderId && it.occursOn == occursOn
    }
    require(idx >= 0) { "no such occurrence" }
    val existing = occurrences[idx]
    return if (existing.doneAt != null) {
        existing  // idempotent: already done, no-op
    } else {
        val updated = existing.copy(doneAt = now)
        occurrences[idx] = updated
        updated
    }
}
Show idempotency without being asked: "Calling markDone twice with the same args returns the same state โ€” no exception, no double-write. That's important for retries from a flaky network."

Final 24-Hour Plan

Wednesday (after HM round)

  • Skim this whole page in 30 min.
  • Open IntelliJ. Type out solutions 1, 2, 5, 6 from memory. Don't copy-paste.
  • Write 2 tests per problem in JUnit 5.
  • Re-read the "what to verbalize" callouts. Say them out loud.

Thursday morning

  • Light review only. Don't write new code.
  • Re-read the 60-min playbook timing breakdown.
  • Hydrate. Eat something. Be at the desk 15 min early to set up CoderPad.
  • Have a notebook for clarifying questions.

If you blank during the interview

  1. Say "Let me think for a second" out loud. Pause is fine.
  2. Restate the problem in your own words. Often unlocks the approach.
  3. Write the function signature first. Type system narrows the solution.
  4. Start with the dumbest correct approach. Optimize later.
  5. Ask: "Is it OK if I sketch a brute-force solution first and then improve?"

Closing moves

"What would you have pushed back on if I'd written this in a PR?"
"If I had another hour, I'd add X, Y, Z."
"I'm curious โ€” what does this look like in the actual Personio codebase?"