Kotlin Coding Standards

SkillAI & models

Kotlin coding standards for modern applications. Includes naming conventions, coroutines, flows, modern Kotlin 2.3.0 features, and recommended tooling.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Kotlin Coding Standards skill

What this skill tells your AI

The instructions your AI receives, as published by b33eep/claude-code-setup in skills/standards-kotlin/SKILL.md and read by ahel’s review.

Core Principles

  1. Explicitness: Explicit code over implicit magic
  2. Readability: Readable code over clever tricks
  3. Null Safety: Embrace Kotlin's null safety system
  4. Immutability: Prefer val over var, immutable collections
  5. Expressiveness: Use Kotlin's expressive features (data classes, sealed classes)
  6. DRY: Don't Repeat Yourself - but keep it simple

General Rules

  • Prefer val over var: Immutability by default
  • Use data classes: For simple data holders
  • Sealed classes/interfaces: For type-safe state modeling
  • Early returns: Avoid deep nesting
  • Descriptive names: Clear, meaningful names
  • Minimal changes: Only change relevant code
  • No over-engineering: Keep it simple
  • Minimal comments: Self-explanatory code. Comments for "why", not "what"

Naming Conventions

ElementConventionExample
ClassesPascalCaseUserService, OrderRepository
InterfacesPascalCaseUserRepository, PaymentProcessor
FunctionscamelCasegetUserById, calculateTotal
PropertiescamelCasefirstName, totalAmount
ConstantsUPPER_SNAKE_CASEMAX_RETRY_COUNT, DEFAULT_TIMEOUT
Packageslowercase.dot.separatedcom.example.service, com.example.repository
FilesPascalCase.ktUserService.kt, OrderRepository.kt
Test ClassesClassNameTestUserServiceTest, OrderRepositoryTest
Test Functionsbacktick names`should return user when id exists`

Project Structure

Gradle Kotlin DSL Project (Recommended)

myproject/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle.properties
├── src/
│   ├── main/
│   │   ├── kotlin/
│   │   │   └── com/example/myapp/
│   │   │       ├── Application.kt          # Main entry point
│   │   │       ├── config/
│   │   │       │   └── AppConfig.kt        # Configuration
│   │   │       ├── domain/
│   │   │       │   └── User.kt             # Domain models
│   │   │       ├── repository/
│   │   │       │   └── UserRepository.kt   # Data access
│   │   │       ├── service/
│   │   │       │   └── UserService.kt      # Business logic
│   │   │       └── api/
│   │   │           └── UserController.kt   # REST endpoints
│   │   └── resources/
│   │       ├── application.conf
│   │       └── logback.xml
│   └── test/
│       ├── kotlin/
│       │   └── com/example/myapp/
│       │       ├── service/
│       │       │   └── UserServiceTest.kt
│       │       └── repository/
│       │           └── UserRepositoryTest.kt
│       └── resources/
│           └── application-test.conf
└── README.md

Maven Project (Alternative)

myproject/
├── pom.xml
├── src/
│   ├── main/
│   │   └── kotlin/...      # Same structure as Gradle
│   └── test/
│       └── kotlin/...      # Same structure as Gradle
└── README.md

Modern Kotlin Features

Recommended: Use Kotlin 2.3.0 (latest LTS) for new projects with K2 compiler enabled by default.

K2 Compiler (Stable since 2.0)

The K2 compiler brings significant performance improvements and faster compilation times.

Features:

  • Faster compilation (up to 2x)
  • Better smart casts
  • Improved type inference
  • Unified architecture for all platforms

Enabled by default in Kotlin 2.3.0 - no configuration needed.

Data Classes

Use data classes for immutable data holders.

// Data class - automatic equals, hashCode, toString, copy, componentN
data class User(
    val id: String,
    val name: String,
    val email: String,
    val age: Int
)

// Usage
val user = User("1", "John Doe", "john@example.com", 30)

// Copy with changes
val updatedUser = user.copy(age = 31)

// Destructuring
val (id, name, email, age) = user
println("User: $name ($email)")

Sealed Classes/Interfaces (Exhaustive When)

Use sealed classes for type-safe state modeling with exhaustive when expressions.

// Sealed interface for result types
sealed interface Result<out T> {
    data class Success<T>(val data: T) : Result<T>
    data class Error(val message: String, val cause: Throwable? = null) : Result<Nothing>
    data object Loading : Result<Nothing>
}

// Exhaustive when - compiler ensures all cases are handled
fun <T> handleResult(result: Result<T>) {
    when (result) {
        is Result.Success -> println("Success: ${result.data}")
        is Result.Error -> println("Error: ${result.message}")
        Result.Loading -> println("Loading...")
        // No else needed - compiler knows all cases
    }
}

// Usage
val result: Result<User> = Result.Success(user)
handleResult(result)

Inline Value Classes (Zero-Cost Wrappers)

Use inline value classes for type-safe wrappers without runtime overhead.

// Inline value class - no boxing overhead
@JvmInline
value class UserId(val value: String)

@JvmInline
value class Email(val value: String) {
    init {
        require(value.contains("@")) { "Invalid email" }
    }
}

// Usage - type-safe, no runtime cost
fun getUserById(id: UserId): User = TODO()
fun sendEmail(email: Email): Unit = TODO()

val userId = UserId("123")
val email = Email("user@example.com")

Context Receivers (Experimental, 2.2+)

Context receivers allow implicit parameters for cleaner DSLs.

Enable with:

// build.gradle.kts
kotlin {
    compilerOptions {
        freeCompilerArgs.add("-Xcontext-receivers")
    }
}

Usage:

interface Logger {
    fun log(message: String)
}

// Function with context receiver
context(Logger)
fun processUser(user: User) {
    log("Processing user: ${user.name}")
    // ...
}

// Call with context
val logger = object : Logger {
    override fun log(message: String) = println(message)
}

with(logger) {
    processUser(user)
}

Explicit Backing Fields (Experimental, 2.3)

Simplifies backing property pattern - define implementation type within property scope.

Enable with:

// build.gradle.kts
kotlin {
    compilerOptions {
        freeCompilerArgs.add("-Xexplicit-backing-fields")
    }
}

Before:

private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users

After:

val users: StateFlow<List<User>> field = MutableStateFlow(emptyList())

UUID API (Experimental, 2.3)

Built-in UUID support without external dependencies.

Enable with:

@OptIn(ExperimentalUuidApi::class)

Usage:

import kotlin.uuid.Uuid
import kotlin.uuid.ExperimentalUuidApi

@OptIn(ExperimentalUuidApi::class)
fun generateUserId(): Uuid {
    return Uuid.generateV4()
}

@OptIn(ExperimentalUuidApi::class)
fun parseUserId(id: String): Uuid? {
    return Uuid.parseOrNull(id)
}

// V7 UUIDs (time-based, sortable)
@OptIn(ExperimentalUuidApi::class)
fun generateTimeBasedId(): Uuid {
    return Uuid.generateV7()
}

Coroutines & Concurrency

Structured Concurrency

Always use structured concurrency - never use GlobalScope.

import kotlinx.coroutines.*

// GOOD - Structured concurrency
suspend fun fetchUserData(userId: String): UserData = coroutineScope {
    val userDeferred = async { fetchUser(userId) }
    val ordersDeferred = async { fetchOrders(userId) }

    UserData(
        user = userDeferred.await(),
        orders = ordersDeferred.await()
    )
}

// BAD - GlobalScope leaks
fun fetchUserDataBad(userId: String) {
    GlobalScope.launch {  // Don't use GlobalScope!
        // ...
    }
}

Dispatchers

Use appropriate dispatchers for different workloads.

// Dispatchers.Default - CPU-intensive work
withContext(Dispatchers.Default) {
    // Heavy computation
    processLargeDataset(data)
}

// Dispatchers.IO - I/O operations (network, disk)
withContext(Dispatchers.IO) {
    // Network call
    apiClient.fetchData()
}

// Dispatchers.Main - UI updates (Android/Desktop)
withContext(Dispatchers.Main) {
    updateUI(data)
}

// Dispatchers.Unconfined - Advanced use cases only

launch vs async

// launch - fire and forget, returns Job
fun processInBackground() = coroutineScope {
    launch {
        // No result needed
        sendNotification()
    }
}

// async - returns Deferred<T>, await for result
suspend fun fetchMultipleResources() = coroutineScope {
    val users = async { fetchUsers() }
    val orders = async { fetchOrders() }

    CombinedData(
        users = users.await(),
        orders = orders.await()
    )
}

Cancellation & Exception Handling

// Cancellation-aware code
suspend fun longRunningTask() = coroutineScope {
    repeat(100) { i ->
        ensureActive()  // Check for cancellation
        delay(100)
        println("Step $i")
    }
}

// Exception handling with supervisorScope
suspend fun fetchDataSafely(): Result<Data> = try {
    supervisorScope {
        val data = async { fetchData() }
        Result.Success(data.await())
    }
} catch (e: Exception) {
    Result.Error("Failed to fetch data", e)
}

// CoroutineExceptionHandler
val handler = CoroutineExceptionHandler { _, exception ->
    println("Caught exception: $exception")
}

val scope = CoroutineScope(SupervisorJob() + handler)

Flow API

StateFlow vs SharedFlow vs MutableStateFlow

// StateFlow - single value, always has current value, conflates updates
class UserViewModel {
    private val _userName = MutableStateFlow("")
    val userName: StateFlow<String> = _userName.asStateFlow()

    fun updateUserName(name: String) {
        _userName.value = name
    }
}

// SharedFlow - event stream, can replay, doesn't conflate
class EventBus {
    private val _events = MutableSharedFlow<Event>(
        replay = 0,      // Don't replay events
        extraBufferCapacity = 64
    )
    val events: SharedFlow<Event> = _events.asSharedFlow()

    suspend fun emit(event: Event) {
        _events.emit(event)
    }
}

// Hot vs Cold flows
// StateFlow/SharedFlow = Hot (emit regardless of collectors)
// flow { } = Cold (only emit when collected)

Flow Operators

// Transform flows
val userNames: Flow<String> = users
    .map { it.name }
    .filter { it.isNotEmpty() }
    .distinctUntilChanged()

// Combine flows
val combinedData = combine(users, orders) { users, orders ->
    CombinedData(users, orders)
}

// FlatMap variants
val allOrders: Flow<Order> = users
    .flatMapConcat { user -> fetchOrders(user.id) }  // Sequential
    // .flatMapMerge { user -> fetchOrders(user.id) }  // Concurrent
    // .flatMapLatest { user -> fetchOrders(user.id) }  // Cancel previous

// Error handling
val safeData: Flow<Data> = dataFlow
    .catch { e -> emit(Data.Empty) }
    .retry(3)

Flow Collection

// Collect in coroutine
lifecycleScope.launch {
    userViewModel.userName.collect { name ->
        updateUI(name)
    }
}

// collectLatest - cancel previous collection on new emission
lifecycleScope.launch {
    searchQuery.collectLatest { query ->
        // Cancelled if new query arrives
        val results = searchRepository.search(query)
        updateResults(results)
    }
}

// Single value
val user = userFlow.first()  // First value
val user = userFlow.firstOrNull()  // Or null if empty

Flow Best Practices

  1. Single source of truth: Expose StateFlow/SharedFlow, keep MutableStateFlow/MutableSharedFlow private
  2. Use collectLatest for UI: Cancel previous work on new emissions
  3. Use stateIn for cold-to-hot conversion:
val data: StateFlow<Data> = dataRepository.getData()
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = Data.Empty
    )

Null Safety

Safe Calls, Elvis, and !! Operator

// Safe call (?.) - returns null if receiver is null
val length: Int? = name?.length

// Elvis operator (?:) - default value
val length: Int = name?.length ?: 0

// !! operator - throws NPE if null (use sparingly!)
val length: Int = name!!.length  // Only if you're 100% sure it's not null

// Safe cast (as?)
val user: User? = obj as? User

// let for null checks
name?.let { n ->
    println("Name: $n")
}

lateinit vs lazy

// lateinit - non-null var, initialized later (must be var)
class MyClass {
    lateinit var repository: Repository

    fun init(repo: Repository) {
        repository = repo
    }

    // Check if initialized
    fun isInitialized() = ::repository.isInitialized
}

// lazy - initialized on first access (must be val)
class MyClass {
    val expensiveValue: String by lazy {
        // Computed only once, on first access
        computeExpensiveValue()
    }
}

Nullable Types vs Optional

// GOOD - Use nullable types (Kotlin-native)
fun findUser(id: String): User? {
    return repository.findById(id)
}

// BAD - Don't use Java's Optional in Kotlin
fun findUserBad(id: String): Optional<User> {  // Avoid
    return Optional.ofNullable(repository.findById(id))
}

// When interoping with Java, convert at boundary
fun getUserFromJava(id: String): User? {
    return javaService.getUser(id).orElse(null)
}

Backing Properties (Standard Pattern)

Use underscore prefix for private mutable backing properties.

// Standard pattern for exposing read-only property
class UserRepository {
    private val _users = mutableListOf<User>()
    val users: List<User> get() = _users

    fun addUser(user: User) {
        _users.add(user)
    }
}

// StateFlow pattern
class UserViewModel {
    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state: StateFlow<UiState> = _state.asStateFlow()

    fun updateState(newState: UiState) {
        _state.value = newState
    }
}

// Alternative: explicit backing fields (experimental, Kotlin 2.3+)
// Requires: -Xexplicit-backing-fields compiler flag
val users: StateFlow<List<User>> field = MutableStateFlow(emptyList())

Scope Functions

Kotlin provides five scope functions: let, run, with, apply, also. Choose based on context object reference and return value.

FunctionObject ReferenceReturn ValueUse Case
letitLambda resultNull checks, transformations
runthisLambda resultObject configuration + computation
withthisLambda resultGroup function calls on object
applythisContext objectObject initialization
alsoitContext objectSide effects

let - Null Checks & Transformations

// Null check with let
val length: Int? = name?.let { it.length }

// Chain with let
val result = value?.let { v ->
    processValue(v)
}?.let { processed ->
    saveResult(processed)
}

// Execute block only if non-null
user?.let { u ->
    println("User: ${u.name}")
    sendWelcomeEmail(u)
}

// Transform value
val uppercaseName = name?.let { it.uppercase() }

apply - Object Initialization

// Object initialization (returns `this`)
val user = User().apply {
    name = "John Doe"
    email = "john@example.com"
    age = 30
}

// Configure and return
val intent = Intent(context, DetailActivity::class.java).apply {
    putExtra("id", userId)
    flags = Intent.FLAG_ACTIVITY_NEW_TASK
}

// Builder pattern style
val dialog = AlertDialog.Builder(context).apply {
    setTitle("Confirm")
    setMessage("Are you sure?")
    setPositiveButton("Yes") { _, _ -> confirm() }
}.create()

also - Side Effects

// Side effects (returns `this`)
val numbers = mutableListOf(1, 2, 3).also {
    println("List created with ${it.size} elements")
}

// Debug chain
val result = processData(input)
    .also { println("Intermediate result: $it") }
    .transformData()
    .also { println("Final result: $it") }

// Multiple operations
val user = createUser().also {
    logUserCreation(it)
    sendWelcomeEmail(it)
}

run - Computations

// Compute value (returns lambda result)
val result = run {
    val x = computeX()
    val y = computeY()
    x + y
}

// Null check with run
val result = service?.run {
    fetchData()
    processData()
}

// Replace multiple statements
val hexString = run {
    val color = getColor()
    Integer.toHexString(color)
}

with - Multiple Calls on Object

// Group operations on object (returns lambda result)
val result = with(canvas) {
    drawCircle(centerX, centerY, radius)
    drawLine(startX, startY, endX, endY)
    drawText(text, x, y)
    save()
}

// Configure object
with(sharedPreferences.edit()) {
    putString("username", username)
    putInt("age", age)
    apply()
}

// Avoid repetition
val user = getUser()
with(user) {
    println("Name: $name")
    println("Email: $email")
    println("Age: $age")
}

Scope Function Selection Guide

// Use let for null checks
value?.let { println(it) }

// Use apply for object initialization (returns object)
val config = Config().apply { timeout = 30 }

// Use also for side effects (returns object)
val list = getList().also { log("Size: ${it.size}") }

// Use run for computations (returns result)
val result = run { compute() }

// Use with for grouping calls (returns result)
val formatted = with(user) { "$name ($email)" }

Collections & Sequences

List vs MutableList

// Prefer immutable collections
val users: List<User> = listOf(user1, user2, user3)

// Mutable only when needed
val mutableUsers: MutableList<User> = mutableListOf()
mutableUsers.add(user4)

// Read-only view of mutable collection
val readOnlyView: List<User> = mutableUsers

// List.of() for Java interop (immutable)
val javaList = java.util.List.of(user1, user2)

Sequence for Lazy Evaluation

Use Sequence for large collections or chained operations.

// List - eager evaluation (creates intermediate lists)
val result = users
    .filter { it.age > 18 }
    .map { it.name }
    .take(10)

// Sequence - lazy evaluation (no intermediate collections)
val result = users.asSequence()
    .filter { it.age > 18 }
    .map { it.name }
    .take(10)
    .toList()  // Terminal operation

// Generate infinite sequence
val fibonacci = generateSequence(1 to 1) { (a, b) -> b to a + b }
    .map { it.first }
    .take(10)
    .toList()

Collection Operations

// Transform
val names = users.map { it.name }
val adults = users.filter { it.age >= 18 }
val groups = users.groupBy { it.department }

// Reduce/Fold
val totalAge = users.sumOf { it.age }
val oldest = users.maxByOrNull { it.age }
val names = users.fold("") { acc, user -> "$acc, ${user.name}" }

// Partition
val (adults, minors) = users.partition { it.age >= 18 }

// Associate
val userById = users.associateBy { it.id }
val nameToUser = users.associateWith { it.name }

Loops on Ranges

// BAD - off-by-one prone
for (i in 0..n - 1) { }
for (i in 0 until n) { }  // Better but verbose

// GOOD - use ..< (Kotlin 1.7+)
for (i in 0..<n) { }

// Ranges
for (i in 1..10) { }        // 1 to 10 (inclusive)
for (i in 1..<10) { }       // 1 to 9 (exclusive end)
for (i in 10 downTo 1) { }  // 10 to 1 (descending)
for (i in 1..10 step 2) { } // 1, 3, 5, 7, 9

// Prefer higher-order functions over loops
// GOOD
val adults = users.filter { it.age >= 18 }

// Less idiomatic
val adults = mutableListOf<User>()
for (user in users) {
    if (user.age >= 18) {
        adults.add(user)
    }
}

String Templates

Use string templates instead of concatenation.

// Simple variable - no braces needed
val message = "Hello, $name!"

// Expression - use braces
val message = "User $name has ${children.size} children"
val message = "${user.name} (${user.age} years old)"

// Property access
val message = "Length: ${text.length}"

// Function call
val message = "Result: ${compute()}"

// BAD - concatenation
val message = "Hello, " + name + "!"

// Multiline strings with templates
val json = """
{
    "name": "$name",
    "age": $age,
    "email": "$email"
}
""".trimIndent()

// Multi-dollar strings (Kotlin 2.0+) - escape $ in raw strings
val jsonSchema = $$"""
{
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "title": "$${title ?: "unknown"}"
}
"""

Extension Functions

Extension functions add functionality to existing classes without inheritance.

When to Use Extensions

// GOOD - Add functionality to existing type
fun String.isValidEmail(): Boolean {
    return this.contains("@") && this.contains(".")
}

// Usage
val email = "user@example.com"
if (email.isValidEmail()) { }

// GOOD - Domain-specific operations
fun User.toDisplayString(): String {
    return "$name ($email)"
}

// GOOD - Collection transformations
fun List<User>.activeUsers(): List<User> {
    return filter { it.isActive }
}

Extension Best Practices

// 1. Restrict visibility (prefer private/internal)
private fun String.sanitize(): String {
    return this.trim().lowercase()
}

// 2. Place extensions in same file as class (if you own it)
// User.kt
data class User(val name: String)

fun User.greet() = "Hello, $name"

// 3. Group related extensions in separate file
// StringExtensions.kt
fun String.isValidEmail(): Boolean { }
fun String.isValidUrl(): Boolean { }

// 4. Don't overuse - prefer member functions when appropriate
class User {
    // GOOD - core behavior as member
    fun activate() { }
}

// Extension for convenience
fun User.deactivate() { }

Extension Properties

// Extension property (no backing field allowed)
val String.lastChar: Char?
    get() = this.lastOrNull()

val List<Int>.median: Double
    get() {
        val sorted = this.sorted()
        val middle = sorted.size / 2
        return if (sorted.size % 2 == 0) {
            (sorted[middle - 1] + sorted[middle]) / 2.0
        } else {
            sorted[middle].toDouble()
        }
    }

// Usage
println("Hello".lastChar)  // 'o'
println(listOf(1, 3, 2).median)  // 2.0

When Expressions Advanced

When with Guards (Kotlin 1.7+)

// Guard conditions in when
sealed interface Status {
    data class Ok(val info: Info) : Status
    data class Error(val code: Int) : Status
}

fun handleStatus(status: Status): String {
    return when (status) {
        is Status.Ok if (status.info.isEmpty()) -> "no information"
        is Status.Ok -> "success: ${status.info}"
        is Status.Error if (status.code >= 500) -> "server error"
        is Status.Error -> "client error: ${status.code}"
    }
}

// Guard with multiple conditions
fun classify(value: Int): String {
    return when (value) {
        in 0..10 if (value % 2 == 0) -> "small even"
        in 0..10 -> "small odd"
        in 11..100 -> "medium"
        else -> "large"
    }
}

When as Expression

// Prefer when as expression (not statement)
// GOOD
val result = when (x) {
    0 -> "zero"
    in 1..10 -> "small"
    else -> "large"
}

// BAD
var result: String
when (x) {
    0 -> result = "zero"
    in 1..10 -> result = "small"
    else -> result = "large"
}

Generics Basics

Generic Functions

// Generic function
fun <T> singletonList(item: T): List<T> {
    return listOf(item)
}

val list = singletonList(42)        // List<Int>
val names = singletonList("Alice")  // List<String>

// Multiple type parameters
fun <K, V> mapOf(key: K, value: V): Map<K, V> {
    return mapOf(key to value)
}

// Type constraints
fun <T : Comparable<T>> max(a: T, b: T): T {
    return if (a > b) a else b
}

Generic Classes

// Generic class
class Box<T>(val value: T) {
    fun get(): T = value
}

val intBox = Box(42)
val stringBox = Box("hello")

// Multiple type parameters
class Pair<A, B>(val first: A, val second: B)

val pair = Pair(1, "one")

Variance (in, out)

// out - covariant (producer)
interface Producer<out T> {
    fun produce(): T
}

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
56
Forks
6
Last commit
May 2026
Advanced
Catalog kind
skill
Gateway key
standards-kotlin
Source
github.com/b33eep/claude-code-setup