Free Handbook · Every example compiled & verified

Interview Questions

Sixty Kotlin interview questions — 25 junior, 25 mid-level, 10 senior — with model answers, what each tests, a coding round and a take-home checklist.

0 / 136 lessons🔥 0 day streak
ShareXLinkedIn

Module 15 · what you'll be able to do

  • Answer the 25 junior questions on null safety, functions, scope functions and classes that every Kotlin screen draws from
  • Explain sequences, variance, inline and reified, coroutines, Flow and Java interop at mid-level depth
  • Reason through senior questions on Android architecture, Compose performance, Kotlin Multiplatform, migration and production debugging
  • Run a live coding round in idiomatic Kotlin, and hand in a take-home that reviewers approve
01

How to use this module

Kotlin interviews split into two tracks that share a core. The core — null safety, data and sealed classes, collections, lambdas, coroutines — is asked everywhere. Android roles then go deep on ViewModels, Compose and offline data; backend roles on Spring Boot or Ktor, databases and coroutines on the server. The questions below follow that shape.

  • Say the answer out loud before opening it. Recognising an answer when you read it is not the same as producing it under pressure.
  • Read the "what they are really testing" line. It tells you what the interviewer will follow up on, which is where most candidates lose points.
  • Back every claim with code you have run. The modules this draws on — Null Safety, Collections, Generics & Extensions, Coroutines — have examples you can paste into your editor.
Expect Java questions too
Most Kotlin codebases sit next to Java code and Java libraries, and many interviewers came to Kotlin from Java. Be ready to say what a Kotlin feature compiles to on the JVM and how Java code sees it — the Java handbook covers that side.
02

Junior: basics and null safety — 10 questions

Asked in nearly every first-round Kotlin screen. Null safety in particular is the feature interviewers expect you to explain precisely, because it is the main reason teams adopted the language.

JuniorWhy would a team choose Kotlin over Java?

Kotlin runs on the same JVM, calls every Java library directly and can live in the same module as Java files, so adopting it is not a rewrite. What it adds: null safety in the type system (most NullPointerExceptions become compile errors), far less boilerplate (data classes, properties, default arguments), expressions everywhere (if, when and try return values), extension functions, sealed types with exhaustive when, and coroutines for asynchronous code. Google made it the preferred language for Android, and Spring supports it as a first-class language, so it covers both of the big JVM job markets.

What they are really testing: Whether you can name concrete, checkable differences — null safety, interop, coroutines — rather than "it is more modern".

JuniorWhat is the difference between val, var and const val?

var is a variable you can reassign. val is a read-only reference: assigned once, never reassigned — but the object it points at can still change (val xs = mutableListOf(1); xs.add(2) is legal). const val is a compile-time constant: only primitives and String, only at top level or inside an object/companion object, and its value is inlined into every call site. Default to val; reach for var only when the value genuinely changes.

What they are really testing: That val is about the reference, not deep immutability — the most common follow-up.

JuniorWhat is the difference between == and === in Kotlin?

== is structural equality: it calls equals(), and it is null-safe (a == b compiles to a?.equals(b) ?: (b === null)). === is referential equality: are these the same object? This is the opposite of Java, where == on objects compares references. So comparing strings with == is correct in Kotlin. Data classes generate equals(), so two data objects with the same properties are == but not ===.

What they are really testing: Whether you carry Java habits over wrongly, and whether you know == is null-safe.

JuniorExplain Kotlin null safety: ?, ?., ?: and !!

Types are non-null by default: a String can never hold null. String? can, and the compiler will not let you call members on it directly. ?. is the safe call — name?.length is null if name is. ?: is the Elvis operator — "or else": name?.length ?: 0. It also works with return and throw on the right: val user = find(id) ?: return. !! is the not-null assertion: it converts to non-null or throws a NullPointerException right there. See Module 04.

What they are really testing: Fluency with the operators, and whether you treat !! as a tool of last resort.

JuniorWhen is it acceptable to use !!?

Rarely. !! says "I know better than the compiler", and when you are wrong the crash has no message beyond the line number. Acceptable cases: a test, where a crash is the failure you want; or a value that is guaranteed by something the compiler cannot see and that you would treat as a programming bug. Better options almost always exist: ?: with a default, ?: return, ?: error("user $id must exist") (a clear message), requireNotNull(x) { "…" }, smart casts after a null check, or lateinit for injected fields. Code review in most Kotlin teams flags every !!.

What they are really testing: Judgement — not a rule recited, but the alternatives and why they produce better failures.

JuniorWhat is a smart cast, and when does it not work?

After a check such as if (x is String) or if (x != null), the compiler treats x as the narrower type inside that branch, so no explicit cast is needed. It only works when the compiler can prove the value cannot change between the check and the use: local vals, local vars not captured by a lambda that modifies them, and val properties with no custom getter declared in the same module. It fails for a var property (another thread or call could change it) and for open or custom-getter properties. The fix is to copy into a local: val n = this.name; if (n != null) use(n), or name?.let { use(it) }.

What they are really testing: Whether you understand the reason (stability of the value), not just the syntax.

Juniorlateinit versus by lazy — when do you use each?

lateinit var is for a non-null property that is set after construction by someone else — dependency injection, a test @BeforeEach, an Android onCreate. It works only on var, only for non-primitive, non-null types; reading it before assignment throws UninitializedPropertyAccessException, and ::prop.isInitialized checks it. val x by lazy { … } computes the value on first access and caches it; by default the initialiser is synchronized, so it is safe across threads. Rule: you set it later → lateinit; it computes itself later → lazy.

What they are really testing: Knowing the restrictions (var only, no primitives) and the thread-safety default of lazy.

JuniorWhat are Any, Unit and Nothing?

Any is the root of all non-null types (Any? is the root of everything), like Object in Java. Unit is the type of "no useful value": a real singleton object, so it can be used as a generic argument (() -> Unit), unlike Java’s void. Nothing is the type of an expression that never completes — throw, error(), TODO(), an infinite loop. Because Nothing is a subtype of every type, val x: Int = y ?: throw IllegalStateException() type-checks. emptyList() is a List<Nothing>, which is why it fits any List<T>.

What they are really testing: Whether you know Nothing exists and why it makes throw usable as an expression.

JuniorHow is when different from Java’s switch?

when is an expression that returns a value, has no fall-through (so no break), and its branches can match constants, several values (1, 2 ->), ranges (in 1..9), types (is String, with a smart cast) or arbitrary conditions when used without a subject. Used as an expression it must be exhaustive: over an enum, a sealed type or a Boolean the compiler checks every case is covered, so adding a new subtype breaks every incomplete when at compile time instead of at runtime.

What they are really testing: Exhaustiveness with sealed types — the feature that makes when a design tool.

JuniorHow do string templates and raw strings work?

"Hello, $name" inserts a variable; "${user.name} has ${items.size} items" inserts any expression. Triple-quoted raw strings """…""" keep newlines and do not process escapes, which is ideal for SQL, JSON and regular expressions; .trimIndent() strips the common leading indentation. Templates call toString(), so a data class prints readably. To write a literal dollar sign in a normal string, escape it as \$.

What they are really testing: A quick fluency check; they will notice if you concatenate with + in live code.

03

Junior: functions, lambdas and scope functions — 8 questions

Kotlin code is dense with lambdas, extension functions and scope functions. These questions check that you can read that style and write it without overusing it.

JuniorHow do default and named arguments replace method overloading?

A parameter with a default (fun connect(host: String, port: Int = 5432, ssl: Boolean = true)) can be omitted, and named arguments let callers pick which to pass and make calls self-documenting: connect("db", ssl = false). One function replaces the telescoping overloads Java needs. Java callers do not see defaults, though — add @JvmOverloads to generate the overloads when Java code calls the function.

What they are really testing: Whether you know the Java-interop catch, which is where the follow-up goes.

JuniorWhat is a lambda in Kotlin, and what is the trailing-lambda syntax?

A lambda is a function literal: { x: Int -> x * 2 }, with type (Int) -> Int. With exactly one parameter you can omit it and use it: list.map { it * 2 }. The last expression is the return value. If a function’s last parameter is a function, the lambda can be written outside the parentheses — list.filter { it > 0 }, repeat(3) { println(it) } — which is what makes builders and DSLs read like language syntax.

What they are really testing: Reading and writing idiomatic Kotlin; every collection question builds on this.

JuniorWhat is a higher-order function? Give an example you would write.

A function that takes a function as a parameter or returns one. The standard library is full of them (map, filter, sortedBy). A useful one to write yourself is a retry helper:

fun <T> retry(times: Int, block: () -> T): T {
    repeat(times - 1) {
        try { return block() } catch (e: IOException) { /* log, back off */ }
    }
    return block()
}
Marking it inline removes the allocation of the lambda object at each call site. See Module 03.

What they are really testing: That you can design with function types, not only consume them.

JuniorWhat is an extension function, and how is it dispatched?

fun String.isEmail(): Boolean = contains("@") adds a function you can call as "[email protected]".isEmail() without touching the String class. Under the hood it is a static function taking the receiver as its first parameter, so it can only see public members, and it is resolved statically by the declared type of the variable, not the runtime type — no polymorphism. If a class has a member with the same signature, the member always wins. Use extensions to keep utility code readable and close to where it is used.

What they are really testing: The static-dispatch trap: an extension on a supertype does not get "overridden".

JuniorExplain the scope functions let, run, with, apply and also.

All five run a block with an object in scope; they differ in how the object is referenced (it or this) and what they return (the object or the lambda result).

val len = name?.let { it.trim().length }        // it, returns result: null-safe transform
val user = User().apply { age = 30 }            // this, returns object: configure
list.also { log("size ${it.size}") }             // it, returns object: side effect
val area = rect.run { width * height }          // this, returns result: compute
with(builder) { append("a"); append("b") }      // this, returns result: group calls
Guidelines: apply for configuring, let for null checks and transforms, also for logging. Avoid nesting them — two levels of it is unreadable.

What they are really testing: Whether you pick one for a reason, and whether you know when not to use them.

JuniorWhat happens to top-level functions when Kotlin compiles to the JVM?

The JVM has no free functions, so the compiler puts every top-level function and property of a file into a generated class named after the file: functions in Main.kt become static methods of MainKt. Java calls them as MainKt.greet(). @file:JvmName("Greetings") at the top of the file renames that class. This is also why java -jar on a Kotlin app needs Main-Class: MainKt in the manifest.

What they are really testing: A peek below the syntax — they want to see you know what the bytecode looks like.

JuniorWhat is a vararg parameter and the spread operator?

fun sum(vararg xs: Int): Int accepts any number of arguments: sum(1, 2, 3). Inside the function xs is an array (IntArray here). To pass an existing array, spread it with *: sum(*numbers), and you can mix: sum(0, *numbers, 10). A List must be converted first: sum(*list.toIntArray()). Only one parameter can be vararg, and if it is not last, the parameters after it must be passed by name.

What they are really testing: Small detail, but it trips people up when bridging lists and arrays.

JuniorWhen do you write a single-expression function?

When the body is one expression: fun square(x: Int) = x * x. The return type is inferred. Good for small pure functions and for returning a when: fun label(s: Status) = when (s) { … }. For public API, write the return type explicitly so a change in the body cannot silently change the signature callers depend on. Do not force long logic into one expression just to use the syntax.

What they are really testing: Readability judgement and awareness of public-API stability.

04

Junior: classes, data, sealed and objects — 7 questions

Kotlin’s class features are where it differs most from Java: final by default, data classes, objects instead of statics, sealed hierarchies. Examples win over definitions.

JuniorWhy are Kotlin classes final by default?

Every class and member is final unless marked open. Inheritance is a promise that subclasses can rely on your internals; making it opt-in (the "design for inheritance or prohibit it" advice from Java best practice) avoids fragile base classes. The practical cost: frameworks that subclass your classes at runtime — Spring proxies, JPA/Hibernate lazy loading — need them open, which is why Kotlin Spring projects use the kotlin-spring (all-open) and kotlin-jpa (no-arg) compiler plugins.

What they are really testing: The reasoning and the framework consequence — the second half is what experienced interviewers listen for.

JuniorWhat does a data class generate, and what are its limits?

From the properties in the primary constructor it generates equals(), hashCode(), toString(), copy() and component1()…componentN() for destructuring. Properties declared in the class body are ignored by all of them. A data class needs at least one constructor parameter, cannot be abstract, open, sealed or inner, and copy() is shallow. Use them for values — DTOs, events, UI state — not for entities with identity. See Module 08.

What they are really testing: The "only constructor properties count" rule, which causes real equality bugs.

JuniorPrimary constructor, secondary constructors and init blocks — how do they fit together?

The primary constructor is in the class header: class User(val name: String, age: Int); val/var there declares properties, a bare parameter is only visible to initialisers. init { … } blocks run as part of the primary constructor, in source order, interleaved with property initialisers — the place for require(age >= 0). A secondary constructor (constructor(…) : this(…)) must delegate to the primary. In practice, default arguments make secondary constructors rare.

What they are really testing: Initialisation order and whether you validate in init.

JuniorWhat is the difference between object, companion object and a class?

object Config { … } declares a class and its single instance at once — a thread-safe, lazily initialised singleton. A companion object is an object tied to a class, used for what Java does with static: factory functions (User.from(json)) and constants. Its members are still instance members of the companion at bytecode level; add @JvmStatic or const so Java sees real statics. object : Listener { … } as an expression creates an anonymous object, like a Java anonymous class.

What they are really testing: That Kotlin has no static keyword, and what replaces it.

JuniorInterface versus abstract class in Kotlin?

Interfaces can declare abstract members, methods with default bodies and properties (abstract, or with a getter — no stored state). A class can implement many. An abstract class can hold state (backing fields), have a constructor, and a class can extend only one. Choose an interface for a capability ("can be saved", "can be rendered"); choose an abstract class when subclasses genuinely share state and construction logic. When two interfaces provide the same default method, the implementing class must override it and pick with super<A>.method().

What they are really testing: The state distinction, and conflict resolution as a follow-up.

Juniorenum class versus sealed class — when do you use which?

An enum has a fixed set of instances, each a singleton with the same shape: enum class Direction { NORTH, SOUTH }. A sealed class or interface has a fixed set of subtypes, each of which can carry different data and have many instances:

sealed interface LoadState {
    data object Loading : LoadState
    data class Success(val items: List<Item>) : LoadState
    data class Error(val message: String) : LoadState
}
Both give exhaustive when. Enum for a closed list of constants; sealed for a closed set of cases — results, UI states, commands.

What they are really testing: The single most common Kotlin modelling question; the UI-state example is the expected answer.

JuniorWhat does the internal visibility modifier mean?

internal means visible everywhere in the same module — a Gradle source set, an IntelliJ module, a Maven project compiled together — and invisible outside it. It is how a library hides implementation classes that several of its own packages need. Kotlin’s default is public; private at top level means private to the file. Java has no equivalent, so internal members are public in bytecode with a mangled name, which Java code can technically still call.

What they are really testing: Module boundaries — relevant to anyone who has split an app into Gradle modules.

05

Mid-level: collections and sequences — 6 questions

For roles with two to five years of experience. The interviewer wants to know how the standard library behaves, because that is what lets you predict performance and edge cases.

Mid-levelIs a Kotlin List immutable?

No — it is read-only. The List interface has no add, but the object behind it may be an ArrayList that someone else holds as a MutableList and changes. listOf() returns a list you cannot modify through that interface, and a cast to MutableList can even succeed on some implementations. For a defensive copy, use toList(); for true immutability, kotlinx.collections.immutable. When a List crosses into Java it becomes java.util.List, which Java code can try to mutate.

What they are really testing: The read-only versus immutable distinction, which causes shared-state bugs.

Mid-levelSequence versus Iterable — when is a Sequence faster, and when slower?

Collection operations on an Iterable are eager: list.map { … }.filter { … }.first() builds a full intermediate list at each step. A Sequence is lazy and processes element by element through the whole chain, stopping as soon as the terminal operation is satisfied — no intermediate lists, and first() may touch only a few elements. Sequences win for long chains on large collections, short-circuiting terminals, or infinite sources (generateSequence). For small collections and one or two steps, lists are faster because sequences add per-element overhead. Operations like sorted() must still consume everything. See Module 05.

What they are really testing: That lazy is not automatically faster — the nuanced answer is what they are after.

Mid-levelExplain groupBy, associateBy, partition and flatMap with a use for each.

orders.groupBy { it.customerId } → Map<Id, List<Order>>, for reports. users.associateBy { it.id } → Map<Id, User>, for O(1) lookups (a later duplicate key overwrites). val (valid, invalid) = rows.partition { it.isValid() } splits in one pass. orders.flatMap { it.lines } flattens nested lists. Counting is groupingBy { it }.eachCount(), which avoids building lists at all. Knowing the right one replaces most hand-written loops with mutable maps.

What they are really testing: Standard-library fluency; interviewers watch for hand-rolled loops in live coding.

Mid-levelfold versus reduce?

reduce starts from the first element and throws UnsupportedOperationException on an empty collection; its accumulator has the element type. fold(initial) { acc, x -> … } starts from a value you give, works on empty collections (returns initial) and the accumulator can be a different type — words.fold(0) { acc, w -> acc + w.length }. Prefer fold, or a specific function like sumOf, unless empty input is impossible; reduceOrNull exists when you want a null.

What they are really testing: Edge-case thinking: the empty-collection behaviour is the whole question.

Mid-levelArray<Int> versus IntArray versus List<Int>?

IntArray compiles to a primitive int[]: no boxing, compact, fastest. Array<Int> is Integer[]: every element a boxed object. List<Int> is a read-only interface usually backed by an ArrayList of boxed integers, with the full collection API. Use List by default for APIs; use primitive arrays for hot numeric code, large buffers and algorithm work where boxing shows up in profiles. Arrays are mutable, fixed-size and compare by reference — use contentEquals.

What they are really testing: Awareness of boxing and of array equality semantics.

Mid-levelWhy does removing items inside a for loop fail, and how do you do it properly?

Structurally modifying a MutableList while iterating it with for (an iterator) throws ConcurrentModificationException — the iterator detects the list changed underneath it. Correct options: list.removeAll { it.expired } (or removeIf), build a new list with filter, or use the iterator’s own remove() with an explicit iterator() loop. The name is misleading: this happens on a single thread. See Module 05.

What they are really testing: Whether you have actually hit it, and know it is not a threading issue.

06

Mid-level: generics, variance, inline and reified — 5 questions

Questions that separate people who use library APIs from people who can design them. Variance and inline functions come up in almost every mid-level Kotlin loop.

Mid-levelExplain variance: what do out and in mean?

By default a generic type is invariant: MutableList<String> is not a MutableList<Any>, because you could add an Int to it. out T (covariance) means the type only produces T, so List<String> is a List<Any> — Kotlin’s read-only List is declared List<out E>. in T (contravariance) means it only consumes T, so a Comparator<Any> can be used where a Comparator<String> is expected. Kotlin puts this on the declaration (declaration-site variance) instead of on every use like Java’s ? extends / ? super. See Module 09.

What they are really testing: Producer-out, consumer-in, and why List is covariant while MutableList is not.

Mid-levelWhat does List<*> mean, and how is it different from List<Any?>?

List<*> is a star projection: a list of some specific type you do not know. You can read elements as Any? but, for a mutable type, you cannot add anything, because the real element type is unknown. MutableList<Any?> accepts anything. Use star projection when you only need to inspect a value of an unknown generic type — if (x is List<*>) — which is also the only generic is check the JVM allows, because of type erasure.

What they are really testing: Type erasure and safe handling of unknown generic types.

Mid-levelWhat does reified do, and why does it need inline?

Generic type arguments are erased at runtime, so inside a normal fun <T> you cannot write x is T or T::class. An inline function is copied into each call site, where the real type is known, so marking the parameter reified lets the compiler substitute it:

inline fun <reified T> List<Any>.only(): List<T> = filterIsInstance<T>()

val names = mixed.only<String>()
This is how filterIsInstance, Gson/Jackson helpers and Android’s by viewModels<T>() work. Without inline it is a compile error — there is no call site to copy into.

What they are really testing: Understanding erasure and what inlining actually does.

Mid-levelWhat do inline, noinline and crossinline do?

inline copies the function body and its lambda arguments into the caller, removing the lambda object allocation and allowing a plain return inside the lambda to return from the enclosing function (a non-local return). noinline on a lambda parameter keeps it as an object, needed when you store it or pass it to a non-inline function. crossinline keeps it inlined but forbids non-local returns, needed when the lambda is called from another context (a Runnable, a nested lambda) where returning from the outer function would be impossible. Inline only small higher-order functions — inlining big bodies bloats bytecode.

What they are really testing: Knowing the three and the non-local return rule — the classic mid-level Kotlin question.

Mid-levelHow do you constrain a generic type, and what is a where clause?

fun <T : Comparable<T>> max(a: T, b: T) sets an upper bound: T must be comparable. The default bound is Any?, so write T : Any to forbid nullable type arguments. For more than one bound, use a where clause:

fun <T> sortedTexts(xs: List<T>): List<T>
    where T : CharSequence, T : Comparable<T> = xs.sorted()
Passing a type that does not satisfy the bound is a compile error, not a runtime one.

What they are really testing: Precision with type parameters, including the nullable default bound.

07

Mid-level: coroutines, structured concurrency and Flow — 8 questions

The largest block in any mid-level Kotlin interview, on both Android and the server. Know the rules of structured concurrency and cancellation well enough to spot the bug in a snippet.

Mid-levelWhat is a coroutine, and how is it different from a thread?

A coroutine is a computation that can suspend — pause without blocking a thread — and resume later, possibly on another thread. Threads are OS resources: about a megabyte of stack each, expensive to create and to context-switch, so a server cannot have a million of them. Coroutines are objects on the heap; a small thread pool runs many thousands of them, and while one waits for I/O its thread runs others. You write sequential-looking code (val user = api.load(id)) instead of callbacks. The language provides suspend; the kotlinx.coroutines library provides launch, async, dispatchers and Flow. See Module 10.

What they are really testing: The suspend-versus-block distinction, stated precisely.

Mid-levelHow does a suspend function work under the hood?

The compiler rewrites it in continuation-passing style: it gets an extra hidden parameter, a Continuation, and returns Any?. The body becomes a state machine — each suspension point is a label, and local variables that live across suspension points are stored in the continuation object. When a call suspends, the function returns the marker COROUTINE_SUSPENDED and the thread is free; when the result arrives, something calls continuation.resumeWith(result) and execution jumps back to the saved label. That is why a suspend function can only be called from another suspend function or a coroutine builder — it needs a continuation to pass.

What they are really testing: Depth beyond usage: CPS, the state machine and why the calling restriction exists.

Mid-levellaunch versus async?

launch starts a coroutine for its side effect and returns a Job (to cancel or join); an exception in it propagates to its parent immediately. async returns a Deferred<T>, a future you await() for a result; use it to run things in parallel:

coroutineScope {
    val user = async { api.user(id) }
    val orders = async { api.orders(id) }
    Profile(user.await(), orders.await())
}
Calling async { … }.await() immediately is just a slower sequential call. Inside a normal scope, a failing async still cancels its siblings and parent — it does not wait for await.

What they are really testing: Correct parallel decomposition and the exception-propagation detail.

Mid-levelWhat is structured concurrency?

Every coroutine is launched in a CoroutineScope, which makes it a child of that scope’s Job. The rules: a parent does not complete until all children complete; cancelling a parent cancels all children; a failing child cancels the parent and therefore its siblings. So no coroutine can leak — when a screen’s viewModelScope is cancelled, or a request’s coroutineScope { } fails, everything it started stops. supervisorScope / SupervisorJob change one rule: a child’s failure does not cancel its siblings — used when tasks are independent, like loading several widgets. GlobalScope opts out of all of this, which is why it is discouraged.

What they are really testing: The three rules and when to reach for a supervisor.

Mid-levelExplain Dispatchers.Main, IO and Default, and withContext.

A dispatcher decides which thread(s) run a coroutine. Default is a pool sized to the CPU cores, for CPU-bound work (parsing, sorting). IO is a larger, elastic pool (64 threads or the core count, whichever is larger) for blocking calls — JDBC, file I/O, legacy SDKs. Main is the UI thread on Android. withContext(Dispatchers.IO) { … } switches for a block and returns its result. Convention: a suspend function should be main-safe — it switches dispatcher itself so callers never have to think about it. Truly suspending APIs (Ktor client, Retrofit suspend functions, Room) do not need IO at all.

What they are really testing: Main-safety as a design principle, not just which dispatcher exists.

Mid-levelWhy is coroutine cancellation "cooperative", and how can you break it?

Cancelling a Job only sets a flag; the coroutine stops when it next reaches a suspension point that checks it (every kotlinx suspending function does), which throws CancellationException. A tight CPU loop with no suspension ignores cancellation unless it calls ensureActive() or yield(), or checks isActive. You break cancellation by swallowing CancellationException: catch (e: Exception) around a suspend call, or runCatching { }, catches it and the coroutine keeps running. Rethrow it, or catch specific exception types. Cleanup that must suspend goes in withContext(NonCancellable) inside finally.

What they are really testing: The catch (e: Exception) bug — one of the most common coroutine bugs in real code reviews.

Mid-levelFlow versus StateFlow versus SharedFlow?

A Flow is cold: nothing runs until someone calls collect, and each collector gets its own execution — like a function that returns many values over time. StateFlow is hot: it always holds a current value, new collectors get the latest value immediately, and equal consecutive values are skipped — the standard holder for UI state. SharedFlow is hot with no required value and configurable replay — for events broadcast to several collectors. stateIn / shareIn turn a cold flow into a hot one within a scope, e.g. stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial).

What they are really testing: Cold versus hot, and which one belongs in a ViewModel.

Mid-levelWhy avoid GlobalScope and runBlocking?

GlobalScope.launch starts a coroutine tied to nothing: it is not cancelled when the screen or request that started it goes away, errors go to no parent, and tests cannot wait for it — a leak by design. Use a scope with a lifecycle (viewModelScope, a request scope, an application scope you inject). runBlocking blocks the current thread until its coroutines finish; it is for main functions and tests (though runTest is better for tests). Inside a coroutine or on the Android main thread it wastes a thread or freezes the UI, and on a limited dispatcher it can deadlock.

What they are really testing: Whether you understand scopes as lifecycles.

08

Mid-level: Java interop and class design — 6 questions

Almost every Kotlin job touches Java — Android SDKs, Spring, JDBC drivers, an older half of the codebase. These questions check that you know where the boundary leaks, and how Kotlin’s delegation and value classes shape designs.

Mid-levelWhat is a platform type?

A type coming from Java whose nullability Kotlin cannot know, shown in IDE hints as String!. Kotlin lets you treat it as nullable or non-null; if you treat it as non-null and Java returns null, you get a NullPointerException at that line — null safety has a hole at every Java boundary. Defences: declare the Kotlin variable type explicitly (val name: String? = javaUser.getName()), and annotate Java code with @Nullable/@NonNull (JSpecify, JetBrains or AndroidX annotations), which Kotlin reads and enforces.

What they are really testing: Awareness that Kotlin null safety is only as good as its Java boundaries.

Mid-levelWhat are @JvmStatic, @JvmField, @JvmOverloads and @JvmName for?

They shape how Kotlin code looks from Java. @JvmStatic on a companion or object member generates a real static method, so Java writes Util.parse() instead of Util.Companion.parse(). @JvmField exposes a property as a public field with no getter/setter. @JvmOverloads generates overloads for functions with default arguments. @JvmName renames a function or file class in bytecode — also used to resolve clashes like fun f(xs: List<String>) and fun f(xs: List<Int>), which erase to the same signature.

What they are really testing: Experience maintaining a mixed Java/Kotlin codebase.

Mid-levelKotlin has no checked exceptions. What does that mean for Java interop?

Kotlin never forces you to catch or declare exceptions, so a Kotlin function can throw IOException without saying so. Java callers then cannot write catch (IOException e) around it — javac rejects catching a checked exception the method does not declare. Add @Throws(IOException::class) to the Kotlin function to emit the throws clause. Within Kotlin, document thrown exceptions in KDoc, or model expected failures as return values (a sealed result type) instead of exceptions.

What they are really testing: Interop detail plus a design opinion about expected versus exceptional failures.

Mid-levelWhat is SAM conversion, and what is a fun interface?

A SAM (single abstract method) interface can be implemented with a lambda: executor.execute { work() } converts the lambda to a Java Runnable. This works automatically for Java interfaces. For a Kotlin interface you must declare it fun interface:

fun interface Validator { fun validate(s: String): Boolean }

val notBlank = Validator { it.isNotBlank() }
In pure Kotlin you can often use a function type (String) -> Boolean instead; a fun interface is better when you want a name, KDoc and Java callers.

What they are really testing: Knowing why a Kotlin interface does not accept a lambda by default.

Mid-levelExplain class delegation (by) and property delegation.

class LoggingList<T>(private val inner: MutableList<T>) : MutableList<T> by inner implements the whole interface by forwarding to inner; you override only what you want to change, e.g. add. That is composition without the boilerplate, and a safer alternative to inheriting from a concrete class. Property delegation (val x by lazy { }, var name by Delegates.observable("") { … }, val id by map) moves a property’s get/set logic into a reusable object with getValue/setValue. Android’s by viewModels() and Compose’s by remember { mutableStateOf(…) } are both property delegates.

What they are really testing: Using the language feature as a design tool — composition over inheritance.

Mid-levelWhat is a value class, and when is it boxed?

@JvmInline value class UserId(val raw: String) wraps exactly one value to give it a distinct type — you can no longer pass an OrderId where a UserId belongs — while compiling to the bare String in most places, so there is no extra object. It is boxed when used as a nullable type, as a generic type argument (List<UserId>), or through an interface it implements. Functions that take value classes get mangled names in bytecode, so Java cannot call them easily. See Module 08.

What they are really testing: Type-safe domain modelling and knowing the performance fine print.

09

Senior: Android and Compose architecture — 3 questions

Senior Android interviews assume Jetpack: ViewModel, Compose, Room, WorkManager and a dependency-injection framework. There is no single right answer; the model answers show the shape of a strong one.

SeniorHow would you structure a modern Android screen: state, events and data?

Unidirectional data flow. A ViewModel exposes one StateFlow<UiState> (a data class or sealed interface) and accepts events as function calls (onRefresh(), onQueryChange(q)). The composable collects with collectAsStateWithLifecycle(), renders the state, and calls the event functions — it holds no business logic. Below the ViewModel, a repository owns data access and exposes Flows; use cases appear only where logic is shared. Work runs in viewModelScope, so it is cancelled with the screen. One-off effects (navigate, show a snackbar) are modelled as state that the UI acknowledges, or a Channel, not a hot flow that drops events. Dependencies are injected (Hilt or manual), which makes the ViewModel testable with a fake repository and runTest.

What they are really testing: Whether you have built real screens: single source of truth, lifecycle-aware collection, testability.

SeniorA Compose screen is recomposing too often and scrolling janks. How do you investigate and fix it?

Measure first: Layout Inspector’s recomposition counts and a system trace or Macrobenchmark on a release build (debug builds are much slower and misleading). Common causes: reading fast-changing state (scroll offset, animation) too high in the tree — defer the read into a lambda (Modifier.offset { … }) or use derivedStateOf; unstable parameters — classes from modules without the Compose compiler or with var properties make the compiler unable to skip; allocating objects or sorting lists during composition — hoist into remember(key) or the ViewModel; missing keys in LazyColumn items so moves recompose everything. Fix the measured hot spot, re-measure, and add a baseline profile for start-up and scroll.

What they are really testing: A measure-first process and real knowledge of how Compose decides to skip.

SeniorDesign an offline-first feature: users can create notes without a network, and they sync later.

The local database is the single source of truth: the UI observes a Room query as a Flow, and writes go to Room first with a sync status column (PENDING, SYNCED, FAILED) and a client-generated UUID so the server can deduplicate retries. WorkManager runs a sync job with a network constraint and exponential backoff; it pushes pending rows, then pulls changes since the last server timestamp or cursor. Conflicts need an explicit rule — last-write-wins by server timestamp, or per-field merge — agreed with the backend. Deletes are soft (tombstones) until synced. Test the repository with an in-memory Room database and a fake API that fails on demand, and test the migration path when the schema changes.

What they are really testing: Source of truth, idempotency, conflict policy and background work constraints — the full picture.

10

Senior: multiplatform, performance, migration and production — 7 questions

Design conversations for experienced engineers on either track. What you would ask first, what you would measure, and which trade-off you would accept matter more than any single fact.

SeniorYour company wants to share code between Android and iOS with Kotlin Multiplatform. What would you share, and what are the risks?

Share what is platform-independent and valuable to keep identical: domain models, validation and business rules, networking (Ktor client), serialization (kotlinx.serialization), persistence (SQLDelight or Room’s multiplatform support), and possibly ViewModels. Keep UI native at first, or evaluate Compose Multiplatform per screen. Platform APIs go behind expect/actual declarations or interfaces injected from each app. Risks: iOS developers consume Kotlin through generated Objective-C headers, so sealed classes, generics, suspend functions and Flow need an adapter layer or tooling to feel natural in Swift; build times and debugging on iOS are worse; and the iOS team must be part of the decision, not handed a framework. Start with one module — say, the API client and models — measure the effort, then expand.

What they are really testing: Pragmatic scoping, awareness of the Swift interop cost, and treating it as an organisational decision too.

SeniorWhat Kotlin language features have hidden performance costs, and how do you check?

Non-inline lambdas allocate an object per call when they capture variables; long eager collection chains allocate an intermediate list per step; generic collections of primitives box every element (List<Int> versus IntArray); value classes box when nullable or generic; by lazy is synchronized by default (use LazyThreadSafetyMode.NONE for single-threaded UI code); string templates in hot log statements build strings even when logging is off; and vararg allocates an array. None of these matter until a profile says they do. Check with a profiler or JMH / Android Macrobenchmark, and read the bytecode — IntelliJ’s "Show Kotlin Bytecode" then "Decompile" — to see what a construct actually becomes.

What they are really testing: Knowledge of what compiles to what, combined with measure-before-optimising discipline.

SeniorHow would you migrate a large Java codebase to Kotlin?

Incrementally, file by file, because both languages compile in the same module and call each other. Order: set up the Kotlin Gradle plugin and CI first; write new code and tests in Kotlin; convert leaf classes with good test coverage next (DTOs to data classes, utilities to top-level or extension functions); leave core, heavily used Java classes until last. Before converting, add nullability annotations to Java so the converter produces correct types instead of platform types everywhere. Review every auto-converted file by hand — the converter emits !!, lateinit and Java idioms that need rewriting. Watch interop details: @JvmStatic/@JvmOverloads for remaining Java callers, the all-open plugin for Spring, the no-arg plugin for JPA. Track progress and agree style rules (ktlint or detekt) so the result reads as one codebase.

What they are really testing: A risk-managed plan, not "run the converter". The annotate-first step is a strong signal.

SeniorHow do you design error handling in a Kotlin codebase: exceptions or result types?

Split failures into two kinds. Expected outcomes the caller must handle — validation failed, not found, payment declined — are modelled as return values: a sealed interface (sealed interface PayResult { data class Ok(…); data class Declined(val reason: String) }) so when forces every case to be handled. Bugs and infrastructure failures — a broken invariant, the database is down — stay exceptions, caught once at a boundary (a Ktor/Spring error handler, a ViewModel) and logged. Be careful with the standard Result and runCatching: they catch every Throwable, including CancellationException, which breaks coroutine cancellation. Pick one convention per layer and write it down.

What they are really testing: Principled separation and the runCatching-plus-coroutines trap.

SeniorYou are building a Kotlin backend with coroutines. How do you handle blocking JDBC and size the pools?

Coroutines only help if nothing blocks the threads that run them. JDBC is blocking, so wrap repository calls in withContext(Dispatchers.IO) — or a dedicated dispatcher created with Dispatchers.IO.limitedParallelism(n) sized to the connection pool (for example HikariCP’s maximum), so you never park more threads waiting for connections than there are connections. Alternatives are R2DBC or a driver with a native asynchronous API, at the cost of maturity and tooling. In Spring, controllers can be suspend functions and @Transactional works with coroutines on reactive transaction managers — know which one your stack uses. Measure with load tests: throughput, p99 latency and pool wait time, then size the database pool from the database’s limits, not from the number of requests.

What they are really testing: That coroutines do not make blocking I/O non-blocking, and pool sizing from first principles.

SeniorYou own a Kotlin library used by other teams. How do you keep its public API stable?

Turn on explicit API mode (kotlin { explicitApi() }) so every public declaration needs a visibility modifier and a declared return type — nothing becomes public by accident. Make implementation internal. Track binary compatibility with the binary-compatibility-validator plugin, which fails the build when the public API dump changes unexpectedly. Avoid data classes in public API: adding a property changes the constructor, copy() and componentN(), breaking compiled callers — use regular classes with builders or factory functions. Be careful with default arguments, inline functions (their bodies are copied into callers, so changing them needs a recompile) and sealed hierarchies (adding a subtype breaks exhaustive when in client code). Version with semantic versioning and deprecate with @Deprecated(level = WARNING) then ERROR, with ReplaceWith.

What they are really testing: Library-maintainer thinking: binary versus source compatibility and Kotlin-specific breakage.

SeniorProduction reports memory growing and requests slowing in a Kotlin service that uses coroutines. How do you debug it?

Gather evidence before theories. Check metrics: heap after GC trending up means a leak; thread count, dispatcher queue depth and DB pool wait time point to blocking. Take a heap dump and look at what retains memory — frequently Job/StandaloneCoroutine objects from coroutines launched in GlobalScope or a never-cancelled custom scope, each holding its captured state; or an unbounded cache or Channel. Take thread dumps: many IO threads parked in JDBC or HTTP calls without timeouts mean blocking work is starving the pool. The kotlinx-coroutines-debug agent can dump live coroutines with their creation stack traces. Typical fixes: tie every coroutine to a lifecycle scope, add timeouts (withTimeout) on external calls, bound caches and channels, and move blocking calls to a sized dispatcher. Then add an alert on the metric that would have caught it.

What they are really testing: A methodical production-debugging approach with coroutine-specific leak sources.

The senior-answer shape
Clarify the goal and constraints, name two options with their costs, pick one and say what would make you change your mind, then say how you would verify it in production. That structure matters more than any single fact.
11

The coding round, walked through

A live coding round is 30–45 minutes on one or two problems in a shared editor, often without autocomplete. Interviewers grade how you think, communicate and test — and in a Kotlin round, whether your code reads like Kotlin rather than Java with semicolons removed. Follow the same script every time — the one from Module 14.

  1. 1
    Clarify (2 min)

    Restate the problem. Ask about input size, empty input, duplicates, null, and what to return when there is no answer. Write the contract as a comment.

  2. 2
    Example (1 min)

    Work one small case by hand. It becomes your first test in main.

  3. 3
    Brute force out loud (2 min)

    "Count everything, sort it all, take the first k — O(n log n)." Say it and its cost before improving it.

  4. 4
    Pick the pattern (1 min)

    groupingBy for counting? Two pointers? A PriorityQueue? Name it, and why.

  5. 5
    Code (15 min)

    Talk while you type. Use val, read-only types in signatures, standard-library functions over hand-rolled loops, and real names.

  6. 6
    Test (5 min)

    Run your example, then the edges. Finding your own bug scores higher than never having one.

  7. 7
    Complexity and improvements (2 min)

    State time and space, then the better version if there is one.

Here is a typical 30-minute problem solved that way: given a list of words, return the k most frequent; break ties alphabetically. The first version is the brute force with a composed comparator; the second answers "what would you improve?" with a size-k heap.

kotlinMain.kt
import java.util.PriorityQueue

// Contract: words may be empty or repeat; k >= 0.
// Returns the k most frequent words, ties broken alphabetically.
fun topK(words: List<String>, k: Int): List<String> {
    val counts = words.groupingBy { it }.eachCount()
    return counts.keys
        .sortedWith(compareByDescending<String> { counts.getValue(it) }.thenBy { it })
        .take(k)                                       // O(n log n)
}

// Improvement for huge n, small k: keep only k candidates. O(n log k).
fun topKHeap(words: List<String>, k: Int): List<String> {
    val counts = words.groupingBy { it }.eachCount()
    val heap = PriorityQueue(
        compareBy<String> { counts.getValue(it) }.thenByDescending { it }  // weakest on top
    )
    for (w in counts.keys) {
        heap.add(w)
        if (heap.size > k) heap.poll()
    }
    return buildList { while (heap.isNotEmpty()) add(heap.poll()) }.asReversed()
}

fun main() {
    println(topK(listOf("b", "a", "b", "c", "a", "b"), 2))
    println(topK(emptyList(), 3))
    println(topK(listOf("x"), 0))
    println(topK(listOf("z", "y", "z", "y"), 1))   // tie -> alphabetical
    println(topKHeap(listOf("b", "a", "b", "c", "a", "b"), 2))
    println(topKHeap(listOf("z", "y", "z", "y"), 1))
}
Outputcompiled & run with real Kotlin
[b, a]
[]
[]
[y]
[b, a]
[y]

The heap’s comparator is the reverse of the answer’s order, so the weakest candidate sits at the head and poll() evicts it. groupingBy { it }.eachCount() counts without building a list per word.

Your turn

Add a test where every word appears once, e.g. listOf("d", "c", "b") with k = 2. Predict the output before running it — both functions must print [b, c].

What loses the round

  • Silence for ten minutes, then a wall of code
  • Java-style Kotlin: var everywhere, index loops, HashMap with manual get/put counting
  • Sprinkling !! to make the compiler stop complaining
  • "It should work" without running an example
  • Optimising before the brute force is correct

What wins it

  • Narrating your reasoning, including dead ends
  • A written contract and example before code
  • Testing edge cases — empty list, k = 0, ties
  • Naming the complexity without being asked
  • "I use a PriorityQueue here because I only need k items"
12

Take-home assignment checklist

Kotlin take-homes are usually one of two things: a small Android app (fetch a list from an API, show it, show details, handle errors) or a small backend service (a REST API over a database). Reviewers open the README, run the build, read the tests, then the code. Most rejected submissions fail at step two: it does not build on the reviewer’s machine.

  • Gradle wrapper committed: ./gradlew build (backend) or ./gradlew assembleDebug test (Android) works on a clean machine. Kotlin, JDK toolchain and dependency versions pinned — a version catalog (libs.versions.toml) is a plus.
  • README: what it does, how to build, run and test it in three commands, screenshots for an app or curl lines for an API, and the decisions you made — including what you deliberately left out.
  • Tests: unit tests for the ViewModel or service logic with a fake repository; coroutine code tested with runTest; for backend database code, an integration test against a real Postgres (Testcontainers) beats mocks.
  • Architecture that matches the size: for Android, UI → ViewModel with StateFlow → repository; for backend, routes/controllers → service → repository. No five-layer clean architecture for a two-screen app.
  • Idiomatic Kotlin: val by default, data classes for models, a sealed interface for UI state or results, no !!, no GlobalScope, no runBlocking outside tests and main.
  • Error and loading states handled: airplane mode, an empty list and a 500 response each show something sensible. Reviewers test these first.
  • Lint clean: ktlint or detekt run once, and no compiler warnings left behind.
  • No noise in the repo: a .gitignore for build/, .gradle/, .idea/ and local.properties; no API keys committed.
  • Commits: a handful of meaningful commits, not one "final" dump. Reviewers read the history.
  • Time-box to what they asked (usually 3–4 hours) and say so in the README. Five half-done extras are a red flag; one finished extra is fine.
The sentence reviewers want to write
"Built first time, tests cover the edge cases, the code reads like the team already wrote it." Aim every decision at that sentence. The next module, Job Ready, turns the same standards into a portfolio.

Frequently asked questions

What Kotlin topics are asked most in interviews?
At junior level: null safety operators, val versus var, == versus ===, data classes, sealed classes, object and companion object, and scope functions. At mid level: sequences, variance, inline and reified, coroutines with structured concurrency and cancellation, Flow versus StateFlow, and Java interop. Senior rounds add Android architecture or backend design, Kotlin Multiplatform, migration from Java and production debugging.
Do Kotlin interviews ask about coroutines?
Almost always from mid level upward, on both Android and backend tracks. Expect launch versus async, structured concurrency, dispatchers and withContext, cooperative cancellation (including the bug of catching CancellationException), and the difference between cold Flow and hot StateFlow.
Is a Kotlin interview different for Android and backend roles?
The core language questions are the same. Android interviews then focus on ViewModel, Jetpack Compose, Room, lifecycle and offline behaviour; backend interviews focus on Spring Boot or Ktor, databases, blocking I/O with coroutines and service design.

Finish the Kotlin handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.