Free Handbook · Every example compiled & verified

Kotlin + Tools

Nine mini-labs on Kotlin as it is used at work: Gradle, JUnit 5, Ktor, Spring Boot, PostgreSQL, Android, Jetpack Compose, Docker and Git.

0 / 136 lessons🔥 0 day streak
ShareXLinkedIn

Module 12 · what you'll be able to do

  • Read and write a Gradle Kotlin DSL build file, and run the standard build, test and run tasks
  • Write JUnit 5 tests in Kotlin with readable backtick names, parameterized cases and assertThrows
  • Build a small JSON API with Ktor and the same endpoint with Spring Boot, and query PostgreSQL safely
  • Wire an Android screen from a Room database through a ViewModel to a Jetpack Compose UI
  • Package a Kotlin service in a multi-stage Docker image and keep build output out of Git
01

The Kotlin toolchain at a glance

Every program so far has been one Main.kt compiled with kotlinc. Real Kotlin projects have many files and dozens of libraries, so a job listing that says "Kotlin" nearly always means Kotlin plus Gradle, a test framework, and either a backend framework (Ktor or Spring Boot) or the Android stack. These labs show the smallest real version of each. The code uses libraries, so it is shown as static snippets rather than verified examples.

ToolJobYou meet it when
GradleDownload libraries, compile, test, packageDay one of any Kotlin job
JUnit 5 (+ kotlin.test)Unit testsEvery pull request
KtorLightweight, coroutine-first web servers and HTTP clientsKotlin-first backends, microservices
Spring BootFull-featured web framework, dependency injectionMost enterprise backend roles
PostgreSQL via JDBCStore and query dataAny service that stores data
Android + Jetpack ComposeMobile apps and their UIAndroid roles — the largest Kotlin market
DockerShip the service with its runtimeDeploying a backend anywhere
GitVersion controlEvery day
Versions
The version numbers in these labs were current releases when this page was written. Libraries move fast; check Maven Central or the project's site for the newest release, and keep the Kotlin plugin, Compose compiler and kotlinx libraries on matching versions.
02

Kotlin + Gradle

Gradle is the standard build tool for Kotlin, and its build files are themselves written in Kotlin (build.gradle.kts, the Kotlin DSL). A build file applies plugins (the Kotlin compiler, "this is a runnable application"), declares where libraries come from (mavenCentral()) and which ones you need, and configures tasks. Sources live in src/main/kotlin, tests in src/test/kotlin.

Kotlin + Gradle

A minimal build.gradle.kts for a Kotlin app with tests

implementation dependencies ship with the app; testImplementation ones are only on the test classpath. jvmToolchain(21) makes Gradle compile for Java 21 whatever JDK is installed (it can download one). The application plugin adds run and installDist; mainClass is MainKt because top-level functions in Main.kt compile into that class.

kotlin
// build.gradle.kts
plugins {
    kotlin("jvm") version "2.4.20"
    application
}

group = "com.example"
version = "1.0.0"

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")

    testImplementation(kotlin("test"))
    testImplementation("org.junit.jupiter:junit-jupiter:5.13.4")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

kotlin {
    jvmToolchain(21)
}

application {
    mainClass.set("com.example.MainKt")
}

tasks.test {
    useJUnitPlatform()
}

// ./gradlew run            compile and run main
// ./gradlew test           run the tests (report: build/reports/tests)
// ./gradlew build          compile + test + package build/libs/*.jar
// ./gradlew dependencies   see every library and why it is there
Always use the wrapper
Projects commit gradlew, gradlew.bat and gradle/wrapper/ so everyone and every CI job builds with the same Gradle version. Type ./gradlew test, never a globally installed gradle. Bigger projects move versions into gradle/libs.versions.toml (a version catalog) and reference them as libs.ktor.server.core.
03

Kotlin + JUnit 5

JUnit 5 is the test framework for Kotlin on the JVM, usually together with kotlin.test assertions. Kotlin adds one pleasant trick: a function name in backticks may contain spaces, so a test reads like a sentence in the report. Tests are ordinary classes in src/test/kotlin; Gradle runs them with ./gradlew test.

Kotlin + JUnit5

Unit tests with a parameterized case and an expected exception

@ParameterizedTest with @CsvSource runs one test body over several rows. assertThrows<T> { } is JUnit's Kotlin extension: it fails the test unless the block throws that exception type, and returns the exception so you can check its message. For suspend functions, wrap the test body in runTest { } from kotlinx-coroutines-test.

kotlin
// src/main/kotlin/PriceCalculator.kt
class PriceCalculator(private val taxRate: Double) {
    fun gross(net: Double): Double {
        require(net >= 0) { "net price must not be negative, was $net" }
        return net * (1 + taxRate)
    }
}

// src/test/kotlin/PriceCalculatorTest.kt
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import kotlin.test.assertEquals

class PriceCalculatorTest {
    private val calc = PriceCalculator(taxRate = 0.18)

    @Test
    fun `adds tax to the net price`() {
        assertEquals(118.0, calc.gross(100.0), absoluteTolerance = 0.001)
    }

    @ParameterizedTest
    @CsvSource("0.0, 0.0", "50.0, 59.0", "10.0, 11.8")
    fun `gross price table`(net: Double, expected: Double) {
        assertEquals(expected, calc.gross(net), absoluteTolerance = 0.001)
    }

    @Test
    fun `rejects a negative price`() {
        val e = assertThrows<IllegalArgumentException> { calc.gross(-1.0) }
        assertEquals("net price must not be negative, was -1.0", e.message)
    }
}
Mocks and fakes
Kotlin classes are final by default, which trips up older Java mocking setups. Kotlin teams use MockK (every { repo.find(1) } returns book, coEvery for suspend functions) or, often better, a small hand-written fake that implements the same interface. Many also use Kotest or AssertJ for richer assertions on top of the JUnit 5 platform.
04

Kotlin + Ktor

Ktor is JetBrains' own web framework, written in Kotlin and built on coroutines: every route handler is a suspend lambda, so waiting for a database or another service never blocks a thread. It is small and explicit — you install only the plugins you need (JSON, authentication, CORS) — which makes it popular for Kotlin-first microservices. The same library family includes an HTTP client used on Android and Kotlin Multiplatform.

Kotlin + Ktor

A JSON API with a path parameter and proper status codes

Dependencies: io.ktor:ktor-server-netty, ktor-server-content-negotiation and ktor-serialization-kotlinx-json (version 3.x), plus the kotlin("plugin.serialization") Gradle plugin that makes @Serializable work. return@get leaves the handler lambda early — a labelled return, from Functions & Lambdas. curl localhost:8080/books/1 returns {"id":1,"title":"Kotlin in Action"}; /books/x returns 400 and /books/9 returns 404.

kotlin
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable

@Serializable
data class Book(val id: Int, val title: String)

val books = mapOf(
    1 to Book(1, "Kotlin in Action"),
    2 to Book(2, "Atomic Kotlin"),
)

fun main() {
    embeddedServer(Netty, port = 8080) {
        install(ContentNegotiation) { json() }

        routing {
            get("/health") { call.respondText("ok") }

            get("/books/{id}") {
                val id = call.parameters["id"]?.toIntOrNull()
                    ?: return@get call.respond(HttpStatusCode.BadRequest, "id must be a number")
                val book = books[id]
                    ?: return@get call.respond(HttpStatusCode.NotFound, "no book $id")
                call.respond(book)          // serialized to JSON
            }
        }
    }.start(wait = true)
}
Test it without a server
ktor-server-test-host gives you testApplication { val res = client.get("/books/1"); assertEquals(HttpStatusCode.OK, res.status) }, which runs the whole routing pipeline in memory — fast enough for every pull request.
05

Kotlin + Spring Boot

Spring Boot is the most used JVM backend framework, and it supports Kotlin as a first-class language: the project generator at start.spring.io offers Kotlin and Gradle Kotlin DSL directly. You write the same controllers and services as in Java, with less code — constructor injection is just a primary constructor, DTOs are data classes, and nullable types tell Spring which parameters are optional.

Kotlin + Spring Boot

A REST controller with constructor injection

The generator adds two Kotlin plugins you should know about. kotlin("plugin.spring") opens the classes Spring must subclass (Kotlin classes are final by default, and Spring's proxies need to extend them). jackson-module-kotlin teaches Jackson to build data classes through their constructors. A GET /books/9 for a missing book returns 404; POST /books returns 201 with the saved book.

kotlin
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Repository
import org.springframework.web.bind.annotation.*
import java.util.concurrent.ConcurrentHashMap

@SpringBootApplication
class BooksApplication

fun main(args: Array<String>) {
    runApplication<BooksApplication>(*args)
}

data class Book(val id: Long, val title: String)

@Repository
class BookRepository {
    private val store = ConcurrentHashMap<Long, Book>()
    fun find(id: Long): Book? = store[id]
    fun save(book: Book): Book = book.also { store[it.id] = it }
}

@RestController
@RequestMapping("/books")
class BookController(private val repo: BookRepository) {   // injected by Spring

    @GetMapping("/{id}")
    fun get(@PathVariable id: Long): ResponseEntity<Book> =
        repo.find(id)?.let { ResponseEntity.ok(it) } ?: ResponseEntity.notFound().build()

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun create(@RequestBody book: Book): Book = repo.save(book)
}
Coroutines in Spring
With Spring WebFlux, controller functions can be suspend fun and can return Flow<T>, so the concurrency model from Coroutines carries straight into Spring. If you already know Spring from Java, the Java handbook covers the same framework from the Java side.
06

Kotlin + PostgreSQL

On the JVM every SQL database is reached through JDBC, and Kotlin makes plain JDBC pleasant: use { } closes connections, statements and result sets even when an exception is thrown (Kotlin's try-with-resources), and buildList { } turns a result set into a list. Always bind values with ? placeholders — never build SQL with string templates, which is how SQL injection happens.

Kotlin + PostgreSQL

A parameterized query mapped to a data class

Dependency: org.postgresql:postgresql:42.7.7. JDBC calls block, so the function switches to Dispatchers.IO and is safe to call from a coroutine. In production, take connections from a pool (HikariCP, which Spring Boot configures for you) rather than DriverManager. For SQL itself — joins, indexes, query plans — see SQL Mastery.

kotlin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.sql.DriverManager

data class Customer(val id: Long, val email: String)

class CustomerDao(private val url: String, private val user: String, private val password: String) {

    suspend fun byDomain(domain: String): List<Customer> = withContext(Dispatchers.IO) {
        DriverManager.getConnection(url, user, password).use { conn ->
            conn.prepareStatement(
                "SELECT id, email FROM customers WHERE email LIKE ? ORDER BY id"
            ).use { stmt ->
                stmt.setString(1, "%@$domain")          // bound, not concatenated
                stmt.executeQuery().use { rs ->
                    buildList {
                        while (rs.next()) add(Customer(rs.getLong("id"), rs.getString("email")))
                    }
                }
            }
        }
    }
}

// val dao = CustomerDao("jdbc:postgresql://localhost:5432/shop", "shop", System.getenv("DB_PASSWORD"))
// dao.byDomain("example.com")  ->  [Customer(id=1, [email protected]), ...]
Beyond raw JDBC
Kotlin teams usually add a thin layer: Exposed (JetBrains' type-safe SQL DSL), jOOQ, or Spring Data (JPA or JDBC) in Spring projects. On Android the equivalent is Room over SQLite, shown in the next lab. Whatever the layer, the rules stay the same: parameters, not string templates; close what you open; keep blocking calls off the main thread.
07

Kotlin + Android

Android is Kotlin's biggest job market, and Google recommends Kotlin for all new Android code. A modern screen is layered: a Room database (SQLite with generated code) exposes data as a Flow, a ViewModel turns it into UI state with stateIn, and the UI collects that state. Every piece uses what you learned in Coroutines.

Kotlin + Android

Room DAO and a ViewModel exposing StateFlow

Room generates the DAO implementation at build time (through the KSP plugin). A DAO function returning Flow re-emits whenever the table changes, so the screen updates by itself after an insert. stateIn(viewModelScope, WhileSubscribed(5_000), emptyList()) keeps the query alive while the screen is visible and for five seconds after — long enough to survive a screen rotation without re-querying.

kotlin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.room.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val text: String,
)

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY id DESC")
    fun observeAll(): Flow<List<Note>>        // emits again on every change

    @Insert
    suspend fun insert(note: Note)             // Room runs it off the main thread
}

class NotesViewModel(private val dao: NoteDao) : ViewModel() {

    val notes: StateFlow<List<Note>> = dao.observeAll()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    fun add(text: String) {
        if (text.isBlank()) return
        viewModelScope.launch { dao.insert(Note(text = text.trim())) }
    }
}
What Android interviews check
Lifecycle (why a ViewModel survives rotation and an Activity does not), where coroutines are launched and cancelled, state hoisting and unidirectional data flow, dependency injection with Hilt, and offline-first data with Room. Being able to explain the three layers above out loud covers a large part of a typical Android interview.
08

Kotlin + Jetpack Compose

Jetpack Compose is Android's UI toolkit, and it is pure Kotlin: a screen is a @Composable function that describes the UI for the current state. When the state changes, Compose calls the function again and updates only what changed (recomposition). Trailing lambdas, named and default arguments and extension functions from earlier modules are exactly what make Compose code read like a layout.

Kotlin + Jetpack Compose

A notes screen with hoisted state

NotesRoute connects to the ViewModel from the Android lab; NotesScreen takes plain values and callbacks, so it can be previewed and tested without a ViewModel — that split is called state hoisting. remember { mutableStateOf("") } keeps the text field's draft across recompositions; collectAsStateWithLifecycle() stops collecting while the app is in the background.

kotlin
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun NotesRoute(viewModel: NotesViewModel) {
    val notes by viewModel.notes.collectAsStateWithLifecycle()
    NotesScreen(notes = notes, onAdd = viewModel::add)
}

@Composable
fun NotesScreen(notes: List<Note>, onAdd: (String) -> Unit) {
    var draft by remember { mutableStateOf("") }

    Column(Modifier.fillMaxSize().padding(16.dp)) {
        Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
            OutlinedTextField(
                value = draft,
                onValueChange = { draft = it },
                label = { Text("New note") },
                modifier = Modifier.weight(1f),
            )
            Spacer(Modifier.width(8.dp))
            Button(onClick = { onAdd(draft); draft = "" }, enabled = draft.isNotBlank()) {
                Text("Add")
            }
        }
        LazyColumn(Modifier.padding(top = 16.dp)) {
            items(notes, key = { it.id }) { note ->
                Text(note.text, Modifier.padding(vertical = 8.dp))
            }
        }
    }
}
Compose runs beyond Android
Compose Multiplatform (from JetBrains) runs the same composables on desktop, iOS and the web, and Kotlin Multiplatform shares ViewModels and data layers between Android and iOS. The Kotlin you have learned in this handbook is the same everywhere; only the UI entry point changes.
09

Kotlin + Docker

A Kotlin backend (Ktor or Spring Boot) ships as a Docker image. The standard pattern is a multi-stage build: the first stage has a full JDK and Gradle and builds the app; the second stage copies only the result onto a much smaller JRE image. Build tools and source code never reach production, and the image is a fraction of the size.

Kotlin + Docker

A multi-stage Dockerfile that runs as a non-root user

Copying the Gradle files and resolving dependencies before copying src lets Docker cache that slow layer: editing code does not re-download every library. installDist (from the application plugin) produces build/install/<project>/ with a start script and every JAR. Build with docker build -t books . and run with docker run -p 8080:8080 books.

dockerfile
# ---- build stage ----
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src

COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle ./gradle
RUN ./gradlew --no-daemon dependencies > /dev/null

COPY src ./src
RUN ./gradlew --no-daemon installDist

# ---- runtime stage ----
FROM eclipse-temurin:21-jre
RUN useradd --system --uid 10001 app
WORKDIR /app
COPY --from=build /src/build/install/books/ ./
USER app
EXPOSE 8080
ENTRYPOINT ["./bin/books"]
Memory limits
The JVM sizes its heap from the container's memory limit (by default a quarter of it). For a service in a 512 MB container, set JAVA_OPTS="-XX:MaxRAMPercentage=75" (the installDist start script reads JAVA_OPTS) so the app can actually use the memory you pay for.
10

Kotlin + Git

A Kotlin repository should contain source, build scripts and the Gradle wrapper — and nothing Gradle or the IDE can regenerate. Committing build/ or .gradle/ bloats the repository and causes constant merge conflicts; committing local.properties leaks a machine-specific SDK path (and sometimes secrets).

Kotlin + Git

A .gitignore for Gradle, IntelliJ and Android projects

The negated line keeps gradle-wrapper.jar, which the wrapper needs, even though other JARs are ignored. .kotlin/ holds the Kotlin Gradle plugin's local caches. Run git status after the first build: if anything from build/ shows up, the ignore file is wrong.

bash
# .gitignore
.gradle/
build/
.kotlin/
out/
*.jar
!gradle/wrapper/gradle-wrapper.jar

# IntelliJ / Android Studio
.idea/
*.iml

# Android
local.properties
*.apk
*.aab
captures/

# secrets never belong in Git
.env
*.keystore

# --- everyday commands ---
# git switch -c feature/book-search     new branch
# ./gradlew test                        run tests before every commit
# git add -p                            stage hunk by hunk
# git commit -m "Add book search endpoint"
# git push -u origin feature/book-search
Gradle Kotlin DSL
Gradle build files written in Kotlin (build.gradle.kts), with IDE completion and type checking.
Gradle wrapper
The committed gradlew script that downloads and runs the project's exact Gradle version.
implementation / testImplementation
Dependency configurations: shipped with the app, or only on the test classpath.
Ktor
JetBrains' coroutine-based Kotlin framework for HTTP servers and clients.
plugin.spring (all-open)
A Kotlin compiler plugin that makes Spring-annotated classes open so Spring can proxy them.
use { }
Kotlin's try-with-resources: runs a block and always closes the resource afterwards.
Room
Android's SQLite library; generates DAO code and exposes queries as Flow.
Composable
A @Composable function that describes UI for the current state and is re-run when that state changes.
State hoisting
Moving state out of a composable into its caller, passing values down and events up.
Multi-stage build
A Dockerfile that builds in one image and copies only the result into a smaller runtime image.
Quick check

Why does a Kotlin Spring Boot project need the kotlin("plugin.spring") compiler plugin?

Quick check

In the Dockerfile above, why are build.gradle.kts and the wrapper copied before src?

Frequently asked questions

Should I learn Ktor or Spring Boot for Kotlin backend jobs?
Spring Boot has far more job listings, because many companies moved existing Java Spring services to Kotlin. Ktor is common in Kotlin-first teams and startups and is the simpler way to learn how a web server works. Learn Spring Boot for the job market and Ktor for understanding; the Kotlin you write in either is the same.
Do I need Gradle to write Kotlin?
Not to learn: single files compile with kotlinc, and every example in this handbook was verified that way. Any real project uses a build tool, and for Kotlin that is almost always Gradle with the Kotlin DSL. Maven also supports Kotlin through the kotlin-maven-plugin and appears in some Java-heavy companies.
Is Jetpack Compose replacing XML layouts on Android?
For new code, yes: Google recommends Compose for new Android UI, and most new screens are written in it. Many existing apps still contain XML layouts and Views, so Android roles often expect you to read both and to migrate screens from one to the other.

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.