Kotlin is the curriculum’s JVM lane and its shared-logic lane at once. It gives you a modern, null-safe
language for backend services with Ktor, and the only mainstream way to write your domain rules once and
run them on Android, iOS, and the server. This track moves from null-safety and coroutines to Ktor, then to
Kotlin Multiplatform’s expect/actual and shared source sets — tagged by level so you read only as deep
as you need.
Install Kotlin and run your first program
BeginnerInstall a JDK 17+ and the Kotlin compiler, then compile and run a single .kt file.
Why the JVM, and what Kotlin adds on top of Java
Kotlin compiles to JVM bytecode, so it runs anywhere Java runs and uses the entire Java library ecosystem. What it adds is the language: null-safety in the type system, concise data classes, sealed hierarchies, and coroutines for concurrency. You get Java’s reach and maturity with far less ceremony — which is why this curriculum uses Kotlin (not Java) as the JVM default.
Compile and run a single file
# Verify a JDK is present (17 or newer for this track)
java -version
# Install the compiler (SDKMAN is the common path on macOS/Linux)
sdk install kotlin
cat > Hello.kt <<'EOF'
fun main() {
println("Hello from Kotlin on the JVM")
}
EOF
kotlinc Hello.kt -include-runtime -d hello.jar
java -jar hello.jarMake nulls impossible by default
BeginnerDeclare types non-nullable, opt into nulls with ?, and handle them with ?., ?:, and (rarely) !!.
Null-safety is in the type system, not a convention
In Kotlin a String can never be null; only a String? can. The compiler refuses to dereference a nullable
value until you’ve handled the null case, which eliminates most NullPointerExceptions at compile time. The
tools you use: the safe call ?. returns null instead of throwing; the Elvis operator ?: supplies a
fallback; and the not-null assertion !! forces a non-null read and throws if you’re wrong — reserve it
for cases you can prove, because it’s the one operator that brings the NPE back.
The null-safety operators
fun greeting(name: String?): String {
// safe call + Elvis: never throws, always returns a String
return "Hello, ${name?.trim() ?: "stranger"}"
}
fun main() {
println(greeting(" Ada ")) // Hello, Ada
println(greeting(null)) // Hello, stranger
val forced: String = nullableMaybe()!! // throws if nullableMaybe() is null — use only when proven
println(forced.length)
}
fun nullableMaybe(): String? = "ok"Chat prompt — paste into a chat to get the code
Role: Kotlin teacher. The reader has no repo access here — return complete, runnable code.
Task: Show a parseUser(raw: String?) function that returns a User on valid input or null on bad input,
using only null-safe operators (no try/catch for control flow).
Requirements:
- Input is "name:age" (e.g. "Ada:36"); age must parse to a non-negative Int.
- Use String?.split, toIntOrNull(), the Elvis operator, and a safe call — never !!.
- Define `data class User(val name: String, val age: Int)`.
Tests / acceptance (describe, since no repo):
- parseUser("Ada:36") == User("Ada", 36)
- parseUser("Ada:-1"), parseUser("Ada"), parseUser(null) all == null
Output: the complete file (a fun main demonstrating each case), no commentary.Model data with data classes
BeginnerDefine a data class for plain data, and use it with destructuring and copy.
What a data class generates for you
Marking a class data makes the compiler generate equals/hashCode, a readable toString, copy(...)
for making modified duplicates, and componentN() functions for destructuring (val (id, name) = product).
That removes the boilerplate that dominates equivalent Java. Data classes are immutable when you declare
their properties val, which is the default you should reach for — immutable values are safe to pass across
coroutines and threads.
A data class in use
data class Product(val id: String, val name: String, val unitPriceCents: Long)
fun main() {
val widget = Product(id = "p1", name = "Widget", unitPriceCents = 1999)
// copy with one field changed — the original is untouched
val onSale = widget.copy(unitPriceCents = 1499)
// destructuring via componentN()
val (id, name, _) = onSale
println("$id $name ${onSale.unitPriceCents}") // p1 Widget 1499
println(widget == onSale) // false — value equality, generated
}Make illegal states unrepresentable with sealed classes
IntermediateModel a closed set of outcomes as a sealed hierarchy and branch on it with an exhaustive when.
Why sealed + when beats exceptions for expected outcomes
A sealed class (or sealed interface) declares a closed set of subtypes known at compile time. When you
branch over a sealed type with when, the compiler checks exhaustiveness — add a new subtype later and every
when that forgot it stops compiling, pointing you straight at the gap. This is how you model expected
results (success vs. validation error vs. not-found) as values instead of throwing exceptions for ordinary
control flow. The when used as an expression must be exhaustive, so no else branch is needed once every
case is covered.
A sealed result type
sealed interface LoadResult {
data class Ok(val product: Product) : LoadResult
data class Invalid(val reason: String) : LoadResult
data object NotFound : LoadResult
}
fun describe(r: LoadResult): String = when (r) {
is LoadResult.Ok -> "loaded ${r.product.name}"
is LoadResult.Invalid -> "rejected: ${r.reason}"
LoadResult.NotFound -> "404"
// no else: add a new subtype and this when stops compiling until handled
}Agent prompt — paste into an agent with repo access
Role: Senior Kotlin engineer working in this repo.
Context: Gradle project, Kotlin 2.x, JUnit 5 on the classpath. A `data class Product(val id: String, val name: String, val unitPriceCents: Long)` already exists.
Task: Add a sealed interface LoadResult (Ok/Invalid/NotFound) and a function loadProduct(id: String, source: Map<String, Product>) returning a LoadResult.
Requirements:
- Blank id -> Invalid("id required"); missing id in source -> NotFound; present -> Ok(product).
- Branch with an exhaustive `when` and NO `else` branch.
- Pure function, no exceptions for control flow.
Tests / acceptance:
- `./gradlew test` passes a JUnit 5 test covering all three branches.
- Removing one `when` branch must fail compilation (note this in the summary).
Output: a unified diff plus a one-paragraph rationale for sealed-over-exceptions.Run concurrent work with coroutines and structured concurrency
IntermediateLaunch suspending work inside a coroutineScope, run pieces in parallel with async, and await them.
Structured concurrency: children can't outlive their scope
A coroutine is a suspendable computation — far cheaper than a thread, so you can run thousands. Structured
concurrency is the rule that every coroutine is launched inside a scope and cannot outlive it: when the
scope finishes (or fails, or is cancelled) all its children are cancelled too, so nothing leaks. coroutineScope
creates such a scope and only returns once every child has finished; async starts a parallel piece and
returns a Deferred you await. If one child throws, its siblings are cancelled and the exception
propagates — no orphaned background work.
Parallel fetches that cancel together
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
suspend fun fetch(id: String): Product { /* suspends on I/O */ return Product(id, "name", 0) }
// runs the two fetches in parallel; if either fails, the other is cancelled
suspend fun loadPair(a: String, b: String): Pair<Product, Product> = coroutineScope {
val first = async { fetch(a) }
val second = async { fetch(b) }
first.await() to second.await()
}# coroutines live in a published library; add it via Gradle:
# implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
./gradlew buildAgent prompt — paste into an agent with repo access
Role: Senior Kotlin engineer working in this repo.
Context: Gradle project, Kotlin 2.x, kotlinx-coroutines-core 1.8.x and kotlinx-coroutines-test on the classpath. A suspend fun fetch(id: String): Product exists.
Task: Implement `suspend fun enrich(ids: List<String>): List<Product>` that fetches all ids concurrently using coroutineScope + async, preserving input order.
Requirements:
- Use awaitAll() (or map { async { } } then awaitAll) — do NOT launch unstructured GlobalScope coroutines.
- If any fetch throws, the whole call fails and siblings are cancelled (rely on structured concurrency).
- Result order matches input order.
Tests / acceptance:
- `./gradlew test` passes a runTest-based test with a fake fetch that delays, asserting order is preserved.
- A test where one id's fetch throws asserts enrich() rethrows and no coroutine leaks.
Output: a unified diff plus a one-paragraph explanation of why no manual cancellation code is needed.Stream values over time with Flow
IntermediateModel a stream of values as a Flow, transform it with operators, and collect it from a coroutine.
Flow is the cold, coroutine-native stream
A Flow<T> is an asynchronous stream of values that does nothing until collected — cold, like a recipe you
re-run per collector. You build it with flow { emit(...) } and shape it with familiar operators (map,
filter, debounce), all suspending and back-pressure-aware. Because collection is a suspend function, a
Flow is automatically tied to the collecting coroutine’s scope — cancel the scope and the stream stops. This
is the idiom for search-as-you-type, live updates, and any “values arriving over time” problem, and it’s
shareable across Multiplatform.
Build, transform, and collect a Flow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
fun priceUpdates(): Flow<Long> = flow {
emit(1999)
emit(1499) // a later price arrives
}
fun main() = runBlocking {
priceUpdates()
.map { cents -> "$%.2f".format(cents / 100.0) }
.collect { println(it) } // $19.99 then $14.99
}Serve HTTP with Ktor
IntermediateCreate a Ktor application, install ContentNegotiation, and respond to GET /healthz with JSON.
Why Ktor is the JVM-backend default here
Ktor is JetBrains’ coroutine-native server framework: routes are suspend functions, so I/O never blocks a
thread the way a classic servlet stack does. It’s lighter than Spring Boot — you install only the plugins
you need (content negotiation, auth, call logging) — which keeps startup fast and the surface small. This is
the lane that, in a Java shop, Spring would occupy; here it’s Kotlin + Ktor. The kotlinx.serialization
plugin turns a @Serializable data class into JSON with no reflection at runtime.
A minimal Ktor server
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable
@Serializable
data class Health(val status: String)
fun main() {
embeddedServer(Netty, port = 8080) {
install(ContentNegotiation) { json() }
routing {
get("/healthz") { call.respond(Health("ok")) }
}
}.start(wait = true)
}./gradlew run
# in another terminal:
curl -s localhost:8080/healthz # {"status":"ok"}Agent prompt — paste into an agent with repo access
Role: Senior Kotlin/Ktor engineer working in this repo.
Context: Gradle project, Kotlin 2.x, Ktor 2.x with the Netty engine, ContentNegotiation + kotlinx-serialization json plugins, and ktor-server-test-host on the classpath.
Task: Add a `GET /products/{id}` route returning a @Serializable Product as JSON, or HTTP 404 with {"error":"not found"} when unknown.
Requirements:
- Read the path parameter with call.parameters["id"]; a blank/missing id returns 400.
- Use call.respond with HttpStatusCode for 404/400; never string-concatenate JSON by hand.
- Look products up in an in-memory Map<String, Product> for now.
Tests / acceptance:
- `./gradlew test` passes a testApplication-based test asserting 200+body for a known id and 404 for an unknown id.
- A blank id path returns 400.
Output: a unified diff plus a one-paragraph note on why routes are suspend functions.Set up a Kotlin Multiplatform module and its source sets
AdvancedCreate a KMP library module with commonMain, androidMain, and iosMain source sets in Gradle.
What commonMain/androidMain/iosMain actually mean
Kotlin Multiplatform lets one module produce artifacts for several targets. Its source sets are the heart of
the model: code in commonMain is compiled for every target and may use only multiplatform-safe APIs;
androidMain and iosMain hold platform-specific code and may call the Android SDK or Apple frameworks
respectively. The Android app and the iOS app each consume the same shared module, so your domain rules,
validation, and networking live once in commonMain instead of being reimplemented per platform. The
kotlin { } block in build.gradle.kts declares the targets that create these source sets.
Declare targets in build.gradle.kts
// shared/build.gradle.kts
plugins {
kotlin("multiplatform")
// plus the Android library plugin (id("com.android.library")), omitted here
}
kotlin {
androidTarget() // creates androidMain
iosArm64(); iosSimulatorArm64() // create iosMain (device + simulator)
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}
// commonTest, androidMain, iosMain are created automatically by the targets above
}
}# Compile the shared module for every declared target:
./gradlew :shared:assembleAgent prompt — paste into an agent with repo access
Role: Senior Kotlin Multiplatform engineer working in this repo.
Context: A Gradle project where the Kotlin Multiplatform plugin is already available. Create a new module `shared`.
Task: Configure shared/build.gradle.kts with androidTarget(), iosArm64(), and iosSimulatorArm64(), plus a commonMain dependency on kotlinx-coroutines-core 1.8.x.
Requirements:
- Source sets commonMain/commonTest must exist; do not hand-create androidMain/iosMain dirs that the targets already generate.
- Put a single `data class Product` in commonMain so both platforms can use it.
- Keep platform SDK calls out of commonMain.
Tests / acceptance:
- `./gradlew :shared:assemble` succeeds (compiles every declared target).
- `./gradlew :shared:allTests` runs a commonTest that constructs a Product.
Output: a unified diff plus a one-paragraph map of which code goes in which source set.Bridge to each platform with expect/actual
AdvancedDeclare an expect function in commonMain and supply an actual implementation in androidMain and iosMain.
expect/actual is the typed seam between shared and native
Sometimes shared code needs something only the platform can provide — a device identifier, the current
timestamp formatter, a secure store. expect declares the signature in commonMain; each platform source
set provides a matching actual with the real implementation. The compiler enforces that every target has
an actual for every expect, so you can’t ship common code that references a platform capability one
target forgot to supply. This keeps the shared surface honest: commonMain stays platform-free, and the
native glue is small, explicit, and checked.
One expect, two actuals
// commonMain/.../Platform.kt
expect fun platformName(): String
// androidMain/.../Platform.android.kt
import android.os.Build
actual fun platformName(): String = "Android ${Build.VERSION.SDK_INT}"
// iosMain/.../Platform.ios.kt
import platform.UIKit.UIDevice
actual fun platformName(): String =
UIDevice.currentDevice.systemName + " " + UIDevice.currentDevice.systemVersion# Verify every target supplies its actual:
./gradlew :shared:compileKotlinIosArm64 :shared:assembleDebugAgent prompt — paste into an agent with repo access
Role: Senior Kotlin Multiplatform engineer working in this repo.
Context: A `shared` KMP module with commonMain, androidMain, iosMain source sets (androidTarget + iosArm64/iosSimulatorArm64 already configured).
Task: Add `expect fun nowEpochMillis(): Long` in commonMain with platform actuals.
Requirements:
- Android actual uses System.currentTimeMillis(); iOS actual uses NSDate().timeIntervalSince1970 * 1000 (platform.Foundation.NSDate).
- commonMain must not import any android.* or platform.* symbols.
- Add a commonMain function `fun isStale(lastSeen: Long, ttlMs: Long): Boolean = nowEpochMillis() - lastSeen > ttlMs`.
Tests / acceptance:
- `./gradlew :shared:compileKotlinIosArm64` and `:shared:assembleDebug` both compile (every expect has an actual).
- A commonTest asserts isStale(0, 1) is true given a positive clock.
Output: a unified diff plus a one-paragraph note on what breaks if an actual is missing.Where to take it next
- Build the cross-platform health app in Vitals, where Kotlin Multiplatform shares the domain, validation, and sync logic across the Android and iOS clients.
- Put a native Android UI on top of your shared module with Jetpack Compose —
the declarative view layer that consumes your
commonMainlogic. - Render the same shared logic on Apple platforms with SwiftUI, calling into the iOS framework your KMP module produces.