kotlin-patterns
Idiomatic Kotlin patterns, best practices, and conventions for building robust, efficient, and maintainable Kotlin applications with coroutines, null safety, and DSL builders.
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:
fun getUserEmail(userId: String): String { val user = userRepository.findById(userId) return user?.email ?: "unknown@example.com"}Sealed class for exhaustive results:
sealed class Result<out T> { data class Success<T>(val data: T) : Result<T>() data class Failure(val error: AppError) : Result<Nothing>() data object Loading : Result<Nothing>()}Structured concurrency with async/await:
suspend fun fetchUserWithPosts(userId: String): UserProfile = coroutineScope { val user = async { userService.getUser(userId) } val posts = async { postService.getUserPosts(userId) } UserProfile(user = user.await(), posts = posts.await()) }Core Principles
1. Null Safety
Kotlin’s type system distinguishes nullable and non-nullable types. Leverage it fully.
// Good: Use non-nullable types by defaultfun getUser(id: String): User { return userRepository.findById(id) ?: throw UserNotFoundException("User $id not found")}
// Good: Safe calls and Elvis operatorfun getUserEmail(userId: String): String { val user = userRepository.findById(userId) return user?.email ?: "unknown@example.com"}
// Bad: Force-unwrapping nullable typesfun getUserEmail(userId: String): String { val user = userRepository.findById(userId) return user!!.email // Throws NPE if null}2. Immutability by Default
Prefer val over var, immutable collections over mutable ones.
// Good: Immutable datadata class User( val id: String, val name: String, val email: String,)
// Good: Transform with copy()fun updateEmail(user: User, newEmail: String): User = user.copy(email = newEmail)
// Good: Immutable collectionsval users: List<User> = listOf(user1, user2)val filtered = users.filter { it.email.isNotBlank() }
// Bad: Mutable statevar currentUser: User? = null // Avoid mutable global stateval mutableUsers = mutableListOf<User>() // Avoid unless truly needed3. Expression Bodies and Single-Expression Functions
Use expression bodies for concise, readable functions.
// Good: Expression bodyfun isAdult(age: Int): Boolean = age >= 18
fun formatFullName(first: String, last: String): String = "$first $last".trim()
fun User.displayName(): String = name.ifBlank { email.substringBefore('@') }
// Good: When as expressionfun statusMessage(code: Int): String = when (code) { 200 -> "OK" 404 -> "Not Found" 500 -> "Internal Server Error" else -> "Unknown status: $code"}
// Bad: Unnecessary block bodyfun isAdult(age: Int): Boolean { return age >= 18}4. Data Classes for Value Objects
Use data classes for types that primarily hold data.
// Good: Data class with copy, equals, hashCode, toStringdata class CreateUserRequest( val name: String, val email: String, val role: Role = Role.USER,)
// Good: Value class for type safety (zero overhead at runtime)@JvmInlinevalue class UserId(val value: String) { init { require(value.isNotBlank()) { "UserId cannot be blank" } }}
@JvmInlinevalue class Email(val value: String) { init { require('@' in value) { "Invalid email: $value" } }}
fun getUser(id: UserId): User = userRepository.findById(id)Sealed Classes and Interfaces
Modeling Restricted Hierarchies
// Good: Sealed class for exhaustive whensealed class Result<out T> { data class Success<T>(val data: T) : Result<T>() data class Failure(val error: AppError) : Result<Nothing>() data object Loading : Result<Nothing>()}
fun <T> Result<T>.getOrNull(): T? = when (this) { is Result.Success -> data is Result.Failure -> null is Result.Loading -> null}
fun <T> Result<T>.getOrThrow(): T = when (this) { is Result.Success -> data is Result.Failure -> throw error.toException() is Result.Loading -> throw IllegalStateException("Still loading")}Sealed Interfaces for API Responses
sealed interface ApiError { val message: String
data class NotFound(override val message: String) : ApiError data class Unauthorized(override val message: String) : ApiError data class Validation( override val message: String, val field: String, ) : ApiError data class Internal( override val message: String, val cause: Throwable? = null, ) : ApiError}
fun ApiError.toStatusCode(): Int = when (this) { is ApiError.NotFound -> 404 is ApiError.Unauthorized -> 401 is ApiError.Validation -> 422 is ApiError.Internal -> 500}Scope Functions
When to Use Each
// let: Transform nullable or scoped resultval length: Int? = name?.let { it.trim().length }
// apply: Configure an object (returns the object)val user = User().apply { name = "Alice" email = "alice@example.com"}
// also: Side effects (returns the object)val user = createUser(request).also { logger.info("Created user: ${it.id}") }
// run: Execute a block with receiver (returns result)val result = connection.run { prepareStatement(sql) executeQuery()}
// with: Non-extension form of runval csv = with(StringBuilder()) { appendLine("name,email") users.forEach { appendLine("${it.name},${it.email}") } toString()}Anti-Patterns
// Bad: Nesting scope functionsuser?.let { u -> u.address?.let { addr -> addr.city?.let { city -> println(city) // Hard to read } }}
// Good: Chain safe calls insteadval city = user?.address?.citycity?.let { println(it) }Extension Functions
Adding Functionality Without Inheritance
// Good: Domain-specific extensionsfun String.toSlug(): String = lowercase() .replace(Regex("[^a-z0-9\\s-]"), "") .replace(Regex("\\s+"), "-") .trim('-')
fun Instant.toLocalDate(zone: ZoneId = ZoneId.systemDefault()): LocalDate = atZone(zone).toLocalDate()
// Good: Collection extensionsfun <T> List<T>.second(): T = this[1]
fun <T> List<T>.secondOrNull(): T? = getOrNull(1)
// Good: Scoped extensions (not polluting global namespace)class UserService { private fun User.isActive(): Boolean = status == Status.ACTIVE && lastLogin.isAfter(Instant.now().minus(30, ChronoUnit.DAYS))
fun getActiveUsers(): List<User> = userRepository.findAll().filter { it.isActive() }}Coroutines
Structured Concurrency
// Good: Structured concurrency with coroutineScopesuspend fun fetchUserWithPosts(userId: String): UserProfile = coroutineScope { val userDeferred = async { userService.getUser(userId) } val postsDeferred = async { postService.getUserPosts(userId) }
UserProfile( user = userDeferred.await(), posts = postsDeferred.await(), ) }
// Good: supervisorScope when children can fail independentlysuspend fun fetchDashboard(userId: String): Dashboard = supervisorScope { val user = async { userService.getUser(userId) } val notifications = async { notificationService.getRecent(userId) } val recommendations = async { recommendationService.getFor(userId) }
Dashboard( user = user.await(), notifications = try { notifications.await() } catch (e: CancellationException) { throw e } catch (e: Exception) { emptyList() }, recommendations = try { recommendations.await() } catch (e: CancellationException) { throw e } catch (e: Exception) { emptyList() }, ) }Flow for Reactive Streams
// Good: Cold flow with proper error handlingfun observeUsers(): Flow<List<User>> = flow { while (currentCoroutineContext().isActive) { val users = userRepository.findAll() emit(users) delay(5.seconds) }}.catch { e -> logger.error("Error observing users", e) emit(emptyList())}
// Good: Flow operatorsfun searchUsers(query: Flow<String>): Flow<List<User>> = query .debounce(300.milliseconds) .distinctUntilChanged() .filter { it.length >= 2 } .mapLatest { q -> userRepository.search(q) } .catch { emit(emptyList()) }Cancellation and Cleanup
// Good: Respect cancellationsuspend fun processItems(items: List<Item>) { items.forEach { item -> ensureActive() // Check cancellation before expensive work processItem(item) }}
// Good: Cleanup with try/finallysuspend fun acquireAndProcess() { val resource = acquireResource() try { resource.process() } finally { withContext(NonCancellable) { resource.release() // Always release, even on cancellation } }}Delegation
Property Delegation
// Lazy initializationval expensiveData: List<User> by lazy { userRepository.findAll()}
// Observable propertyvar name: String by Delegates.observable("initial") { _, old, new -> logger.info("Name changed from '$old' to '$new'")}
// Map-backed propertiesclass Config(private val map: Map<String, Any?>) { val host: String by map val port: Int by map val debug: Boolean by map}
val config = Config(mapOf("host" to "localhost", "port" to 8080, "debug" to true))Interface Delegation
// Good: Delegate interface implementationclass LoggingUserRepository( private val delegate: UserRepository, private val logger: Logger,) : UserRepository by delegate { // Only override what you need to add logging to override suspend fun findById(id: String): User? { logger.info("Finding user by id: $id") return delegate.findById(id).also { logger.info("Found user: ${it?.name ?: "null"}") } }}DSL Builders
Type-Safe Builders
// Good: DSL with @DslMarker@DslMarkerannotation class HtmlDsl
@HtmlDslclass HTML { private val children = mutableListOf<Element>()
fun head(init: Head.() -> Unit) { children += Head().apply(init) }
fun body(init: Body.() -> Unit) { children += Body().apply(init) }
override fun toString(): String = children.joinToString("\n")}
fun html(init: HTML.() -> Unit): HTML = HTML().apply(init)
// Usageval page = html { head { title("My Page") } body { h1("Welcome") p("Hello, World!") }}Configuration DSL
data class ServerConfig( val host: String = "0.0.0.0", val port: Int = 8080, val ssl: SslConfig? = null, val database: DatabaseConfig? = null,)
data class SslConfig(val certPath: String, val keyPath: String)data class DatabaseConfig(val url: String, val maxPoolSize: Int = 10)
class ServerConfigBuilder { var host: String = "0.0.0.0" var port: Int = 8080 private var ssl: SslConfig? = null private var database: DatabaseConfig? = null
fun ssl(certPath: String, keyPath: String) { ssl = SslConfig(certPath, keyPath) }
fun database(url: String, maxPoolSize: Int = 10) { database = DatabaseConfig(url, maxPoolSize) }
fun build(): ServerConfig = ServerConfig(host, port, ssl, database)}
fun serverConfig(init: ServerConfigBuilder.() -> Unit): ServerConfig = ServerConfigBuilder().apply(init).build()
// Usageval config = serverConfig { host = "0.0.0.0" port = 443 ssl("/certs/cert.pem", "/certs/key.pem") database("jdbc:postgresql://localhost:5432/mydb", maxPoolSize = 20)}Sequences for Lazy Evaluation
// Good: Use sequences for large collections with multiple operationsval result = users.asSequence() .filter { it.isActive } .map { it.email } .filter { it.endsWith("@company.com") } .take(10) .toList()
// Good: Generate infinite sequencesval fibonacci: Sequence<Long> = sequence { var a = 0L var b = 1L while (true) { yield(a) val next = a + b a = b b = next }}
val first20 = fibonacci.take(20).toList()Gradle Kotlin DSL
build.gradle.kts Configuration
// Check for latest versions: https://kotlinlang.org/docs/releases.htmlplugins { kotlin("jvm") version "2.3.10" kotlin("plugin.serialization") version "2.3.10" id("io.ktor.plugin") version "3.4.0" id("org.jetbrains.kotlinx.kover") version "0.9.7" id("io.gitlab.arturbosch.detekt") version "1.23.8"}
group = "com.example"version = "1.0.0"
kotlin { jvmToolchain(21)}
dependencies { // Ktor implementation("io.ktor:ktor-server-core:3.4.0") implementation("io.ktor:ktor-server-netty:3.4.0") implementation("io.ktor:ktor-server-content-negotiation:3.4.0") implementation("io.ktor:ktor-serialization-kotlinx-json:3.4.0")
// Exposed implementation("org.jetbrains.exposed:exposed-core:1.0.0") implementation("org.jetbrains.exposed:exposed-dao:1.0.0") implementation("org.jetbrains.exposed:exposed-jdbc:1.0.0") implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.0.0")
// Koin implementation("io.insert-koin:koin-ktor:4.2.0")
// Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
// Testing testImplementation("io.kotest:kotest-runner-junit5:6.1.4") testImplementation("io.kotest:kotest-assertions-core:6.1.4") testImplementation("io.kotest:kotest-property:6.1.4") testImplementation("io.mockk:mockk:1.14.9") testImplementation("io.ktor:ktor-server-test-host:3.4.0") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")}
tasks.withType<Test> { useJUnitPlatform()}
detekt { config.setFrom(files("config/detekt/detekt.yml")) buildUponDefaultConfig = true}Error Handling Patterns
Result Type for Domain Operations
// Good: Use Kotlin's Result or a custom sealed classsuspend fun createUser(request: CreateUserRequest): Result<User> = runCatching { require(request.name.isNotBlank()) { "Name cannot be blank" } require('@' in request.email) { "Invalid email format" }
val user = User( id = UserId(UUID.randomUUID().toString()), name = request.name, email = Email(request.email), ) userRepository.save(user) user}
// Good: Chain resultsval displayName = createUser(request) .map { it.name } .getOrElse { "Unknown" }require, check, error
// Good: Preconditions with clear messagesfun withdraw(account: Account, amount: Money): Account { require(amount.value > 0) { "Amount must be positive: $amount" } check(account.balance >= amount) { "Insufficient balance: ${account.balance} < $amount" }
return account.copy(balance = account.balance - amount)}Collection Operations
Idiomatic Collection Processing
// Good: Chained operationsval activeAdminEmails: List<String> = users .filter { it.role == Role.ADMIN && it.isActive } .sortedBy { it.name } .map { it.email }
// Good: Grouping and aggregationval usersByRole: Map<Role, List<User>> = users.groupBy { it.role }
val oldestByRole: Map<Role, User?> = users.groupBy { it.role } .mapValues { (_, users) -> users.minByOrNull { it.createdAt } }
// Good: Associate for map creationval usersById: Map<UserId, User> = users.associateBy { it.id }
// Good: Partition for splittingval (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
// Bad: Force-unwrapping nullable typesval name = user!!.name
// Bad: Platform type leakage from Javafun getLength(s: String) = s.length // Safefun getLength(s: String?) = s?.length ?: 0 // Handle nulls from Java
// Bad: Mutable data classesdata class MutableUser(var name: String, var email: String)
// Bad: Using exceptions for control flowtry { val user = findUser(id)} catch (e: NotFoundException) { // Don't use exceptions for expected cases}
// Good: Use nullable return or Resultval user: User? = findUserOrNull(id)
// Bad: Ignoring coroutine scopeGlobalScope.launch { /* Avoid GlobalScope */ }
// Good: Use structured concurrencycoroutineScope { launch { /* Properly scoped */ }}
// Bad: Deeply nested scope functionsuser?.let { u -> u.address?.let { a -> a.city?.let { c -> process(c) } }}
// Good: Direct null-safe chainuser?.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.