kotlin-patterns

Idiomatic Kotlin patterns, best practices, and conventions for building robust, efficient, and maintainable Kotlin applications with coroutines, null safety,…

INSTALLATION
npx skills add https://github.com/affaan-m/everything-claude-code --skill kotlin-patterns
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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 default

fun getUser(id: String): User {

    return userRepository.findById(id)

        ?: throw UserNotFoundException("User $id not found")

}

// Good: Safe calls and Elvis operator

fun getUserEmail(userId: String): String {

    val user = userRepository.findById(userId)

    return user?.email ?: "unknown@example.com"

}

// Bad: Force-unwrapping nullable types

fun 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 data

data 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 collections

val users: List<User> = listOf(user1, user2)

val filtered = users.filter { it.email.isNotBlank() }

// Bad: Mutable state

var currentUser: User? = null // Avoid mutable global state

val mutableUsers = mutableListOf<User>() // Avoid unless truly needed

3. Expression Bodies and Single-Expression Functions

Use expression bodies for concise, readable functions.

// Good: Expression body

fun 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 expression

fun statusMessage(code: Int): String = when (code) {

    200 -> "OK"

    404 -> "Not Found"

    500 -> "Internal Server Error"

    else -> "Unknown status: $code"

}

// Bad: Unnecessary block body

fun 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, toString

data class CreateUserRequest(

    val name: String,

    val email: String,

    val role: Role = Role.USER,

)

// Good: Value class for type safety (zero overhead at runtime)

@JvmInline

value class UserId(val value: String) {

    init {

        require(value.isNotBlank()) { "UserId cannot be blank" }

    }

}

@JvmInline

value 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 when

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>()

}

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 result

val 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 run

val csv = with(StringBuilder()) {

    appendLine("name,email")

    users.forEach { appendLine("${it.name},${it.email}") }

    toString()

}

Anti-Patterns

// Bad: Nesting scope functions

user?.let { u ->

    u.address?.let { addr ->

        addr.city?.let { city ->

            println(city) // Hard to read

        }

    }

}

// Good: Chain safe calls instead

val city = user?.address?.city

city?.let { println(it) }

Extension Functions

Adding Functionality Without Inheritance

// Good: Domain-specific extensions

fun 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 extensions

fun <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 &#x26;&#x26; lastLogin.isAfter(Instant.now().minus(30, ChronoUnit.DAYS))

    fun getActiveUsers(): List<User> = userRepository.findAll().filter { it.isActive() }

}

Coroutines

Structured Concurrency

// Good: Structured concurrency with coroutineScope

suspend 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 independently

suspend 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 handling

fun 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 operators

fun 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 cancellation

suspend fun processItems(items: List<Item>) {

    items.forEach { item ->

        ensureActive() // Check cancellation before expensive work

        processItem(item)

    }

}

// Good: Cleanup with try/finally

suspend fun acquireAndProcess() {

    val resource = acquireResource()

    try {

        resource.process()

    } finally {

        withContext(NonCancellable) {

            resource.release() // Always release, even on cancellation

        }

    }

}

Delegation

Property Delegation

// Lazy initialization

val expensiveData: List<User> by lazy {

    userRepository.findAll()

}

// Observable property

var name: String by Delegates.observable("initial") { _, old, new ->

    logger.info("Name changed from '$old' to '$new'")

}

// Map-backed properties

class 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 implementation

class 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

@DslMarker

annotation class HtmlDsl

@HtmlDsl

class 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)

// Usage

val 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()

// Usage

val 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 operations

val result = users.asSequence()

    .filter { it.isActive }

    .map { it.email }

    .filter { it.endsWith("@company.com") }

    .take(10)

    .toList()

// Good: Generate infinite sequences

val 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.html

plugins {

    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 class

suspend 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 results

val displayName = createUser(request)

    .map { it.name }

    .getOrElse { "Unknown" }

require, check, error

// Good: Preconditions with clear messages

fun 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 operations

val activeAdminEmails: List<String> = users

    .filter { it.role == Role.ADMIN &#x26;&#x26; it.isActive }

    .sortedBy { it.name }

    .map { it.email }

// Good: Grouping and aggregation

val 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 creation

val usersById: Map<UserId, User> = users.associateBy { it.id }

// Good: Partition for splitting

val (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 types

val name = user!!.name

// Bad: Platform type leakage from Java

fun getLength(s: String) = s.length // Safe

fun getLength(s: String?) = s?.length ?: 0 // Handle nulls from Java

// Bad: Mutable data classes

data class MutableUser(var name: String, var email: String)

// Bad: Using exceptions for control flow

try {

    val user = findUser(id)

} catch (e: NotFoundException) {

    // Don't use exceptions for expected cases

}

// Good: Use nullable return or Result

val user: User? = findUserOrNull(id)

// Bad: Ignoring coroutine scope

GlobalScope.launch { /* Avoid GlobalScope */ }

// Good: Use structured concurrency

coroutineScope {

    launch { /* Properly scoped */ }

}

// Bad: Deeply nested scope functions

user?.let { u ->

    u.address?.let { a ->

        a.city?.let { c -> process(c) }

    }

}

// Good: Direct null-safe chain

user?.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.

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card