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 61-80 of 100
Q61.How do you cache data in a Kotlin service?
Cache data that is read often, changes rarely and is expensive to compute or fetch. Use a bounded in-process cache for per-instance speed and a shared cache when every instance must see the same values, and always define expiry and size limits.
Design points:
- Bounded size with an eviction policy, such as least recently used, so memory cannot grow without limit.
- Expiry that matches how stale the data may be.
- Concurrent loading: when many coroutines miss the same key, let one load it while the others wait for the same result, for example by caching a deferred value.
- Keys that include every input affecting the value, such as tenant and language.
- Invalidation when the source changes, or short lifetimes when change events are not available.
- Metrics: hit rate, size and load time.
A simple in-process LRU can be built on a linked hash map in access order, which moves an entry to the end whenever it is read and lets you remove the eldest entry when the size limit is exceeded. It is not thread-safe, so guard it with a lock or use a concurrent cache library in production.
Example
A linked hash map in access order evicts the least recently used entry.
class LruCache<K, V>(private val capacity: Int) : LinkedHashMap<K, V>(16, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry<K, V>?): Boolean = size > capacity } fun main() { val cache = LruCache<String, Int>(2) cache["a"] = 1 cache["b"] = 2 cache["a"] cache["c"] = 3 println(cache.keys) // [a, c] println(cache["b"]) // null }Q62.How do you secure a Kotlin web API?
Layer defenses: authenticate every request, authorize every action on every resource, validate input strictly, protect secrets and keep dependencies patched. Frameworks provide the building blocks, but secure defaults must be configured deliberately.
Authentication and authorization:
- Validate tokens fully: signature, issuer, audience and expiry.
- Require authentication by default, and mark public routes explicitly.
- Check ownership in handlers that load resources by ID, so users cannot read each other's data by changing an ID.
- Use role or permission checks expressed as small, testable functions.
Input and output:
- Validate request models into typed commands, reject unknown fields where appropriate and limit body sizes.
- Use parameterized queries and allowlists for dynamic sorting.
- Return consistent errors without stack traces or internal details.
Transport and platform:
- HTTPS everywhere, strict CORS rules and security headers.
- Rate limits on login and expensive endpoints.
Secrets and dependencies:
- Secrets from the environment or a secret store, never in code or logs.
- Dependency verification and vulnerability scanning in the build, and regular updates of the JVM and libraries.
Q63.How do you load and validate configuration in a Kotlin service?
Read configuration from the environment and configuration files at startup, convert values to typed fields, validate them all and fail fast with every problem listed. The rest of the code receives an immutable configuration data class, never raw strings.
Practices:
- One typed data class with
Duration,Int,Booleanand value class fields, not strings. - Defaults for optional settings and clear errors for required ones.
- Collect all errors before failing, so operators fix everything in one deployment.
- Testable loading: pass the environment as a map into the loader instead of reading global state.
- Secrets from the environment or a secret store, never committed, and never printed when logging the configuration.
- Separate configuration per environment through values, not through different code paths.
Using
Durationfor timeouts avoids the classic mistake of mixing milliseconds and seconds.Example
Valid settings produce a typed configuration; invalid ones fail with every problem listed at once.
import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds data class AppConfig(val port: Int, val dbUrl: String, val requestTimeout: Duration, val debug: Boolean) fun loadConfig(env: Map<String, String>): AppConfig { val errors = mutableListOf<String>() fun <T : Any> read(name: String, default: T?, parse: (String) -> T?): T? { val raw = env[name] ?: return default.also { if (it == null) errors += "$name is required" } return parse(raw).also { if (it == null) errors += "$name is invalid: '$raw'" } } val port = read("PORT", 8080) { it.toIntOrNull()?.takeIf { p -> p in 1..65535 } } val dbUrl = read<String>("DB_URL", null) { it.takeIf(String::isNotBlank) } val timeout = read("REQUEST_TIMEOUT", 5.seconds) { Duration.parseOrNull(it) } val debug = read("DEBUG", false) { it.toBooleanStrictOrNull() } require(errors.isEmpty()) { errors.joinToString("; ") } return AppConfig(port!!, dbUrl!!, timeout!!, debug!!) } fun main() { println(loadConfig(mapOf("PORT" to "9000", "DB_URL" to "db.internal/shop", "REQUEST_TIMEOUT" to "2s"))) try { loadConfig(mapOf("PORT" to "99999", "REQUEST_TIMEOUT" to "soon")) } catch (e: IllegalArgumentException) { println(e.message) } } // Output: // AppConfig(port=9000, dbUrl=db.internal/shop, requestTimeout=2s, debug=false) // PORT is invalid: '99999'; DB_URL is required; REQUEST_TIMEOUT is invalid: 'soon'The
!!operators at the end are safe because any missing value would already have failed therequirecheck.- One typed data class with
Q64.How should a Kotlin service log and trace requests?
Write structured logs, such as JSON with named fields, through a logging facade, and add distributed tracing so a request can be followed across services. With coroutines, request context must travel in the coroutine context rather than in thread-local storage, because a coroutine can resume on a different thread.
Logging practices:
- Structured fields, such as
orderIdanddurationMs, rather than values embedded in message text. - A request ID and trace ID on every log line, added by middleware.
- Context propagation with coroutines: use a context element that copies diagnostic context into each thread a coroutine runs on, or the framework's built-in support.
- Lazy messages: pass lambdas for expensive debug messages, so the string is built only when the level is enabled.
- No secrets or sensitive personal data in logs.
- Log exceptions once, at the boundary, with the stack trace.
Tracing practices:
- Create spans for incoming requests, database calls and outgoing HTTP calls.
- Propagate trace headers to downstream services.
- Make sure spans follow coroutines across thread switches, using the tracing library's coroutine integration.
- Structured fields, such as
Q65.How do you write unit tests for Kotlin backend code?
Test behavior through public functions with fast, isolated tests that use fakes for external systems. Kotlin's test libraries and the multiplatform test module provide assertions, while expressive test names and data classes make tests easy to read.
Structure:
- Arrange: build the object with fakes and inputs.
- Act: call one function.
- Assert: compare results, often whole data classes, which gives clear failure messages.
Kotlin-specific tips:
- Readable names: backtick-quoted names, such as
`rejects refunds after 30 days`, read like specifications. - Data class comparisons check every field at once.
- Sealed results are easy to assert with
whenor type checks. - Coroutine code runs in
runTestwith virtual time. - Parameterized tests cover many inputs for the same rule.
Good habits:
- Inject clocks, ID generators and random sources, so tests are deterministic.
- Test failure paths, including cancellation and timeouts.
- Keep unit tests free of real databases and networks; cover those with integration tests against real engines in containers.
- Avoid mocking data classes or value types; build real instances.
Q66.How do you replace dependencies with fakes in Kotlin tests?
Define small interfaces for external dependencies, then write simple fake classes in tests that record calls and return chosen results. Kotlin makes fakes short: an interface with one method can be implemented by a lambda, and data classes make recorded calls easy to compare.
Kinds of test doubles:
- Fakes: working in-memory implementations, such as a repository backed by a map.
- Stubs: fixed answers or failures, to drive specific paths.
- Spies: record calls for later checks.
Guidelines:
- Fake what you do not own: payment gateways, email services, clocks and remote APIs.
- Check outcomes rather than every interaction, so refactoring does not break tests.
- Share fakes across tests to keep them consistent.
- Mocking libraries are useful for large interfaces and for final classes, but hand-written fakes are often clearer.
- Run contract tests against both the fake and the real implementation, so they behave the same.
Example
A configurable fake gateway drives both the success and the decline path, and records what was charged.
interface PaymentGateway { fun charge(orderId: String, amountMinor: Long): Boolean } class FakeGateway(private val declineAbove: Long) : PaymentGateway { val charges = mutableListOf<String>() override fun charge(orderId: String, amountMinor: Long): Boolean { if (amountMinor > declineAbove) return false charges += "$orderId:$amountMinor" return true } } class Checkout(private val payments: PaymentGateway) { fun pay(orderId: String, amountMinor: Long): String = if (payments.charge(orderId, amountMinor)) "paid" else "declined" } fun main() { val gateway = FakeGateway(declineAbove = 100_000) val checkout = Checkout(gateway) println(checkout.pay("ord-1", 45_000)) // paid println(checkout.pay("ord-2", 250_000)) // declined println(gateway.charges) // [ord-1:45000] }Q67.How do you test HTTP endpoints in a Kotlin service?
Most Kotlin web frameworks provide an in-memory test host: the application starts with test configuration, and tests send requests through a client without opening real network ports. The whole pipeline runs, including routing, serialization, validation, authentication and error handling.
Setting it up:
- Start the application module with test settings and fake external dependencies.
- Send requests with the test client, including headers and JSON bodies.
- Assert on status codes, headers and decoded response bodies, comparing to data classes.
What to cover:
- Success paths for each endpoint.
- Validation errors with field messages.
- Authentication failures, missing permissions and access to other users' resources.
- Error mapping, such as 404 and 409 for domain failures, and a generic 500 without internal details.
- Serialization details, such as naming, null handling and unknown fields.
Database-backed tests: run them against the real database engine in a container, and reset data between tests.
Keep them fast: share one application instance per test class where possible, and run independent tests in parallel.
Q68.How do you handle dates, times and time zones in Kotlin services?
Store and exchange instants in UTC, use the platform's modern immutable date and time types and convert to a user's zone only for display. Keep calendar dates, such as birthdays, separate from instants, such as when an order was placed.
Types to use for each purpose:
- Instants for timestamps in databases, logs and APIs.
- Local dates for calendar dates without a time.
- Zoned date-times when local rules matter, such as scheduling a meeting in a specific city.
Durationfrom the Kotlin standard library for amounts of time in code, such as timeouts.
Good practices:
- Inject a clock, so code reading "now" is testable.
- Use standard date-time strings in APIs, such as
2026-03-01T09:30:00Z. - Convert time zones by zone ID, which handles daylight saving rules, not by fixed offsets.
- Be careful with "add one day" across daylight saving changes: adding 24 hours and adding a calendar day can differ.
- Multiplatform code can use a date-time library that works on every Kotlin target.
Recurring local events, such as "every day at 9 in the user's zone", should be stored as a local time plus a zone, and converted to instants when each occurrence is scheduled.
Q69.How do you represent money and precise amounts in Kotlin?
Never use
Doublefor money: binary floating point cannot represent most decimal fractions exactly, so errors accumulate. Use integer amounts in the smallest currency unit, wrapped in a value class, or the platform's arbitrary-precision decimal type with explicit rounding.A money type should:
- Store minor units as a
Long, such as paise or cents, with the currency. - Refuse to mix currencies in arithmetic.
- Round explicitly, in one place, for operations such as tax and discounts.
- Allocate remainders when splitting, so the parts always add up to the total.
- Format only for display, with the right locale.
Storage and APIs: store integers or exact decimal columns in the database; in JSON, send minor units as integers or decimal amounts as strings, so clients do not lose precision.
Value classes give a distinct type for money with almost no runtime cost, and operator functions make arithmetic read naturally.
Example
Integer minor units give exact totals, explicit rounding for tax and splits that always add up.
@JvmInline value class Paise(val value: Long) { operator fun plus(other: Paise) = Paise(value + other.value) operator fun times(quantity: Int) = Paise(value * quantity) fun percentOf(basisPoints: Int) = Paise((value * basisPoints + 5_000) / 10_000) override fun toString() = "₹${value / 100}.${(value % 100).toString().padStart(2, '0')}" } fun split(total: Paise, parts: Int): List<Paise> { val base = total.value / parts val remainder = total.value % parts return List(parts) { i -> Paise(base + if (i < remainder) 1 else 0) } } fun main() { println(0.1 + 0.2 == 0.3) // false val subtotal = Paise(1999) * 3 val tax = subtotal.percentOf(1800) println("$subtotal + $tax = ${subtotal + tax}") // ₹59.97 + ₹10.79 = ₹70.76 println(split(Paise(1000), 3)) // [₹3.34, ₹3.33, ₹3.33] }- Store minor units as a
Q70.How do you build and package a Kotlin service for deployment?
Build the service with a reproducible build, produce a self-contained artifact, such as an executable archive with all dependencies or a distribution folder, and package it into a container image that layers dependencies separately from application code.
Build practices:
- Pin the Kotlin version, the JVM version and dependency versions, and enable dependency verification or lock files.
- Use build caching and incremental compilation to keep builds fast.
- Run tests, static analysis and formatting checks as part of the build.
- Embed the version and commit identifier in the artifact for diagnostics.
Container practices:
- Layered images: dependencies change rarely, so put them in a lower layer and the application classes on top, which makes rebuilds and pulls fast.
- A slim JVM runtime image, or a custom minimal runtime containing only the modules the service needs.
- Non-root user and a read-only file system where possible.
- Memory settings relative to the container limit, rather than fixed heap sizes.
- Health checks and graceful shutdown handling.
Optional native compilation produces a fast-starting executable without a JVM, at the cost of longer builds and restrictions on reflection.
Q71.How do you reduce startup time and memory use for Kotlin services?
Measure where startup time and memory go, then remove unnecessary work at startup, reduce reflection and consider ahead-of-time techniques when fast startup really matters, such as for autoscaling or scale-to-zero workloads.
Standard JVM with just-in-time compilation Ahead-of-time native image Slower startup and warm-up, then excellent peak performance Very fast startup and low memory from the start Full support for reflection and dynamic features Reflection and dynamic loading need configuration Simple builds Long builds and some library limitations Best for long-running services with steady load Best for short-lived tasks and dense, frequently scaled deployments Improvements on the standard JVM:
- Avoid heavy reflection at startup by using compile-time serialization and dependency injection that resolves at build time.
- Defer expensive initialization, such as warming caches, until after the service reports ready, if acceptable.
- Class data sharing and startup snapshots reduce class loading time.
- Right-size thread pools and caches, which affect baseline memory.
- Tune the heap relative to the container, and choose a collector suited to the heap size.
Measure time to first successful request and steady-state memory under realistic load before and after each change.
Q72.How do you size thread pools and connection pools for a Kotlin service?
Size each pool from the work it does and the resource it protects. The number of operations in progress equals throughput times the time each operation takes, so a pool needs at least that many slots, plus headroom, but never more than the downstream resource can handle.
Different pools, different rules:
- CPU-bound work, such as
Dispatchers.Default: about one thread per core; more threads only add switching. - Blocking calls, such as a blocking database driver: enough threads to cover concurrent calls, capped by the connection pool.
- Database connection pools: limited by the database's connection capacity divided across all instances.
- Suspending, non-blocking calls need few threads; concurrency is limited by connection pools and semaphores instead.
Practical steps:
- Measure throughput and latency per operation under load.
- Compute needed concurrency, add headroom for spikes and cap it by downstream limits.
- Align limits: a parallelism limit on blocking database work should equal the connection pool size, so threads never wait for connections.
- Monitor pool usage, wait time and timeouts.
Example
A service runs 300 queries per second per instance, each taking 30 ms, and the database allows 200 connections across 8 instances.
- Concurrent queries needed:
300 × 0.030 = 9. - With 50% headroom for spikes: about 14 connections per instance.
- Database budget:
200 / 8 = 25connections per instance, so 14 fits, leaving room for migrations and administration. - The blocking query work gets a dispatcher limited to 14 in parallel, matching the pool, so coroutines suspend rather than threads blocking on pool waits.
- CPU-bound work, such as
Q73.How do you implement graceful shutdown for a Kotlin service?
On a termination signal, stop accepting new requests, let in-flight requests and background coroutines finish within a time limit, then close resources such as connection pools and flush logs and metrics. Structured concurrency makes this clean: cancelling the application scope cancels every background coroutine, and their
finallyblocks run cleanup.sequenceDiagram participant P as Platform participant A as Application participant S as HTTP server participant B as Background scope P->>A: Termination signal A->>A: Fail readiness, wait briefly for load balancers A->>S: Stop accepting, finish in-flight requests within a grace period A->>B: Cancel scope and wait for children B-->>A: Finished, cleanup done in finally blocks A->>A: Close pools, flush telemetry A-->>P: ExitImplementation details:
- Grace period: shorter than the platform's kill timeout, so the process exits on its own.
- Background work: consumers stop taking new messages, finish or release current ones and commit offsets or acknowledgements.
- Suspending cleanup runs in
withContext(NonCancellable)insidefinally. - Order: stop inputs first, then workers, then shared resources such as databases.
- Test it by sending termination signals under load and checking that no requests fail.
Q74.How do you call other services reliably from Kotlin?
Use a suspending HTTP client configured once per downstream service, with timeouts, connection pooling, limited retries and clear error handling, and wrap each service behind a small client class with typed results.
flowchart LR A["Service code"] --> B["Typed client for the pricing service"] B --> C["Timeout per call"] C --> D["Retry transient failures with backoff"] D --> E["Concurrency limit and circuit breaker"] E --> F["Pooled HTTP connection"] F --> G["Pricing service"]Rules:
- Timeouts everywhere: connect, request and overall, within the caller's own deadline.
- Retries only for safe operations and transient errors, with backoff and jitter, never retrying cancellation.
- Circuit breaking: stop calling a failing dependency for a while and return a fallback, so failures do not cascade.
- Concurrency limits per dependency, with a semaphore.
- Typed errors: map responses to sealed results, such as
NotFoundorUnavailable, so callers handle each case. - Propagate context: trace headers and request deadlines.
- Reuse clients: creating a client per call wastes connections and threads.
Test clients against a local fake server that can return errors, slow responses and malformed bodies.
Q75.How do you use Kotlin's type system to make illegal states unrepresentable?
Design types so invalid combinations of data cannot be constructed. Instead of one class with many nullable fields and flags, model each valid state as its own type with exactly the fields it needs, and make transitions functions that only accept the right starting state.
Techniques:
- Sealed hierarchies for states: a
Shippedorder always has a tracking code; aPendingorder cannot have one. - Value classes for validated primitives: an
Emailcan only be created through a parser that validates it. - Non-null types for required data, so missing values are impossible after validation.
- Private constructors with factories that enforce rules, such as a positive quantity.
- Transition functions that return the next state and reject invalid moves.
Benefits:
- Whole categories of checks disappear from business code, because the compiler guarantees them.
- Reviews focus on transitions instead of scattered
ifchecks. - Adding a state forces every
whenover the states to handle it.
Balance: keep persistence and API models flexible where needed, and convert into strict domain types at the boundary.
Example
Each state carries only its own data, and an invalid transition is rejected.
sealed interface Order { val id: String data class Pending(override val id: String) : Order data class Paid(override val id: String, val paymentId: String) : Order data class Shipped(override val id: String, val paymentId: String, val trackingCode: String) : Order } fun Order.pay(paymentId: String): Order = when (this) { is Order.Pending -> Order.Paid(id, paymentId) else -> error("cannot pay an order that is ${this::class.simpleName}") } fun Order.ship(trackingCode: String): Order = when (this) { is Order.Paid -> Order.Shipped(id, paymentId, trackingCode) else -> error("cannot ship an order that is ${this::class.simpleName}") } fun main() { println(Order.Pending("ord-7").pay("pay-1").ship("TRK-9")) try { Order.Pending("ord-8").ship("TRK-1") } catch (e: IllegalStateException) { println(e.message) } } // Output: // Shipped(id=ord-7, paymentId=pay-1, trackingCode=TRK-9) // cannot ship an order that is Pending- Sealed hierarchies for states: a
Q76.A Kotlin service's latency jumps under load, but processor use stays moderate. What do you suspect?
A blocking call is probably running on a dispatcher meant for non-blocking or CPU work, usually the default dispatcher. Its few threads are stuck waiting, so every coroutine scheduled on them waits too, and latency climbs even though the processor is mostly idle.
How to confirm:
- Thread dumps show default dispatcher threads blocked in input and output, such as a synchronous database driver, a blocking HTTP client or
Thread.sleep. - Coroutine dumps show many coroutines suspended waiting to be dispatched.
- Latency correlates with traffic while processor use stays moderate.
Fixes:
- Wrap blocking calls in
withContext(Dispatchers.IO), with a limited-parallelism view sized to the resource, such as the connection pool. - Replace blocking clients with suspending ones where possible.
- Remove
runBlockingfrom request paths. - Add a guard: debug tooling that detects blocking calls on dispatcher threads in tests and staging.
Example
The default dispatcher has 8 threads on an 8-core server, and a handler accidentally calls a blocking client that takes 200 ms.
- Each blocked call holds a thread for 200 ms, so 8 threads can complete at most
8 / 0.2 = 40such requests per second. - At 100 requests per second,
100 × 0.2 = 20threads would be needed, so requests queue and latency grows without limit. - Moving the call to the input and output dispatcher, limited to 32 in parallel, raises capacity to
32 / 0.2 = 160requests per second. - A suspending client removes the thread cost entirely, leaving the downstream service's capacity as the only limit.
- Thread dumps show default dispatcher threads blocked in input and output, such as a synchronous database driver, a blocking HTTP client or
Q77.Memory in a Kotlin service grows steadily under constant traffic. What coroutine-related causes do you check?
Besides ordinary leaks, such as unbounded caches, coroutine code has its own ways to hold memory: coroutines that never finish, buffers that grow without limit and replay caches that keep old values.
Coroutine-related causes:
- Leaked coroutines: work launched in an application-wide scope that waits forever, such as on a channel nobody sends to, keeping its captured data alive.
- Unlimited channels: a producer faster than its consumer fills an unlimited buffer.
- Large replay caches on shared flows, holding many big events for late subscribers.
- Collectors that never stop, started per request but tied to a long-lived scope.
- Captured request data in long-lived lambdas, such as callbacks registered with a global registry.
How to investigate:
- Dump active coroutines with debugging tools and look for counts that grow.
- Compare heap dumps and follow retention paths from coroutine continuation objects.
- Check every
launchthat does not use the request's scope.
Fixes: structured concurrency for per-request work, bounded channels with explicit overflow behavior, small replay values and timeouts on waits.
Example
A handler launches a follow-up coroutine in the application scope that waits for a confirmation event that never arrives for cancelled orders.
- About 2,000 cancelled orders occur per hour, and each waiting coroutine keeps roughly 50 KB of order data alive.
- Memory grows by about
2,000 × 50 KB = 100 MBper hour, until garbage collection runs constantly. - A coroutine dump shows thousands of coroutines suspended at the same wait.
- Adding a timeout to the wait, and cancelling the waiter when the order is cancelled, keeps the number of waiting coroutines near zero.
Q78.Cancelled requests in a Kotlin service keep doing work in the background. Why?
Cancellation in coroutines is cooperative, so work continues when the code cannot observe the cancellation or actively suppresses it. The most common causes are swallowed cancellation exceptions, blocking calls and work started outside the request's scope.
Typical causes:
- Swallowed cancellation:
catch (e: Exception)orrunCatchingaround suspending calls catches the cancellation exception; if it is not rethrown, the coroutine continues as if nothing happened. - Blocking or CPU loops that never reach a suspension point or check
isActive. - Unscoped launches: coroutines started in a global or application scope do not belong to the request, so cancelling the request does not reach them.
NonCancellableused too broadly, instead of only for short cleanup.- Callback wrappers that do not cancel the underlying operation when the coroutine is cancelled.
Fixes:
- Catch specific exceptions, or rethrow cancellation explicitly in generic handlers.
- Add
ensureActive()in long loops, and use interruptible or suspending APIs. - Start child work in
coroutineScopewithin the request. - Register cancellation handlers in callback wrappers.
Test it: cancel a running operation in a test and assert that no further side effects happen.
- Swallowed cancellation:
Q79.A Kotlin service throws a null pointer exception even though the code uses null safety. How can that happen?
Kotlin's null safety is checked at compile time, but some values bypass the checks at run time. The usual sources are platform types from JVM libraries, uninitialized
lateinitproperties, explicit!!assertions and deserialization or reflection that creates objects without running the normal constructor checks.Where to look:
- Platform types: a library method without nullability annotations returns null, and the value is assigned to a non-null Kotlin type, which throws at that point.
!!on a value the developer assumed could not be null.lateinitproperties read before initialization, which throw a specific uninitialized-property exception.- Deserialization that bypasses constructors and leaves non-null fields null.
- Initialization order: calling an overridable method from a base class constructor, which reads a subclass property before it is set.
Prevention:
- Declare explicit Kotlin types at interop boundaries, choosing nullable types when unsure.
- Search for and justify every
!!. - Prefer constructor parameters over
lateinit. - Configure serialization to respect nullability, and validate input into domain types.
Example
An uninitialized
lateinitproperty and a null platform value both fail at run time despite non-null declarations.class ReportService { lateinit var template: String fun render(name: String) = template.replace("{name}", name) } fun main() { val service = ReportService() try { service.render("q1") } catch (e: UninitializedPropertyAccessException) { println("not initialized: ${e.message}") } service.template = "Report for {name}" println(service.render("q1")) try { val value: String = System.getenv("SURELY_NOT_SET_42") println(value.length) } catch (e: NullPointerException) { println("platform value was null") } } // Output: // not initialized: lateinit property template has not been initialized // Report for q1 // platform value was nullQ80.A message consumer written in Kotlin sometimes processes the same message twice. How do you fix it?
Most message brokers deliver at least once: after a crash, a timeout or a rebalance, a message can be delivered again. The consumer must therefore be idempotent, recording each processed message ID and skipping messages it has already handled.
Making a consumer idempotent:
- Use a stable message ID, set by the producer, not generated on receipt.
- Record processed IDs in the same database transaction as the business change, with a unique constraint, so the check and the write are atomic.
- Acknowledge after processing succeeds and commits.
- Design operations to be naturally idempotent where possible, such as setting a status rather than incrementing a counter.
- Expire old IDs after a period longer than the broker's maximum redelivery window.
Related issues:
- Ordering: if messages can arrive out of order, compare versions or timestamps before applying updates.
- Poison messages: messages that always fail should go to a dead-letter queue after a few attempts.
- Partial side effects: external calls, such as emails, need their own idempotency keys.
Example
A redelivered message is detected by its ID, so the ledger records the payment once.
data class Message(val id: String, val orderId: String, val amountMinor: Long) class PaymentConsumer { private val processed = mutableSetOf<String>() val ledger = mutableListOf<String>() fun handle(message: Message): String { if (!processed.add(message.id)) return "skipped duplicate ${message.id}" ledger += "${message.orderId}:${message.amountMinor}" return "recorded ${message.id}" } } fun main() { val consumer = PaymentConsumer() listOf(Message("m1", "ord-1", 45_000), Message("m2", "ord-2", 9_900), Message("m1", "ord-1", 45_000)) .forEach { println(consumer.handle(it)) } println(consumer.ledger) } // Output: // recorded m1 // recorded m2 // skipped duplicate m1 // [ord-1:45000, ord-2:9900]In production, the processed IDs live in a database table with a unique constraint, written in the same transaction as the ledger entry.