Kotlin Backend Developer Top 100 Interview Questions
100 Kotlin backend developer interview questions with structured answers, comparison tables, flow diagrams, worked calculations and runnable code. Covers null safety and the type system, coroutines and structured concurrency, flows, web services, data access, testing, JVM performance and system design, from the basics to production incidents.
Questions 41-60 of 100
Q41.What are the most common coroutine mistakes in backend code?
Most coroutine bugs come from breaking structured concurrency, mixing blocking and suspending code or mishandling cancellation and exceptions.
Common mistakes:
- Global scope launches: fire-and-forget coroutines that are never cancelled, never awaited and whose failures disappear.
runBlockingin request handlers or inside coroutines, which blocks threads and can deadlock.- Blocking calls on the default dispatcher, such as synchronous database drivers or
Thread.sleep. - Swallowing
CancellationExceptionwith broadcatchblocks orrunCatching, so cancelled work keeps running. - Sequential awaits:
async { a() }.await()followed byasync { b() }.await(), which runs the calls one after the other. - Unbounded concurrency: launching a coroutine per item without limits.
- Thread-local state: values stored in thread locals are lost when a coroutine resumes on another thread; use coroutine context elements instead.
- Creating new dispatchers or thread pools per request, which leaks threads.
How to prevent them: code review checklists, static analysis rules for coroutine misuse, tests that check cancellation and a small set of shared helpers for timeouts, retries and blocking calls.
Interview tip: describing a real bug caused by a swallowed cancellation exception is one of the most convincing ways to show hands-on coroutine experience.
Q42.How do you test coroutine code?
Use the coroutines test utilities, which run suspend code in a test scope with virtual time. Delays complete instantly while the scheduler tracks the passage of time, so tests of timeouts, retries and periodic work run in milliseconds and are deterministic.
Key techniques:
runTest { ... }runs a test body as a coroutine and fails the test if any child coroutine fails or is left running.- Virtual time:
delay(10.minutes)inside the test completes immediately;advanceTimeByandadvanceUntilIdlecontrol the scheduler. - Inject dispatchers: pass dispatchers into classes instead of hard-coding them, so tests can substitute the test dispatcher.
- Inject time sources and clocks for code that reads the current time.
- Test cancellation: cancel the job and assert that cleanup ran and nothing kept working.
- Test flows by collecting into a list, or with helpers that assert emitted values in order.
Common pitfalls:
- Mixing real threads or real delays into tests, which makes them slow and flaky.
- Forgetting to advance time, so a test waits for a timer that never fires.
- Using
runBlockingin tests of time-based code, which waits for real time.
Q43.How do you turn a callback-based API into a suspend function?
Wrap the callback API with
suspendCancellableCoroutine. The block registers callbacks that resume the coroutine with a value or an exception, and aninvokeOnCancellationhandler cancels the underlying operation if the coroutine is cancelled first.Steps:
- Start the operation inside the
suspendCancellableCoroutineblock. - In the success callback, call
resume(value). - In the failure callback, call
resumeWithException(error). - Register
invokeOnCancellation { operation.cancel() }, so cancelled coroutines do not leave work running.
Rules:
- Resume exactly once; callbacks that can fire several times need guards, or a flow instead.
- Handle late callbacks: after cancellation, a callback may still arrive; it must be ignored safely.
- Use the cancellable variant, not the plain one, so structured cancellation works.
For streams of callbacks, such as listeners that fire repeatedly, use
callbackFlow, which registers the listener when collection starts and unregisters it when collection stops.This pattern lets older asynchronous libraries fit cleanly into coroutine code with timeouts and cancellation.
- Start the operation inside the
Q44.How do you implement retries with exponential backoff and jitter in Kotlin?
Loop over attempts, catch only transient failures, wait an exponentially growing delay with random jitter between attempts and rethrow the last failure when attempts run out. In coroutine code, the wait is
delay, which suspends instead of blocking; injecting the wait function makes the logic easy to test.Design points:
- Retry only transient errors, such as timeouts or 503 responses, using a specific exception type or a predicate.
- Exponential delay:
base × 2^(attempt − 1), capped at a maximum. - Jitter: randomize part of each delay, so many instances do not retry at once.
- Never retry cancellation: rethrow
CancellationExceptionimmediately in suspending versions. - Respect the overall deadline: retries must fit inside the caller's timeout.
- Idempotency: retry only operations that are safe to repeat, or use idempotency keys.
Using
Durationfrom the standard library keeps units explicit and avoids mixing milliseconds and seconds.Example
A seeded random generator and an injected sleep make the retry logic testable without waiting.
import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds class TransientException(message: String) : RuntimeException(message) fun <T> retry(attempts: Int, base: Duration, random: Random = Random.Default, sleep: (Duration) -> Unit, block: (Int) -> T): T { var attempt = 1 while (true) { try { return block(attempt) } catch (e: TransientException) { if (attempt >= attempts) throw e val ceiling = base * (1 shl (attempt - 1)) sleep(ceiling / 2 + ceiling / 2 * random.nextDouble()) attempt++ } } } fun main() { val waits = mutableListOf<Duration>() val result = retry(5, 100.milliseconds, Random(42), sleep = { waits += it }) { attempt -> if (attempt < 3) throw TransientException("temporary $attempt") else "ok on attempt $attempt" } println(result) // ok on attempt 3 println(waits[0] in 50.milliseconds..100.milliseconds && waits[1] in 100.milliseconds..200.milliseconds) // true try { retry(3, 10.milliseconds, sleep = {}) { throw TransientException("service down") } } catch (e: TransientException) { println(e.message) // service down } }Q45.How do threads and memory visibility work in Kotlin on the JVM?
Kotlin uses the JVM's threads and memory model. Without synchronization, a change made by one thread may not be visible to another, and compound operations such as
count++are not atomic, so two threads can lose each other's updates.Tools for thread safety:
synchronized(lock) { ... }: mutual exclusion and visibility for a block; keep it short.@Volatile: makes reads and writes of a single field visible across threads, but does not make compound updates atomic.- Atomic types: lock-free counters and references for single values.
- Immutable objects shared after construction, which need no synchronization.
- Concurrent collections from the platform for shared maps and queues.
In coroutine code: prefer
Mutex, atomic values or confinement, becausesynchronizedblocks the thread and cannot contain suspension points.Good practices:
- Document which lock protects which data.
- Avoid holding locks during input and output.
- Acquire several locks in one fixed order.
- Test concurrent code under load, since races rarely appear in simple tests.
Example
Four threads increment a shared counter; the lock keeps every update.
import kotlin.concurrent.thread class Counter { private val lock = Any() private var value = 0 fun increment() = synchronized(lock) { value++ } fun get(): Int = synchronized(lock) { value } } fun main() { val counter = Counter() val workers = List(4) { thread { repeat(10_000) { counter.increment() } } } workers.forEach { it.join() } println(counter.get()) // 40000 }Without the lock, the result would usually be lower, because concurrent increments overwrite each other.
Q46.How does JVM garbage collection affect Kotlin services?
Kotlin services run on the JVM's garbage-collected heap. Allocation is very fast, but collections consume processor time and, depending on the collector, can pause application threads. Heap sizing and allocation habits therefore affect latency and cost.
What to configure:
- Heap size relative to the container: set the maximum heap as a percentage of the container's memory, leaving room for threads, metaspace, direct buffers and native libraries.
- Collector choice: the default general-purpose collector balances throughput and pauses well for most services; low-pause collectors suit large heaps with strict latency targets, at some throughput cost.
- Garbage collection logging, enabled in production, so pauses and frequency can be analyzed.
Reducing pressure from Kotlin code:
- Avoid creating large intermediate collections in hot paths; use sequences or loops.
- Watch boxing of primitive values in generic collections, such as
List<Int>; primitive arrays avoid it. - Value classes avoid wrapper allocations in many cases.
- Be careful with lambdas that capture variables in hot loops, unless the higher-order function is
inline.
Measure first: look at allocation rates, pause times and heap after collection before tuning, and change one setting at a time.
Q47.How do you profile a Kotlin service running on the JVM?
The JVM has rich built-in diagnostics. Start with metrics to locate the problem, then use the right profile: processor, allocation, lock contention or heap contents.
Tools and signals:
- Runtime metrics: processor use, heap and garbage collection, thread counts and pool usage, exported continuously.
- Built-in flight recording: a low-overhead recorder in the JVM that captures processor samples, allocations, locks, input and output and garbage collection events, suitable for production.
- Sampling profilers that produce flame graphs of where processor time goes.
- Thread dumps to find blocked or stuck threads, including coroutine dispatcher threads blocked by mistake.
- Heap dumps to find what holds memory.
- Coroutine debugging: debug probes and agents that list active coroutines and their suspension points.
A typical workflow:
- Reproduce the problem under realistic load.
- Record for a few minutes, then examine the hottest methods, the largest allocators and the longest waits.
- Fix the biggest cost and measure again with the same load.
Remember warm-up: the just-in-time compiler optimizes hot code after it runs for a while, so measure after a warm-up period.
Q48.How do you find a memory leak in a Kotlin service?
Confirm that the heap after garbage collection keeps growing under steady traffic, take heap dumps at two points in time and compare which objects increase and what keeps them reachable.
- Confirm the trend: old-generation usage after collections rising over hours, eventually leading to out-of-memory errors or constant collections.
- Capture heap dumps, ideally automatically when an out-of-memory error occurs, plus a manual one earlier for comparison.
- Compare dominant objects: look at the largest retained sizes and the types whose counts grow.
- Follow paths to roots to find the long-lived structure that holds them.
Common causes in Kotlin services:
- Unbounded caches in
objectsingletons or companion objects. - Collections in long-lived scopes, such as maps of sessions that are never cleaned up.
- Coroutine leaks: coroutines launched in an application scope that never finish, each holding its captured data.
- Listeners and callbacks registered but never removed.
- Large buffers held by flows with big replay caches.
- Thread locals in thread pools, which keep values alive as long as the thread.
After the fix, confirm the heap levels off under the same load, and alert on heap growth after collection.
Q49.How do virtual threads compare with coroutines for Kotlin backend services?
Virtual threads are lightweight threads provided by the JVM: blocking calls on them park the virtual thread instead of blocking an operating system thread. They make blocking-style code scale, much as coroutines make suspending code scale, but the two models offer different features.
Coroutines Virtual threads Suspension is explicit through suspendfunctionsBlocking calls are transparently cheap Structured concurrency, cancellation and timeouts built into the model Cancellation through interruption, structure through newer platform APIs Flows and channels for streams and communication Works with existing blocking libraries unchanged Requires suspending or wrapped APIs end to end Can pin carrier threads in some situations, such as certain native calls Choosing:
- Coroutine-based frameworks and libraries: coroutines give the richest, safest concurrency model in Kotlin.
- Large codebases built on blocking libraries: virtual threads can raise concurrency with few code changes.
- Both together: coroutines can run on a dispatcher backed by virtual threads, which helps when wrapping many blocking calls.
Whichever model you use, limits on connections and downstream calls still apply; cheap threads do not make databases faster.
Q50.How do you measure time and make time-dependent Kotlin code testable?
Use the standard library's
kotlin.timeAPI:Durationfor amounts of time,TimeSourceand time marks for measuring elapsed time and an injected clock or time source wherever code depends on the current time. Tests then control time precisely instead of waiting.Key types:
Duration: typed amounts, such as15.minutes, with arithmetic and readable printing, which prevents unit mix-ups.TimeSource.Monotonic: a clock for measuring elapsed time that never jumps backwards.measureTime { ... }andmeasureTimedValue { ... }for timing blocks.TestTimeSource: a time source tests can move forward manually.
Design guidelines:
- Inject time into classes that check expiry, rate limits or timeouts.
- Use monotonic time for durations and wall-clock time only for timestamps shown to users or stored.
- Store timestamps in UTC, and convert to time zones only for display.
- In coroutine tests, virtual time from the test scheduler controls
delayand timeouts.
Example
A test time source moves forward instantly, so session expiry is checked without waiting.
import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.TestTimeSource import kotlin.time.TimeSource class SessionTracker(private val time: TimeSource, private val idleLimit: Duration) { private var lastSeen = time.markNow() fun touch() { lastSeen = time.markNow() } fun isExpired(): Boolean = lastSeen.elapsedNow() > idleLimit } fun main() { val clock = TestTimeSource() val session = SessionTracker(clock, idleLimit = 15.minutes) clock += 10.minutes println(session.isExpired()) // false session.touch() clock += 16.minutes println(session.isExpired()) // true }Q51.What happens when an HTTP request reaches a Kotlin web service?
The server accepts the connection and hands the request to the application's pipeline. Interceptors or middleware run first, then routing selects a handler, the request body is deserialized into Kotlin types, the handler runs, usually as a coroutine, and the result is serialized into the response.
flowchart LR A["Server accepts request"] --> B["Plugins or middleware: logging, auth, errors"] B --> C["Routing to a handler"] C --> D["Deserialize and validate body"] D --> E["Handler coroutine calls services"] E --> F["Serialize result"] F --> G["Response sent"]What developers should know:
- Handlers run concurrently, so shared services must be safe to use from many coroutines at once.
- Each request has a coroutine scope that is cancelled when the client disconnects or the request times out; pass that context down instead of starting unscoped work.
- Blocking calls inside handlers tie up server threads; use suspending clients or move blocking work to a suitable dispatcher.
- Error handling belongs in one place, mapping exceptions and failure results to consistent responses.
- Serialization settings, such as how unknown fields and nulls are handled, are part of the API contract.
The same flow applies across Kotlin web frameworks; only the names of the components differ.
Q52.How do you design request models and validate input in Kotlin?
Separate the raw request shape from the validated domain command. The request model mirrors the JSON and allows missing or invalid values; a validation step converts it into a command with non-null, checked values, or into a list of errors. Business code only ever sees the validated type.
Why two types:
- The JSON may be missing fields or contain wrong values, so the raw model uses nullable types.
- The domain command uses non-null types and value classes, so invalid data cannot reach the service.
Validation practices:
- Collect every error, not just the first, so clients can fix everything in one round trip.
- Normalize values while validating, such as trimming whitespace and lowercasing emails.
- Return a sealed result of valid or invalid, rather than throwing for expected failures.
- Map invalid results to status 400 or 422 with field-level messages in one error format.
- Keep rules close to the domain, such as quantity limits, so they are reused by every entry point.
Example
A raw request becomes either a validated command or a full list of errors.
data class CreateOrderRequest(val customerId: String?, val quantity: Int?, val email: String?) data class CreateOrder(val customerId: String, val quantity: Int, val email: String) sealed interface Validation { data class Valid(val command: CreateOrder) : Validation data class Invalid(val errors: List<String>) : Validation } private val emailPattern = Regex("""[^@\s]+@[^@\s]+\.[^@\s]+""") fun CreateOrderRequest.validate(): Validation { val errors = mutableListOf<String>() val id = customerId?.trim()?.takeIf { it.isNotEmpty() } ?: run { errors += "customerId is required"; null } val qty = quantity?.takeIf { it in 1..100 } ?: run { errors += "quantity must be between 1 and 100"; null } val mail = email?.trim()?.lowercase()?.takeIf { emailPattern.matches(it) } ?: run { errors += "email is invalid"; null } return if (id != null && qty != null && mail != null) Validation.Valid(CreateOrder(id, qty, mail)) else Validation.Invalid(errors) } fun main() { println(CreateOrderRequest("c-7", 3, " Buyer@Example.com ").validate()) println(CreateOrderRequest(" ", 0, "nope").validate()) } // Output: // Valid(command=CreateOrder(customerId=c-7, quantity=3, email=buyer@example.com)) // Invalid(errors=[customerId is required, quantity must be between 1 and 100, email is invalid])Q53.How do you model expected errors in Kotlin services without exceptions everywhere?
Represent expected business failures as values in a sealed hierarchy, returned alongside success, and keep exceptions for unexpected problems. Callers must handle each outcome, the compiler checks that
whencovers every case and the API layer maps each failure to the right HTTP status.Typical design:
- A sealed error type per area, such as
OrderErrorwithNotFound,OutOfStockandPaymentDeclined, each carrying useful data. - A result type that holds either a value or an error, using the standard
Resultfor exceptions or your own sealedOutcomefor typed errors. - Exhaustive mapping at the boundary from each error case to a status code and an error body.
Benefits:
- Failure cases are visible in function signatures.
- Adding a new error case forces every handler to deal with it.
- Tests check failures as ordinary return values.
Keep exceptions for: infrastructure failures, bugs and situations the caller cannot handle, caught by a central handler that logs them and returns 500.
Avoid: returning
nullfor several different failure reasons, which loses information about what went wrong.Example
Each failure case maps to its own status, and the compiler checks that none is forgotten.
sealed interface OrderError { data class NotFound(val sku: String) : OrderError data class OutOfStock(val sku: String) : OrderError data object PaymentDeclined : OrderError } sealed interface Outcome<out T> { data class Ok<T>(val value: T) : Outcome<T> data class Failed(val error: OrderError) : Outcome<Nothing> } fun placeOrder(sku: String): Outcome<String> = when (sku) { "LAMP" -> Outcome.Ok("ord-1") "DESK" -> Outcome.Failed(OrderError.OutOfStock(sku)) else -> Outcome.Failed(OrderError.NotFound(sku)) } fun toHttp(outcome: Outcome<String>): Pair<Int, String> = when (outcome) { is Outcome.Ok -> 201 to "created ${outcome.value}" is Outcome.Failed -> when (val e = outcome.error) { is OrderError.NotFound -> 404 to "product ${e.sku} not found" is OrderError.OutOfStock -> 409 to "${e.sku} is out of stock" OrderError.PaymentDeclined -> 402 to "payment declined" } } fun main() { listOf("LAMP", "DESK", "SOFA").forEach { val (status, body) = toHttp(placeOrder(it)) println("$status $body") } } // Output: // 201 created ord-1 // 409 DESK is out of stock // 404 product SOFA not found- A sealed error type per area, such as
Q54.How do you handle JSON serialization in Kotlin services?
Kotlin services usually serialize JSON either with a compiler plugin that generates serializers at build time from annotated classes, or with reflection-based libraries from the wider JVM ecosystem plus a Kotlin support module. Both work well when configured to respect Kotlin's null safety and default values.
Compile-time generated serializers Reflection-based libraries with Kotlin support Serializers generated for annotated classes during compilation Classes inspected at run time Respect nullability and default values precisely Need the Kotlin module to understand nullability and defaults No reflection, which suits fast startup and native compilation Very flexible, with a large ecosystem of extensions Best for new Kotlin-first services Best for codebases already built around those libraries Settings that matter for APIs:
- Unknown fields: decide whether to ignore them, which helps compatibility, or reject them, which catches client mistakes.
- Missing fields: non-null properties without defaults should fail deserialization with a clear error.
- Explicit nulls and defaults: decide whether defaults are written to the output.
- Naming strategy: camel case or snake case, applied consistently.
- Polymorphism: sealed hierarchies need a type discriminator field.
Separate API models from domain types, so internal refactoring does not change the JSON contract.
Q55.How do you structure a Kotlin backend project as it grows?
Organize code into modules by business capability, keep the domain free of framework and database code and let a thin application module wire everything together. Kotlin's
internalvisibility lets the compiler enforce these boundaries across modules.flowchart TD A["app module: configuration, routing, wiring"] --> B["orders module: domain and use cases"] A --> C["billing module: domain and use cases"] B --> D["orders infrastructure: database and clients"] C --> E["billing infrastructure: payment gateway client"] B --> F["shared kernel: value classes such as Money"] C --> FPrinciples:
- Feature modules contain their domain model, use cases and the interfaces they need.
- Infrastructure modules implement those interfaces with database and network code.
- The app module maps HTTP requests to use cases and creates the object graph.
internalby default for implementation classes, so other modules depend only on public interfaces.- A small shared kernel for truly common types, kept stable and minimal.
Signs a split is needed: slow builds from one huge module, unrelated features changing the same files and tests that need the whole application to run. Splitting into modules speeds up incremental builds and clarifies ownership.
Q56.How do you do dependency injection in Kotlin?
Pass dependencies through constructors, typed as small interfaces where substitution matters, and create the object graph in one place at startup. Kotlin's concise constructors and default arguments make manual injection pleasant, and dependency injection libraries add automatic wiring for large applications.
Constructor injection in practice:
class OrderService(private val orders: OrderRepository, private val clock: Clock)declares and stores dependencies in one line.fun interfacedeclarations make single-method dependencies, such as clocks or ID generators, easy to supply with a lambda.- Default arguments can provide production implementations while tests pass fakes, though explicit wiring is usually clearer.
Good practices:
- Wire in one composition root, such as the application's startup module.
- Avoid singletons with hidden state declared as
object; inject instances instead. - Avoid service locator calls inside business code.
- Keep constructors cheap, with no network calls, so object creation is fast and predictable.
When a library helps: many modules and services, scoped lifetimes such as per-request objects, or framework integration. Choose one that resolves dependencies at compile time or startup and reports missing bindings clearly.
Example
A service gets its repository and clock through the constructor; a lambda supplies the clock for a predictable result.
data class Order(val id: String, val placedAt: String) interface OrderRepository { fun save(order: Order) fun count(): Int } fun interface Clock { fun now(): String } class OrderService(private val repository: OrderRepository, private val clock: Clock) { fun place(id: String): Order = Order(id, clock.now()).also(repository::save) } class InMemoryOrders : OrderRepository { private val orders = mutableListOf<Order>() override fun save(order: Order) { orders += order } override fun count() = orders.size } fun main() { val repository = InMemoryOrders() val service = OrderService(repository, Clock { "2026-03-01T10:00:00Z" }) println(service.place("ord-1")) // Order(id=ord-1, placedAt=2026-03-01T10:00:00Z) println(repository.count()) // 1 }Q57.How do you access relational databases from Kotlin services?
Choose an approach based on how much control over SQL you need and how complex the domain is. All approaches run on a pooled connection, use parameters for every value and, in coroutine code, either use a non-blocking driver or wrap blocking calls in a dispatcher meant for them.
Hand-written SQL with a driver Type-safe SQL builder or DSL Object-relational mapper Full control of every query Queries written in Kotlin, checked by the compiler Entities mapped to tables automatically Mapping rows to objects by hand Generated or declared table definitions Change tracking and relationships Best for performance-critical queries and reports Best for teams wanting safety without hiding SQL Best for domain-heavy applications Practices for any approach:
- Connection pool sizing matched to the database's limits and the service's concurrency.
- Blocking drivers wrapped in
withContext(Dispatchers.IO), ideally with a parallelism limit equal to the pool size. - Parameters for all values, and allowlists for dynamic column names.
- Explicit transactions around related changes.
- Migrations versioned and applied automatically.
- Integration tests against the real database engine.
Map database rows to domain types in the data access layer, so the rest of the code never handles raw rows.
Q58.How do you run database transactions safely in Kotlin code?
Wrap related operations in a transaction helper that begins the transaction, runs a block, commits if it succeeds and rolls back if anything throws. Higher-order functions make this pattern safe and reusable:
transaction { ... }guarantees cleanup however the block exits.sequenceDiagram participant S as Service participant T as Transaction helper participant D as Database S->>T: transaction with a block T->>D: Begin T->>T: Run the block on the same connection alt Block succeeds T->>D: Commit else Block throws T->>D: Roll back and rethrow end T-->>S: Result or exceptionRules:
- Same connection: every statement in the block must use the transaction's connection, usually passed through the helper's receiver or context.
- Keep transactions short: no network calls or slow work inside.
- Coroutines: a transaction is tied to one connection, so do not start parallel coroutines that use it; keep its work sequential.
- Cancellation: a cancelled coroutine must still roll back; the helper should catch everything, including cancellation, roll back and rethrow.
- Retries: retry the whole block on deadlocks or serialization failures, with idempotent logic.
- Side effects after commit: publish events or send emails only after the commit succeeds, or use an outbox table.
Q59.How do you avoid N+1 queries in Kotlin services?
An N+1 problem happens when code loads a list, then queries again for each item, such as loading each order's customer separately. The number of queries grows with the list, and so does latency.
How to find it:
- Log or count queries per request, and look for repeated queries that differ only by ID.
- Look for repository calls inside
map,forEachor loops. - Watch for lazy relationships in mappers that load data on property access.
Fixes:
- Batch lookups: collect the IDs, load them with one
WHERE id IN (...)query, then associate results by ID withassociateBy. - Joins for small related data needed with every row.
- Projections: select exactly the columns a response needs.
- Data loaders in aggregation layers, which batch requests made during one execution step.
Design repositories for batching: offer
findByIds(ids)alongsidefindById(id), so the efficient path is the obvious one.Example
Looking up customers one by one costs a query per order; a batched lookup costs one query with the same result.
data class Customer(val id: Int, val name: String) data class Order(val id: Int, val customerId: Int) class CustomerRepository(private val rows: Map<Int, Customer>) { var queries = 0 private set fun findById(id: Int): Customer? { queries++ return rows[id] } fun findByIds(ids: Collection<Int>): Map<Int, Customer> { queries++ return ids.mapNotNull(rows::get).associateBy { it.id } } } fun main() { val customers = (1..3).associateWith { Customer(it, "c$it") } val orders = listOf(1, 2, 1, 3, 2, 1).mapIndexed { i, customerId -> Order(i + 1, customerId) } val perItemRepo = CustomerRepository(customers) val perItem = orders.map { perItemRepo.findById(it.customerId)?.name } val batchedRepo = CustomerRepository(customers) val byId = batchedRepo.findByIds(orders.map { it.customerId }.toSet()) val batched = orders.map { byId[it.customerId]?.name } println("per item: ${perItemRepo.queries} queries, batched: ${batchedRepo.queries} query") // per item: 6 queries, batched: 1 query println(perItem == batched) // true }Q60.How do you implement efficient pagination in a Kotlin API?
Use keyset pagination for large or frequently changing data: sort by a unique, indexed key and return a cursor that marks where the next page starts. The database can jump straight to the next page with an index, instead of skipping rows as offset pagination does.
The query shape:
SELECT ... WHERE id < :cursor ORDER BY id DESC LIMIT :limit + 1. Fetching one extra row tells you whether another page exists, without a separate count query.Design details:
- Opaque cursors: return an encoded string rather than raw values, so the format can change without breaking clients.
- Tie-breakers: when sorting by a non-unique field, such as creation time, include the ID too, as in
(created_at, id). - Page size limits: validate the requested size against a maximum.
- Stable filters: a cursor should be used only with the same filters; reject mismatches.
When offset pagination is fine: small tables and admin screens where users jump to page numbers.
Example
Each page continues after the last ID of the previous page, and the cursor disappears when there is nothing left.
data class Job(val id: Int, val title: String) data class Page(val items: List<Job>, val nextCursor: String?) fun page(all: List<Job>, cursor: String?, limit: Int): Page { val beforeId = cursor?.removePrefix("before:")?.toIntOrNull() ?: Int.MAX_VALUE val window = all.asSequence().filter { it.id < beforeId }.sortedByDescending { it.id }.take(limit + 1).toList() val items = window.take(limit) return Page(items, if (window.size > limit) "before:${items.last().id}" else null) } fun main() { val jobs = (1..10).map { Job(it, "job $it") } var cursor: String? = null do { val p = page(jobs, cursor, limit = 4) println("${p.items.map { it.id }} next=${p.nextCursor}") cursor = p.nextCursor } while (cursor != null) } // Output: // [10, 9, 8, 7] next=before:7 // [6, 5, 4, 3] next=before:3 // [2, 1] next=null