Kotlin Development Patterns
Idiomatic Kotlin patterns and best practices for building robust, efficient, and maintainable applications.
When to Use
- Writing new Kotlin code
- Reviewing Kotlin code
- Refactoring existing Kotlin code
- Designing Kotlin modules or libraries
- Configuring Gradle Kotlin DSL builds
How It Works
This skill enforces idiomatic Kotlin conventions across seven key areas: null safety using the type system and safe-call operators, immutability via val and copy() on data classes, sealed classes and interfaces for exhaustive type hierarchies, structured concurrency with coroutines and Flow, extension functions for adding behaviour without inheritance, type-safe DSL builders using @DslMarker and lambda receivers, and Gradle Kotlin DSL for build configuration.
Examples
Null safety with Elvis operator:
kotlin
1fun getUserEmail(userId: String): String {
2 val user = userRepository.findById(userId)
3 return user?.email ?: "unknown@example.com"
4}
Sealed class for exhaustive results:
kotlin
1sealed class Result<out T> {
2 data class Success<T>(val data: T) : Result<T>()
3 data class Failure(val error: AppError) : Result<Nothing>()
4 data object Loading : Result<Nothing>()
5}
Structured concurrency with async/await:
kotlin
1suspend fun fetchUserWithPosts(userId: String): UserProfile =
2 coroutineScope {
3 val user = async { userService.getUser(userId) }
4 val posts = async { postService.getUserPosts(userId) }
5 UserProfile(user = user.await(), posts = posts.await())
6 }
Core Principles
1. Null Safety
Kotlin's type system distinguishes nullable and non-nullable types. Leverage it fully.
kotlin
1// Good: Use non-nullable types by default
2fun getUser(id: String): User {
3 return userRepository.findById(id)
4 ?: throw UserNotFoundException("User $id not found")
5}
6
7// Good: Safe calls and Elvis operator
8fun getUserEmail(userId: String): String {
9 val user = userRepository.findById(userId)
10 return user?.email ?: "unknown@example.com"
11}
12
13// Bad: Force-unwrapping nullable types
14fun getUserEmail(userId: String): String {
15 val user = userRepository.findById(userId)
16 return user!!.email // Throws NPE if null
17}
2. Immutability by Default
Prefer val over var, immutable collections over mutable ones.
kotlin
1// Good: Immutable data
2data class User(
3 val id: String,
4 val name: String,
5 val email: String,
6)
7
8// Good: Transform with copy()
9fun updateEmail(user: User, newEmail: String): User =
10 user.copy(email = newEmail)
11
12// Good: Immutable collections
13val users: List<User> = listOf(user1, user2)
14val filtered = users.filter { it.email.isNotBlank() }
15
16// Bad: Mutable state
17var currentUser: User? = null // Avoid mutable global state
18val mutableUsers = mutableListOf<User>() // Avoid unless truly needed
3. Expression Bodies and Single-Expression Functions
Use expression bodies for concise, readable functions.
kotlin
1// Good: Expression body
2fun isAdult(age: Int): Boolean = age >= 18
3
4fun formatFullName(first: String, last: String): String =
5 "$first $last".trim()
6
7fun User.displayName(): String =
8 name.ifBlank { email.substringBefore('@') }
9
10// Good: When as expression
11fun statusMessage(code: Int): String = when (code) {
12 200 -> "OK"
13 404 -> "Not Found"
14 500 -> "Internal Server Error"
15 else -> "Unknown status: $code"
16}
17
18// Bad: Unnecessary block body
19fun isAdult(age: Int): Boolean {
20 return age >= 18
21}
4. Data Classes for Value Objects
Use data classes for types that primarily hold data.
kotlin
1// Good: Data class with copy, equals, hashCode, toString
2data class CreateUserRequest(
3 val name: String,
4 val email: String,
5 val role: Role = Role.USER,
6)
7
8// Good: Value class for type safety (zero overhead at runtime)
9@JvmInline
10value class UserId(val value: String) {
11 init {
12 require(value.isNotBlank()) { "UserId cannot be blank" }
13 }
14}
15
16@JvmInline
17value class Email(val value: String) {
18 init {
19 require('@' in value) { "Invalid email: $value" }
20 }
21}
22
23fun getUser(id: UserId): User = userRepository.findById(id)
Sealed Classes and Interfaces
Modeling Restricted Hierarchies
kotlin
1// Good: Sealed class for exhaustive when
2sealed class Result<out T> {
3 data class Success<T>(val data: T) : Result<T>()
4 data class Failure(val error: AppError) : Result<Nothing>()
5 data object Loading : Result<Nothing>()
6}
7
8fun <T> Result<T>.getOrNull(): T? = when (this) {
9 is Result.Success -> data
10 is Result.Failure -> null
11 is Result.Loading -> null
12}
13
14fun <T> Result<T>.getOrThrow(): T = when (this) {
15 is Result.Success -> data
16 is Result.Failure -> throw error.toException()
17 is Result.Loading -> throw IllegalStateException("Still loading")
18}
Sealed Interfaces for API Responses
kotlin
1sealed interface ApiError {
2 val message: String
3
4 data class NotFound(override val message: String) : ApiError
5 data class Unauthorized(override val message: String) : ApiError
6 data class Validation(
7 override val message: String,
8 val field: String,
9 ) : ApiError
10 data class Internal(
11 override val message: String,
12 val cause: Throwable? = null,
13 ) : ApiError
14}
15
16fun ApiError.toStatusCode(): Int = when (this) {
17 is ApiError.NotFound -> 404
18 is ApiError.Unauthorized -> 401
19 is ApiError.Validation -> 422
20 is ApiError.Internal -> 500
21}
Scope Functions
When to Use Each
kotlin
1// let: Transform nullable or scoped result
2val length: Int? = name?.let { it.trim().length }
3
4// apply: Configure an object (returns the object)
5val user = User().apply {
6 name = "Alice"
7 email = "alice@example.com"
8}
9
10// also: Side effects (returns the object)
11val user = createUser(request).also { logger.info("Created user: ${it.id}") }
12
13// run: Execute a block with receiver (returns result)
14val result = connection.run {
15 prepareStatement(sql)
16 executeQuery()
17}
18
19// with: Non-extension form of run
20val csv = with(StringBuilder()) {
21 appendLine("name,email")
22 users.forEach { appendLine("${it.name},${it.email}") }
23 toString()
24}
Anti-Patterns
kotlin
1// Bad: Nesting scope functions
2user?.let { u ->
3 u.address?.let { addr ->
4 addr.city?.let { city ->
5 println(city) // Hard to read
6 }
7 }
8}
9
10// Good: Chain safe calls instead
11val city = user?.address?.city
12city?.let { println(it) }
Extension Functions
Adding Functionality Without Inheritance
kotlin
1// Good: Domain-specific extensions
2fun String.toSlug(): String =
3 lowercase()
4 .replace(Regex("[^a-z0-9\\s-]"), "")
5 .replace(Regex("\\s+"), "-")
6 .trim('-')
7
8fun Instant.toLocalDate(zone: ZoneId = ZoneId.systemDefault()): LocalDate =
9 atZone(zone).toLocalDate()
10
11// Good: Collection extensions
12fun <T> List<T>.second(): T = this[1]
13
14fun <T> List<T>.secondOrNull(): T? = getOrNull(1)
15
16// Good: Scoped extensions (not polluting global namespace)
17class UserService {
18 private fun User.isActive(): Boolean =
19 status == Status.ACTIVE && lastLogin.isAfter(Instant.now().minus(30, ChronoUnit.DAYS))
20
21 fun getActiveUsers(): List<User> = userRepository.findAll().filter { it.isActive() }
22}
Coroutines
Structured Concurrency
kotlin
1// Good: Structured concurrency with coroutineScope
2suspend fun fetchUserWithPosts(userId: String): UserProfile =
3 coroutineScope {
4 val userDeferred = async { userService.getUser(userId) }
5 val postsDeferred = async { postService.getUserPosts(userId) }
6
7 UserProfile(
8 user = userDeferred.await(),
9 posts = postsDeferred.await(),
10 )
11 }
12
13// Good: supervisorScope when children can fail independently
14suspend fun fetchDashboard(userId: String): Dashboard =
15 supervisorScope {
16 val user = async { userService.getUser(userId) }
17 val notifications = async { notificationService.getRecent(userId) }
18 val recommendations = async { recommendationService.getFor(userId) }
19
20 Dashboard(
21 user = user.await(),
22 notifications = try {
23 notifications.await()
24 } catch (e: CancellationException) {
25 throw e
26 } catch (e: Exception) {
27 emptyList()
28 },
29 recommendations = try {
30 recommendations.await()
31 } catch (e: CancellationException) {
32 throw e
33 } catch (e: Exception) {
34 emptyList()
35 },
36 )
37 }
Flow for Reactive Streams
kotlin
1// Good: Cold flow with proper error handling
2fun observeUsers(): Flow<List<User>> = flow {
3 while (currentCoroutineContext().isActive) {
4 val users = userRepository.findAll()
5 emit(users)
6 delay(5.seconds)
7 }
8}.catch { e ->
9 logger.error("Error observing users", e)
10 emit(emptyList())
11}
12
13// Good: Flow operators
14fun searchUsers(query: Flow<String>): Flow<List<User>> =
15 query
16 .debounce(300.milliseconds)
17 .distinctUntilChanged()
18 .filter { it.length >= 2 }
19 .mapLatest { q -> userRepository.search(q) }
20 .catch { emit(emptyList()) }
Cancellation and Cleanup
kotlin
1// Good: Respect cancellation
2suspend fun processItems(items: List<Item>) {
3 items.forEach { item ->
4 ensureActive() // Check cancellation before expensive work
5 processItem(item)
6 }
7}
8
9// Good: Cleanup with try/finally
10suspend fun acquireAndProcess() {
11 val resource = acquireResource()
12 try {
13 resource.process()
14 } finally {
15 withContext(NonCancellable) {
16 resource.release() // Always release, even on cancellation
17 }
18 }
19}
Delegation
Property Delegation
kotlin
1// Lazy initialization
2val expensiveData: List<User> by lazy {
3 userRepository.findAll()
4}
5
6// Observable property
7var name: String by Delegates.observable("initial") { _, old, new ->
8 logger.info("Name changed from '$old' to '$new'")
9}
10
11// Map-backed properties
12class Config(private val map: Map<String, Any?>) {
13 val host: String by map
14 val port: Int by map
15 val debug: Boolean by map
16}
17
18val config = Config(mapOf("host" to "localhost", "port" to 8080, "debug" to true))
Interface Delegation
kotlin
1// Good: Delegate interface implementation
2class LoggingUserRepository(
3 private val delegate: UserRepository,
4 private val logger: Logger,
5) : UserRepository by delegate {
6 // Only override what you need to add logging to
7 override suspend fun findById(id: String): User? {
8 logger.info("Finding user by id: $id")
9 return delegate.findById(id).also {
10 logger.info("Found user: ${it?.name ?: "null"}")
11 }
12 }
13}
DSL Builders
Type-Safe Builders
kotlin
1// Good: DSL with @DslMarker
2@DslMarker
3annotation class HtmlDsl
4
5@HtmlDsl
6class HTML {
7 private val children = mutableListOf<Element>()
8
9 fun head(init: Head.() -> Unit) {
10 children += Head().apply(init)
11 }
12
13 fun body(init: Body.() -> Unit) {
14 children += Body().apply(init)
15 }
16
17 override fun toString(): String = children.joinToString("\n")
18}
19
20fun html(init: HTML.() -> Unit): HTML = HTML().apply(init)
21
22// Usage
23val page = html {
24 head { title("My Page") }
25 body {
26 h1("Welcome")
27 p("Hello, World!")
28 }
29}
Configuration DSL
kotlin
1data class ServerConfig(
2 val host: String = "0.0.0.0",
3 val port: Int = 8080,
4 val ssl: SslConfig? = null,
5 val database: DatabaseConfig? = null,
6)
7
8data class SslConfig(val certPath: String, val keyPath: String)
9data class DatabaseConfig(val url: String, val maxPoolSize: Int = 10)
10
11class ServerConfigBuilder {
12 var host: String = "0.0.0.0"
13 var port: Int = 8080
14 private var ssl: SslConfig? = null
15 private var database: DatabaseConfig? = null
16
17 fun ssl(certPath: String, keyPath: String) {
18 ssl = SslConfig(certPath, keyPath)
19 }
20
21 fun database(url: String, maxPoolSize: Int = 10) {
22 database = DatabaseConfig(url, maxPoolSize)
23 }
24
25 fun build(): ServerConfig = ServerConfig(host, port, ssl, database)
26}
27
28fun serverConfig(init: ServerConfigBuilder.() -> Unit): ServerConfig =
29 ServerConfigBuilder().apply(init).build()
30
31// Usage
32val config = serverConfig {
33 host = "0.0.0.0"
34 port = 443
35 ssl("/certs/cert.pem", "/certs/key.pem")
36 database("jdbc:postgresql://localhost:5432/mydb", maxPoolSize = 20)
37}
Sequences for Lazy Evaluation
kotlin
1// Good: Use sequences for large collections with multiple operations
2val result = users.asSequence()
3 .filter { it.isActive }
4 .map { it.email }
5 .filter { it.endsWith("@company.com") }
6 .take(10)
7 .toList()
8
9// Good: Generate infinite sequences
10val fibonacci: Sequence<Long> = sequence {
11 var a = 0L
12 var b = 1L
13 while (true) {
14 yield(a)
15 val next = a + b
16 a = b
17 b = next
18 }
19}
20
21val first20 = fibonacci.take(20).toList()
Gradle Kotlin DSL
build.gradle.kts Configuration
kotlin
1// Check for latest versions: https://kotlinlang.org/docs/releases.html
2plugins {
3 kotlin("jvm") version "2.3.10"
4 kotlin("plugin.serialization") version "2.3.10"
5 id("io.ktor.plugin") version "3.4.0"
6 id("org.jetbrains.kotlinx.kover") version "0.9.7"
7 id("io.gitlab.arturbosch.detekt") version "1.23.8"
8}
9
10group = "com.example"
11version = "1.0.0"
12
13kotlin {
14 jvmToolchain(21)
15}
16
17dependencies {
18 // Ktor
19 implementation("io.ktor:ktor-server-core:3.4.0")
20 implementation("io.ktor:ktor-server-netty:3.4.0")
21 implementation("io.ktor:ktor-server-content-negotiation:3.4.0")
22 implementation("io.ktor:ktor-serialization-kotlinx-json:3.4.0")
23
24 // Exposed
25 implementation("org.jetbrains.exposed:exposed-core:1.0.0")
26 implementation("org.jetbrains.exposed:exposed-dao:1.0.0")
27 implementation("org.jetbrains.exposed:exposed-jdbc:1.0.0")
28 implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.0.0")
29
30 // Koin
31 implementation("io.insert-koin:koin-ktor:4.2.0")
32
33 // Coroutines
34 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
35
36 // Testing
37 testImplementation("io.kotest:kotest-runner-junit5:6.1.4")
38 testImplementation("io.kotest:kotest-assertions-core:6.1.4")
39 testImplementation("io.kotest:kotest-property:6.1.4")
40 testImplementation("io.mockk:mockk:1.14.9")
41 testImplementation("io.ktor:ktor-server-test-host:3.4.0")
42 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
43}
44
45tasks.withType<Test> {
46 useJUnitPlatform()
47}
48
49detekt {
50 config.setFrom(files("config/detekt/detekt.yml"))
51 buildUponDefaultConfig = true
52}
Error Handling Patterns
Result Type for Domain Operations
kotlin
1// Good: Use Kotlin's Result or a custom sealed class
2suspend fun createUser(request: CreateUserRequest): Result<User> = runCatching {
3 require(request.name.isNotBlank()) { "Name cannot be blank" }
4 require('@' in request.email) { "Invalid email format" }
5
6 val user = User(
7 id = UserId(UUID.randomUUID().toString()),
8 name = request.name,
9 email = Email(request.email),
10 )
11 userRepository.save(user)
12 user
13}
14
15// Good: Chain results
16val displayName = createUser(request)
17 .map { it.name }
18 .getOrElse { "Unknown" }
require, check, error
kotlin
1// Good: Preconditions with clear messages
2fun withdraw(account: Account, amount: Money): Account {
3 require(amount.value > 0) { "Amount must be positive: $amount" }
4 check(account.balance >= amount) { "Insufficient balance: ${account.balance} < $amount" }
5
6 return account.copy(balance = account.balance - amount)
7}
Collection Operations
Idiomatic Collection Processing
kotlin
1// Good: Chained operations
2val activeAdminEmails: List<String> = users
3 .filter { it.role == Role.ADMIN && it.isActive }
4 .sortedBy { it.name }
5 .map { it.email }
6
7// Good: Grouping and aggregation
8val usersByRole: Map<Role, List<User>> = users.groupBy { it.role }
9
10val oldestByRole: Map<Role, User?> = users.groupBy { it.role }
11 .mapValues { (_, users) -> users.minByOrNull { it.createdAt } }
12
13// Good: Associate for map creation
14val usersById: Map<UserId, User> = users.associateBy { it.id }
15
16// Good: Partition for splitting
17val (active, inactive) = users.partition { it.isActive }
Quick Reference: Kotlin Idioms
| Idiom | Description |
|---|
val over var | Prefer immutable variables |
data class | For value objects with equals/hashCode/copy |
sealed class/interface | For restricted type hierarchies |
value class | For type-safe wrappers with zero overhead |
Expression when | Exhaustive pattern matching |
Safe call ?. | Null-safe member access |
Elvis ?: | Default value for nullables |
let/apply/also/run/with | Scope functions for clean code |
| Extension functions | Add behavior without inheritance |
copy() | Immutable updates on data classes |
require/check | Precondition assertions |
Coroutine async/await | Structured concurrent execution |
Flow | Cold reactive streams |
sequence | Lazy evaluation |
Delegation by | Reuse implementation without inheritance |
Anti-Patterns to Avoid
kotlin
1// Bad: Force-unwrapping nullable types
2val name = user!!.name
3
4// Bad: Platform type leakage from Java
5fun getLength(s: String) = s.length // Safe
6fun getLength(s: String?) = s?.length ?: 0 // Handle nulls from Java
7
8// Bad: Mutable data classes
9data class MutableUser(var name: String, var email: String)
10
11// Bad: Using exceptions for control flow
12try {
13 val user = findUser(id)
14} catch (e: NotFoundException) {
15 // Don't use exceptions for expected cases
16}
17
18// Good: Use nullable return or Result
19val user: User? = findUserOrNull(id)
20
21// Bad: Ignoring coroutine scope
22GlobalScope.launch { /* Avoid GlobalScope */ }
23
24// Good: Use structured concurrency
25coroutineScope {
26 launch { /* Properly scoped */ }
27}
28
29// Bad: Deeply nested scope functions
30user?.let { u ->
31 u.address?.let { a ->
32 a.city?.let { c -> process(c) }
33 }
34}
35
36// Good: Direct null-safe chain
37user?.address?.city?.let { process(it) }
Remember: Kotlin code should be concise but readable. Leverage the type system for safety, prefer immutability, and use coroutines for concurrency. When in doubt, let the compiler help you.