| name | kotlin-patterns |
| description | Idiomatic Kotlin patterns: sealed classes, data classes, extension functions, coroutines, Flow, DSL builders, value classes, and functional idioms. Use when writing or reviewing Kotlin code. |
Kotlin Patterns Skill
When to Activate
- Writing new Kotlin classes or functions
- Reviewing Kotlin code for idiomatic style
- Modeling domain objects in Kotlin
- Implementing async workflows with coroutines
- Building Kotlin DSLs or builder APIs
- Debugging coroutine or Flow issues
- Replacing nullable
String or Int primitives with value classes to enforce type safety at compile time
- Refactoring Java-style mutable data classes to use
val and copy() for immutable updates
Core Idioms
val over var — always
var userId = ""
userId = input.id
val userId = input.id
Null safety patterns
val name = user?.profile?.displayName ?: "Anonymous"
user?.let { u ->
analyticsService.track(u.id, "login")
sessionManager.create(u)
}
val user = createUser(request).also {
log.info("Created user: ${it.id}")
}
val summary = user.run {
"${name} (${email})"
}
Domain Modeling
Sealed classes for exhaustive state
sealed class AuthResult {
data class Success(val token: String, val user: User) : AuthResult()
data class InvalidCredentials(val attemptsRemaining: Int) : AuthResult()
data object AccountLocked : AuthResult()
data class Error(val cause: Throwable) : AuthResult()
}
fun handleAuth(result: AuthResult) = when (result) {
is AuthResult.Success -> redirect("/dashboard")
is AuthResult.InvalidCredentials -> showRetryForm(result.attemptsRemaining)
AuthResult.AccountLocked -> showLockedPage()
is AuthResult.Error -> showError(result.cause)
}
Value classes for type-safe primitives
@JvmInline
value class UserId(val value: String) {
init { require(value.isNotBlank()) { "UserId cannot be blank" } }
}
@JvmInline
value class Email(val value: String) {
init { require(value.contains('@')) { "Invalid email: $value" } }
}
fun findUser(id: UserId): User?
fun sendEmail(to: Email): Unit
Data classes as value objects
data class Money(val amount: BigDecimal, val currency: Currency) {
operator fun plus(other: Money): Money {
require(currency == other.currency) { "Currency mismatch" }
return copy(amount = amount + other.amount)
}
companion object {
fun of(amount: String, currency: String) =
Money(BigDecimal(amount), Currency.getInstance(currency))
}
}
Coroutines
Structured concurrency
suspend fun loadDashboard(userId: UserId): Dashboard =
coroutineScope {
val user = async { userRepo.findById(userId) }
val stats = async { statsRepo.getForUser(userId) }
Dashboard(user.await()!!, stats.await())
}
suspend fun fetchWithTimeout(): Data = withTimeout(5000) {
apiClient.fetch()
}
Flow for reactive data streams
fun liveUpdates(id: String): Flow<Update> = flow {
while (true) {
emit(repo.getLatest(id))
delay(5000)
}
}.flowOn(Dispatchers.IO)
viewModelScope.launch {
liveUpdates(itemId)
.filter { it.isRelevant }
.map { it.toUiModel() }
.catch { e -> _error.emit(e.message) }
.collect { update -> _state.emit(update) }
}
Dispatchers
withContext(Dispatchers.Default) { heavyComputation() }
withContext(Dispatchers.IO) { database.query() }
withContext(Dispatchers.Main) { updateUi() }
Extension Functions
fun String.toSlug(): String =
lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-')
fun String.truncate(maxLength: Int, suffix: String = "..."): String =
if (length <= maxLength) this
else take(maxLength - suffix.length) + suffix
fun <T> List<T>.second(): T = this[1]
fun <K, V> Map<K, V>.getOrThrow(key: K): V =
get(key) ?: error("Key not found: $key")
Kotlin DSL Builder Pattern
fun emailMessage(block: EmailBuilder.() -> Unit): EmailMessage =
EmailBuilder().apply(block).build()
@EmailDsl
class EmailBuilder {
var from: String = ""
var to: MutableList<String> = mutableListOf()
var subject: String = ""
private var body: String = ""
fun to(address: String) { to.add(address) }
fun body(content: String) { body = content }
internal fun build() = EmailMessage(from, to.toList(), subject, body)
}
@DslMarker
annotation class EmailDsl
val message = emailMessage {
from = "sender@example.com"
to("recipient@example.com")
subject = "Hello"
body("Hi there!")
}
Functional Patterns
val total = orders.fold(Money.ZERO) { acc, order -> acc + order.total }
val (active, inactive) = users.partition { it.isActive }
val byDepartment: Map<Department, List<Employee>> =
employees.groupBy { it.department }
fun findUserOrders(userId: UserId): List<Order> =
userRepo.findById(userId)
?.let { orderRepo.findByUser(it) }
?: emptyList()
Anti-Patterns
Using !! (Not-Null Assertion) Instead of Safe Null Handling
Wrong:
val name = user!!.profile!!.displayName!!
Correct:
val name = user?.profile?.displayName ?: "Anonymous"
val name = requireNotNull(user?.profile?.displayName) { "displayName must not be null for registered users" }
Why: !! throws an opaque NullPointerException; safe calls with ?: or requireNotNull provide explicit fallbacks and meaningful error messages.
Using var and Mutable Data Classes Instead of copy()
Wrong:
data class Order(var status: OrderStatus, var total: Money)
fun confirm(order: Order): Order {
order.status = OrderStatus.CONFIRMED
return order
}
Correct:
data class Order(val status: OrderStatus, val total: Money)
fun confirm(order: Order): Order =
order.copy(status = OrderStatus.CONFIRMED)
Why: Mutating data class fields in-place creates hidden side effects that are invisible at the call site; copy() makes state transitions explicit and safe to share.
Using Sealed Class with else Branch in when
Wrong:
fun handleAuth(result: AuthResult) = when (result) {
is AuthResult.Success -> redirect("/dashboard")
else -> showError()
}
Correct:
fun handleAuth(result: AuthResult) = when (result) {
is AuthResult.Success -> redirect("/dashboard")
is AuthResult.InvalidCredentials -> showRetryForm(result.attemptsRemaining)
AuthResult.AccountLocked -> showLockedPage()
is AuthResult.Error -> showError(result.cause)
}
Why: An else branch defeats the exhaustiveness check that sealed classes provide; adding a new subtype compiles silently but takes the wrong code path at runtime.
Launching Coroutines Without Structured Concurrency
Wrong:
fun loadDashboard(userId: UserId) {
GlobalScope.launch {
val user = userRepo.findById(userId)
_state.value = UiState.Success(user)
}
}
Correct:
fun loadDashboard(userId: UserId) {
viewModelScope.launch {
val user = userRepo.findById(userId)
_state.value = UiState.Success(user)
}
}
Why: GlobalScope coroutines are not tied to any lifecycle and continue running even after the owning component is destroyed, causing memory leaks and stale state updates.
Using Raw String Primitives for Domain Identifiers
Wrong:
fun findUser(id: String): User?
fun sendWelcome(email: String): Unit
Correct:
@JvmInline value class UserId(val value: String)
@JvmInline value class Email(val value: String) {
init { require(value.contains('@')) { "Invalid email: $value" } }
}
fun findUser(id: UserId): User?
fun sendWelcome(to: Email): Unit
Why: Raw String parameters allow callers to accidentally pass the wrong kind of string; value classes are erased at runtime (no boxing overhead) and make type errors compile-time failures.
Checklist