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
| Function | Returns | Use when |
let | block result | Null-safe ops on non-null receiver: x?.let { ... } |
also | receiver | Side effects while keeping the chain: list.also { log.info(it) } |
apply | receiver | Configure a new object: Person().apply { name = "Anna" } |
run | block result | Compute on receiver, return something else |
with(x) | block result | Reads 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
| Type | Use for |
LocalDate | Calendar date, no time, no zone (start_date, hiredAt) |
LocalDateTime | โ Date+time WITHOUT zone โ rarely correct. Avoid for stored times. |
ZonedDateTime | Date+time + zone. Use for user-facing scheduling. |
Instant | UTC moment. Use for "when did this happen?" and DB timestamps. |
Period | Date arithmetic ("2 months"). DST-safe via LocalDate. |
Duration | Time arithmetic ("30 minutes"). For Instants. |
ZoneId | ZoneId.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
- O(nยฒ) pairwise is acceptable for small N (typically < 1000 requests/team). Verbalize this.
- 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."