← All tech

Backend / language

Kotlin

Modern JVM backends plus logic you share across Android, iOS, and the server.

  • Null-safe, expressive code with sealed classes & data classes
  • Structured concurrency with coroutines and Flow
  • Ktor HTTP services on the JVM (the Spring/Java lane, modernised)
  • Sharing real logic across Android + iOS (+ server) with Multiplatform
Use it when

Reach for Kotlin when you want a modern, null-safe JVM backend, or when one team needs to share validation, networking, and domain rules across an Android app, an iOS app, and the server without rewriting them three times.

Reach for something else when

Skip Kotlin when you want the smallest possible container and fastest cold start (use Go), when you need bare-metal control with no runtime (use Rust), or when the work is pure UI for a single platform (use Jetpack Compose or SwiftUI directly).

Official docs ↗


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

Beginner

Install 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
Run these in your terminal / editor
# 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.jar

Make nulls impossible by default

Beginner

Declare 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
Run these in your terminal / editor
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
For a plain chat. It returns complete code; you paste it in yourself.
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

Beginner

Define 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
Run these in your terminal / editor
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

Intermediate

Model 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
Run these in your terminal / editor
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
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Intermediate

Launch 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
Run these in your terminal / editor
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 build
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Intermediate

Model 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
Run these in your terminal / editor
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

Intermediate

Create 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
Run these in your terminal / editor
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
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Advanced

Create 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
Run these in your terminal / editor
// 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:assemble
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Advanced

Declare 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
Run these in your terminal / editor
// 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:assembleDebug
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
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.

Share one networking client across server, Android, and iOS

Advanced

Put a Ktor client and serialization in commonMain so all three targets reuse the same API code.

The payoff: write the API layer once

Ktor ships a multiplatform client (not just the server). Placed in commonMain with kotlinx.serialization, your request/response models and the call that fetches them compile for the JVM server, the Android app, and the iOS app from one source. Each target plugs in its own engine — CIO or OkHttp on the JVM/Android, Darwin on iOS — via expect/actual or a per-source-set dependency, while the calling code stays shared. The result is the Multiplatform promise made concrete: a single, tested networking and domain layer, and thin native UI on top (Jetpack Compose on Android, SwiftUI on iOS).

A shared client in commonMain
Run these in your terminal / editor
// commonMain — compiled for JVM, Android, and iOS
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.Serializable

@Serializable
data class Product(val id: String, val name: String, val unitPriceCents: Long)

class ProductApi(private val client: HttpClient) {
    suspend fun product(id: String): Product =
        client.get("https://api.example.com/products/$id").body()
}

fun jsonClient(): HttpClient = HttpClient {
    install(ContentNegotiation) { json() }
}
# Engines are per-target dependencies, e.g.:
# androidMain: implementation("io.ktor:ktor-client-okhttp:2.3.12")
# iosMain:     implementation("io.ktor:ktor-client-darwin:2.3.12")
./gradlew :shared:assemble
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin Multiplatform engineer working in this repo.
Context: A `shared` KMP module (commonMain/androidMain/iosMain), Kotlin 2.x, Ktor 2.x multiplatform client + kotlinx-serialization, MockEngine available via ktor-client-mock in commonTest.
Task: Implement a ProductApi in commonMain with `suspend fun product(id: String): Product` calling GET /products/{id} and deserializing JSON.
Requirements:
- Models (@Serializable Product) and ProductApi live entirely in commonMain.
- The HttpClient is injected (constructor parameter) so tests can pass a MockEngine-backed client.
- Add per-target engine dependencies: ktor-client-okhttp in androidMain, ktor-client-darwin in iosMain.
Tests / acceptance:
- `./gradlew :shared:allTests` passes a commonTest using MockEngine that returns a canned product JSON and asserts the parsed result.
- `./gradlew :shared:assemble` compiles every target.
Output: a unified diff plus a one-paragraph note on which engine each target uses and why the client is injected.

Test shared code once, in commonTest

Advanced

Write tests in commonTest using the multiplatform kotlin.test API and run them on every target.

One test source, run on the JVM and on iOS

commonTest is the test source set for shared code: a test written there compiles and runs on every target the module declares. With the multiplatform kotlin.test assertions (assertEquals, assertFailsWith) and kotlinx-coroutines-test’s runTest for suspend code, you verify your domain and networking logic once and get the guarantee that it behaves identically on the JVM server and the iOS binary. This is what makes the “write logic once” claim trustworthy — the same tests gate all platforms, so a regression can’t hide on one of them.

A shared test that runs everywhere
Run these in your terminal / editor
// commonTest/.../ProductTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.coroutines.test.runTest

class ProductTest {
    @Test
    fun copyKeepsOtherFields() {
        val p = Product("p1", "Widget", 1999)
        assertEquals(1499, p.copy(unitPriceCents = 1499).unitPriceCents)
        assertEquals("Widget", p.copy(unitPriceCents = 1499).name)
    }

    @Test
    fun apiParsesJson() = runTest {
        // pass a MockEngine-backed client; assert the parsed Product
    }
}
# Runs the common tests compiled for the JVM, Android unit, and iOS simulator:
./gradlew :shared:allTests
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin Multiplatform engineer working in this repo.
Context: A `shared` KMP module with a ProductApi (injected HttpClient) and a Product data class in commonMain. kotlin-test and kotlinx-coroutines-test are on commonTest; ktor-client-mock is available.
Task: Write commonTest coverage for Product.copy semantics and ProductApi.product() JSON parsing using MockEngine.
Requirements:
- Use kotlin.test assertions (assertEquals, assertFailsWith), NOT JUnit-only APIs, so tests run on all targets.
- Suspend test uses runTest from kotlinx-coroutines-test.
- MockEngine returns a fixed JSON body; assert the deserialized Product equals the expected value.
Tests / acceptance:
- `./gradlew :shared:allTests` passes on the JVM, Android unit, and the iOS simulator targets.
Output: a unified diff plus a one-paragraph note on why kotlin.test (not raw JUnit) is required in commonTest.

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 commonMain logic.
  • Render the same shared logic on Apple platforms with SwiftUI, calling into the iOS framework your KMP module produces.