Skip to main content

Kotlin Best Practices

Overview

This guide covers comprehensive Kotlin best practices including null safety, coroutines and structured concurrency, Flow for reactive programming, scope functions, extension functions, sealed classes for type-safe hierarchies, data classes for DTOs, delegation patterns, and modern Kotlin idioms. These patterns and practices are essential for writing robust, maintainable, and performant Kotlin code across Android, backend, and multiplatform applications.


Core Principles

  1. Null Safety First: Leverage Kotlin's type system to eliminate NPEs
  2. Coroutines for Async: Use coroutines with proper structured concurrency
  3. Flow for Streams: Reactive data streams with Flow operators
  4. Immutability Default: Prefer val over var, immutable collections
  5. Sealed Classes: Type-safe state representation
  6. Extension Functions: Add functionality without inheritance
  7. Scope Functions: Use let, apply, run, also, with appropriately
  8. Idiomatic Kotlin: Write concise, expressive code
  9. Data Classes: Automatic equals/hashCode/toString for DTOs
  10. Smart Casts: Leverage Kotlin's type inference

Null Safety

Kotlin's null safety system is one of its most important features, designed to eliminate NullPointerExceptions (NPEs) at compile time rather than runtime. The type system distinguishes between nullable and non-nullable references, making null handling explicit and preventing the "billion-dollar mistake" of null references.

Why Null Safety Matters

NullPointerExceptions cause runtime crashes in Java applications. Kotlin's type system forces developers to explicitly handle null cases at compile time, shifting errors from runtime to compile time. Every type has a nullable variant (denoted with ?), and the compiler enforces null checks before accessing nullable references.

The compiler catches potential null dereferences before code runs, and nullable types explicitly communicate that a value might be absent. In Android applications, unhandled nulls crash the app and frustrate users. In backend services, null-related errors cause service disruptions and data corruption.

Nullable vs Non-Nullable Types

When you declare a variable in Kotlin, you must decide whether it can hold null. A non-nullable type (e.g., String) guarantees the variable always contains a value, while a nullable type (e.g., String?) allows null. This distinction is enforced by the compiler - you cannot assign null to a non-nullable type, and you cannot access members of a nullable type without explicit null handling.

// GOOD: Explicit null safety
class PaymentService {
// Non-nullable - must always have value
// The compiler ensures this is initialized
private val repository: PaymentRepository = PaymentRepositoryImpl()

// Nullable - explicitly allows null
// The ? suffix indicates this might be absent
private var currentPayment: Payment? = null

fun processPayment(payment: Payment?) {
// Smart cast after null check
// The compiler tracks null checks and automatically casts
if (payment != null) {
// Within this block, payment is treated as non-nullable
processValidPayment(payment)
}
}

private fun processValidPayment(payment: Payment) {
// No null checks needed - parameter is non-nullable
// The compiler guarantees payment cannot be null here
println("Processing payment: ${payment.id}")
}
}

// BAD: Using !! (null assertion) - can crash
// The !! operator bypasses null safety and can throw NPE
fun processPayment(payment: Payment?) {
val amount = payment!!.amount // Crashes if payment is null!
}

The !! operator (not-null assertion) should be avoided in production code. It essentially tells the compiler "I know this is not null, trust me," which defeats the purpose of Kotlin's null safety. If the value is actually null, it throws a NullPointerException, exactly what Kotlin's type system is designed to prevent. Use it only in test code or when you have an absolute guarantee the value is non-null and refactoring to prove it to the compiler is impractical.

Safe Call Operator

The safe call operator (?.) is the primary tool for working with nullable types. It performs an operation only if the value is non-null; otherwise, it short-circuits and returns null. This eliminates the need for explicit null checks in many scenarios and enables clean chaining of operations on potentially null values.

When you write payment?.amount, Kotlin checks if payment is null. If it is, the entire expression evaluates to null without attempting to access the amount property. If payment is non-null, it accesses amount normally. This is equivalent to writing if (payment != null) payment.amount else null but far more concise.

The safe call operator truly shines when chaining multiple operations. Each ?. in the chain acts as a null check, so payment?.customer?.email?.lowercase() safely navigates through potentially null references. If any reference in the chain is null, the entire expression evaluates to null without throwing an exception.

// GOOD: Safe call operator (?.)
// Returns amount if payment is non-null, otherwise returns null
fun getPaymentAmount(payment: Payment?): BigDecimal? {
return payment?.amount
}

// GOOD: Chaining safe calls
// Safely navigate through multiple nullable references
// If any link in the chain is null, returns null immediately
fun getCustomerEmail(payment: Payment?): String? {
return payment?.customer?.email?.lowercase()
}

// GOOD: Elvis operator for defaults
// The ?: operator provides a default value when left side is null
// This pattern is especially useful for UI display logic
fun getPaymentDescription(payment: Payment?): String {
return payment?.description ?: "No description provided"
}

// GOOD: Safe cast
// Returns typed value if cast succeeds, null if it fails
// Safer than regular cast which throws ClassCastException
fun handleResult(result: Any): Payment? {
return result as? Payment // Returns null if cast fails
}

The Elvis operator (?:) complements the safe call operator by providing default values. The expression a ?: b returns a if a is non-null, otherwise it returns b. This is commonly used to provide fallback values or throw exceptions with custom messages (e.g., val name = user?.name ?: throw IllegalStateException("User must have a name")). See our error handling guidelines for more patterns.

Let Function for Null Checks

// GOOD: Use let for null-safe operations
fun processPaymentIfPresent(payment: Payment?) {
payment?.let { safePayment ->
// Only executes if payment is not null
validatePayment(safePayment)
sendToGateway(safePayment)
auditLog(safePayment)
}
}

// GOOD: let with elvis for null handling
fun getFormattedAmount(payment: Payment?): String {
return payment?.let { p ->
NumberFormat.getCurrencyInstance().format(p.amount)
} ?: "N/A"
}

// BAD: Using if checks excessively
fun processPaymentIfPresent(payment: Payment?) {
if (payment != null) {
validatePayment(payment)
sendToGateway(payment)
auditLog(payment)
}
}

RequireNotNull and CheckNotNull

// GOOD: Use requireNotNull for preconditions
fun processPayment(payment: Payment?) {
val safePayment = requireNotNull(payment) {
"Payment cannot be null"
}
// safePayment is now non-nullable
gateway.process(safePayment)
}

// GOOD: Use checkNotNull for state validation
class PaymentProcessor {
private var gateway: PaymentGateway? = null

fun initialize(gateway: PaymentGateway) {
this.gateway = gateway
}

fun process(payment: Payment) {
val safeGateway = checkNotNull(gateway) {
"Gateway not initialized"
}
safeGateway.process(payment)
}
}

Coroutines and Concurrency

Kotlin coroutines and concurrency patterns are covered in depth in our dedicated Kotlin Concurrency guide. This includes structured concurrency, channels, actors, Flow, StateFlow/SharedFlow, error handling, cancellation, and thread-safe state management.

For a comprehensive understanding of Kotlin's concurrency model, see Kotlin Concurrency.


Scope Functions

Kotlin provides five scope functions (let, apply, run, also, with) that execute a block of code within the context of an object. They differ in how they reference the object (as it or this), what they return (the object or the lambda result), and whether they're extension functions or not. Choosing the right scope function makes code more concise and expressive.

Understanding Scope Functions

Scope functions don't provide new functionality - anything you do with them can be done without them. Their value is readability and expressiveness. They create a temporary scope for an object, making code more fluent and reducing repetition. However, overusing scope functions or nesting them deeply reduces readability.

The key differences:

  • Object Reference: this (implicit) vs it (explicit parameter)
  • Return Value: The object itself vs the lambda result
  • Use Case: Configuration, transformation, null-safety, side-effects, or grouping operations

Choose based on your intent: let for transforming or null-checking, apply for configuring objects, run for computing results, also for side effects, with for calling multiple methods on an object without extension syntax.

Let - Null Safety and Transformations

The let function calls the lambda with the object as the argument (it) and returns the lambda result. Use let for null-safe operations (with ?.let) or to transform a value. The explicit it parameter makes it clear when the object is being referenced, unlike this which can be implicit.

// GOOD: let for null-safe operations
// Only executes block if payment is non-null
// The smart cast makes 'p' non-nullable inside the lambda
fun processPayment(payment: Payment?) {
payment?.let { p ->
validatePayment(p)
sendToGateway(p)
}
}

// GOOD: let for transformations
// Transform the amount and assign the result
val formattedAmount = payment.amount.let { amount ->
NumberFormat.getCurrencyInstance().format(amount)
}

// GOOD: let to limit variable scope
fun processData() {
val result = fetchData().let { rawData ->
// rawData only exists in this scope
transform(rawData)
validate(rawData)
rawData.toProcessedData()
}
// rawData not accessible here
}

The pattern ?.let { } is idiomatic for executing code only when a value is non-null, avoiding explicit if checks. The lambda result becomes the result of the entire expression, making let natural for transformation pipelines.

Apply - Object Configuration

The apply function calls the lambda with the object as this (accessible implicitly) and returns the object itself. Use apply when you want to configure an object's properties. Because it returns the object, apply enables method chaining and is perfect for builder patterns.

// GOOD: apply for object initialization
// Returns the Payment object after configuration
val payment = Payment().apply {
id = UUID.randomUUID().toString()
recipientName = "John Doe"
amount = BigDecimal("100.00")
currency = "USD"
createdAt = System.currentTimeMillis()
// 'this' is implicit - we're directly setting properties
}

// GOOD: apply with builders
// Returns the builder, then calls build()
val request = PaymentRequest.Builder().apply {
setCustomerId(customerId)
setAmount(amount)
setCurrency(currency)
setDescription(description)
}.build()

// GOOD: apply for configuring and returning object
fun createDefaultPayment() = Payment().apply {
status = PaymentStatus.PENDING
currency = "USD"
createdAt = System.currentTimeMillis()
}

The implicit this makes property access clean - you don't need to repeat the object name. apply is particularly useful in factory functions or when initializing objects with many properties.

Run - Execute Code Block

The run function calls the lambda with the object as this and returns the lambda result. Use run when you need to compute a result from an object's context, or when you want to group multiple operations and return a value. It's similar to let but with this instead of it.

// GOOD: run for complex initialization
// Computes a result from multiple operations
val result = run {
val payments = repository.getPayments()
val totalAmount = payments.sumOf { it.amount }
val averageAmount = totalAmount / payments.size

PaymentSummary(
total = totalAmount,
average = averageAmount,
count = payments.size
)
}

// GOOD: run for scoped operations
// Groups database operations and handles transactions
database.run {
beginTransaction()
try {
paymentDao.insertAll(payments)
accountDao.updateBalance(newBalance)
setTransactionSuccessful()
} finally {
endTransaction()
}
}

// GOOD: run for null-safe operations with result
val length = str?.run {
// 'this' is the non-null string
uppercase().trim().length
}

run is useful when you need to perform multiple operations on an object and return a computed result. It's also handy for limiting the scope of temporary variables used in a computation.

Also - Side Effects

The also function calls the lambda with the object as it and returns the object itself. Use also for side effects like logging, debugging, or additional actions that don't modify the object. Because it returns the object, also works well in method chains.

// GOOD: also for logging/debugging
// Each 'also' returns the payment, enabling chaining
val payment = createPayment(request)
.also { p -> log.debug("Created payment: ${p.id}") }
.also { p -> auditService.log("Payment created", p) }

// GOOD: also in chains
// Logging between transformation steps without breaking the chain
val result = repository.getPayments()
.also { println("Loaded ${it.size} payments") }
.filter { it.status == PaymentStatus.COMPLETED }
.also { println("Found ${it.size} completed payments") }
.map { it.amount }

// GOOD: also for side effects during object creation
val user = User(id, name).also {
analytics.track("User Created", it.id)
cache.put(it.id, it)
}

The explicit it parameter makes it clear you're performing a side effect - you're using the object but not transforming it. Unlike apply, which is for configuration, also is for external actions.

With - Multiple Operations

The with function is not an extension - it takes an object as a parameter, calls the lambda with it as this, and returns the lambda result. Use with when you want to call multiple functions on an object without extension syntax. It's useful for grouping related operations on an object you already have.

// GOOD: with for multiple operations on same object
// Avoids repeating 'paymentView' on every line
with(paymentView) {
recipientNameText.text = payment.recipientName
amountText.text = formatCurrency(payment.amount)
statusIcon.setImageResource(getStatusIcon(payment.status))
createdAtText.text = formatDate(payment.createdAt)
}

// BAD: Repetitive code without with
paymentView.recipientNameText.text = payment.recipientName
paymentView.amountText.text = formatCurrency(payment.amount)
paymentView.statusIcon.setImageResource(getStatusIcon(payment.status))
paymentView.createdAtText.text = formatDate(payment.createdAt)

// GOOD: with for computing result from object
val summary = with(payment) {
"Payment $id: $recipientName - ${amount.formatAsCurrency(currency)}"
}

with is similar to run, but it's not an extension function - you pass the object as an argument. Use with when you have an object and want to perform multiple operations on it in a localized scope.


Extension Functions

Extension functions allow you to add new functions to existing classes without modifying their source code or using inheritance. From the caller's perspective, an extension function looks like a member function. However, extension functions are resolved statically - they don't actually modify the class, and they can't access private members.

Why Extension Functions Matter

Extension functions solve several problems. They allow you to add domain-specific functionality to library classes you don't control (like String or List). They enable a fluent, readable API where operations are called on objects rather than passed as parameters to utility functions. They also help organize code - related extensions can be grouped in a file, providing discoverability.

Extensions are particularly useful for converting between types, adding validation logic, or providing convenient wrappers around complex operations. Because they're resolved at compile time based on the declared type (not runtime type), they're safe to use and don't incur runtime overhead beyond a regular function call.

Limitations: Extensions can't override existing members - if a class already has a member function with the same signature, the member always wins. Extensions can't access private or protected members of the class. They're resolved statically, so polymorphism doesn't work (the extension called is determined by the declared type, not the runtime type).

Adding Functionality to Existing Classes

// GOOD: Extension functions for common operations
// Adds a method to BigDecimal without modifying the class
fun BigDecimal.formatAsCurrency(currencyCode: String = "USD"): String {
val formatter = NumberFormat.getCurrencyInstance()
formatter.currency = Currency.getInstance(currencyCode)
return formatter.format(this)
}

// GOOD: Extension for date formatting
fun Long.toFormattedDate(pattern: String = "yyyy-MM-dd HH:mm:ss"): String {
val formatter = SimpleDateFormat(pattern, Locale.getDefault())
return formatter.format(Date(this))
}

// GOOD: Domain-specific validation
fun String.isValidAccountNumber(): Boolean {
return this.length in 10..20 && this.all { it.isDigit() }
}

// Usage - looks like member functions
val formatted = payment.amount.formatAsCurrency("EUR")
val dateString = payment.createdAt.toFormattedDate()
val isValid = accountNumber.isValidAccountNumber()

// BAD: Utility class with static methods (Java style)
object StringUtils {
fun isValidAccountNumber(value: String): Boolean {
return value.length in 10..20 && value.all { it.isDigit() }
}
}
// Less fluent: StringUtils.isValidAccountNumber(accountNumber)

Extension functions promote fluent APIs and are idiomatic in Kotlin. They're preferable to utility classes with static methods because they're more discoverable (IDE autocomplete shows them when you type the object) and more readable.

Extension Functions on Custom Types

Extension functions work equally well on your own domain types. This is particularly powerful for adding domain-specific behavior to data classes without polluting the class itself with methods. You can group related extensions in separate files (e.g., PaymentExtensions.kt), keeping domain models focused on data and separating behavior.

Extensions on collection types (like List<Payment>) provide domain-specific collection operations that are more readable than generic functional operations. They encapsulate common patterns and make code self-documenting.

// GOOD: Extensions for domain models
// These add behavior without modifying the Payment class
fun Payment.isCompleted(): Boolean = status == PaymentStatus.COMPLETED

fun Payment.canBeCancelled(): Boolean =
status in listOf(PaymentStatus.PENDING, PaymentStatus.SCHEDULED)

// GOOD: Extensions on collection types
// More readable than generic fold/filter operations
fun List<Payment>.totalAmount(): BigDecimal =
fold(BigDecimal.ZERO) { acc, payment -> acc + payment.amount }

fun List<Payment>.filterByStatus(vararg statuses: PaymentStatus): List<Payment> =
filter { it.status in statuses }

// GOOD: Extensions that encapsulate business logic
fun Payment.requiresApproval(): Boolean =
amount > BigDecimal("5000") && status == PaymentStatus.PENDING

fun List<Payment>.findPending(): List<Payment> =
filter { it.status == PaymentStatus.PENDING }

// Usage - reads like domain language
val total = payments.totalAmount()
val pending = payments.filterByStatus(PaymentStatus.PENDING, PaymentStatus.SCHEDULED)
val needsApproval = payment.requiresApproval()

// BAD: Adding methods to the data class directly
data class Payment(
val id: String,
val amount: BigDecimal,
val status: PaymentStatus
) {
// Mixing data and behavior in the data class
fun isCompleted() = status == PaymentStatus.COMPLETED
fun canBeCancelled() = status in listOf(PaymentStatus.PENDING, PaymentStatus.SCHEDULED)
}

By using extensions, you keep data classes focused on their primary purpose (holding data) and organize behavior in separate files. This improves testability (you can test extensions independently) and maintainability (behavior changes don't affect the data class).

Extension Properties

Extension properties work like extension functions but are accessed like properties. They must be computed (have a getter) because extensions can't add actual state to a class. Extension properties are useful for derived values or convenient aliases that read more naturally as properties than function calls.

Important: Extension properties can only have getters - they can't store state. They can't have backing fields because extensions don't actually modify the class. Every access to an extension property recomputes the value, so avoid expensive operations in extension property getters.

// GOOD: Extension properties for derived values
val Payment.formattedAmount: String
get() = amount.formatAsCurrency(currency)

val Payment.isLargePayment: Boolean
get() = amount > BigDecimal("10000")

// GOOD: Extension properties on collections
val List<Payment>.completedPayments: List<Payment>
get() = filter { it.status == PaymentStatus.COMPLETED }

val List<Payment>.totalValue: BigDecimal
get() = sumOf { it.amount }

// Usage - reads like accessing properties
Text(payment.formattedAmount)
if (payment.isLargePayment) {
showWarning()
}
val completed = payments.completedPayments
val total = payments.totalValue

// BAD: Expensive computation in extension property
val Payment.riskScore: Double
get() = complexRiskAnalysis() // Looks cheap but is expensive!

// GOOD: Use a function for expensive operations
fun Payment.calculateRiskScore(): Double = complexRiskAnalysis()

// BAD: Trying to add state (doesn't compile)
var Payment.cachedScore: Double = 0.0 // ERROR: Extension properties cannot have backing fields

Extension properties should be reserved for cheap, derived values. If the computation is expensive, use a function instead - the parentheses signal to readers that work is being done.


Sealed Classes

Sealed classes and sealed interfaces restrict class hierarchies to a known set of subtypes, all defined in the same file (or module in Kotlin 1.5+). This enables the compiler to verify exhaustiveness - when you use a when expression on a sealed type, the compiler knows all possible subtypes and can ensure you handle every case.

Why Sealed Classes Matter

Sealed classes are particularly valuable for representing state, results, or any domain concept with a finite set of possibilities. Unlike enums, sealed classes can hold data specific to each subtype. Unlike open classes, the set of subtypes is fixed and known at compile time, enabling exhaustive checking.

The compiler's exhaustiveness checking is the key benefit. When you write a when expression on a sealed type, you must handle all subtypes - the compiler won't allow missing cases. This makes refactoring safer: if you add a new subtype to a sealed hierarchy, the compiler immediately identifies every place in your codebase that needs to handle the new case. This is impossible with regular inheritance where new subclasses can be added anywhere.

Sealed classes are commonly used to represent UI state, API results, navigation events, or any discriminated union type. They provide type-safe pattern matching similar to algebraic data types in functional languages. For more on modeling state with sealed classes in Android, see our Android architecture guide.

Type-Safe State Representation

// GOOD: Sealed class for payment states
sealed class PaymentState {
object Idle : PaymentState()
object Loading : PaymentState()
data class Success(val receipt: PaymentReceipt) : PaymentState()
data class Error(val message: String, val errorCode: String) : PaymentState()
}

// Exhaustive when expression
fun handlePaymentState(state: PaymentState) {
when (state) {
is PaymentState.Idle -> showIdleState()
is PaymentState.Loading -> showLoadingSpinner()
is PaymentState.Success -> showReceipt(state.receipt)
is PaymentState.Error -> showError(state.message, state.errorCode)
} // No else needed - compiler enforces exhaustiveness
}

Sealed Interface for Multiple Hierarchies

// GOOD: Sealed interface for results
sealed interface PaymentResult {
data class Success(
val transactionId: String,
val amount: BigDecimal,
val timestamp: Long
) : PaymentResult

data class Failure(
val errorCode: String,
val errorMessage: String
) : PaymentResult

data class Pending(
val pendingId: String,
val estimatedCompletion: Long
) : PaymentResult
}

// GOOD: Pattern matching with sealed types
fun formatPaymentResult(result: PaymentResult): String {
return when (result) {
is PaymentResult.Success ->
"Payment successful: ${result.transactionId}"
is PaymentResult.Failure ->
"Payment failed: ${result.errorCode} - ${result.errorMessage}"
is PaymentResult.Pending ->
"Payment pending: ${result.pendingId}"
}
}

Sealed Class vs Enum

Enums and sealed classes both represent a fixed set of values, but they serve different purposes. Enums are best for simple constant sets where each value is equivalent (e.g., days of the week, status codes). Sealed classes are better when each case needs to carry different data or have different behavior.

Enums are singletons - there's exactly one instance of each enum constant. They're memory-efficient and provide built-in features like ordinal values and values() arrays. However, enums cannot hold data specific to each constant (beyond properties shared by all constants), and they can't have different constructor signatures.

Sealed classes allow each subtype to have its own structure. A Success case can carry a transaction ID, while a Failure case carries an error code and message. This flexibility makes sealed classes ideal for discriminated unions where different cases have fundamentally different data. Use enums when all cases are structurally identical; use sealed classes when cases have unique data or behavior.

// BAD: Enum with associated data (not possible in Kotlin)
enum class PaymentStatus {
SUCCESS, // Can't attach transaction ID per-instance
FAILURE // Can't attach error message per-instance
}
// You'd need parallel data structures, losing type safety

// GOOD: Sealed class with associated data
// Each subtype has its own unique properties
sealed class PaymentStatus {
data class Success(val transactionId: String) : PaymentStatus()
data class Failure(val errorCode: String, val message: String) : PaymentStatus()
object Pending : PaymentStatus() // Object when no data needed
object Cancelled : PaymentStatus()
}

// Type-safe access to subtype-specific data
fun handleStatus(status: PaymentStatus) {
when (status) {
is PaymentStatus.Success -> println("Transaction: ${status.transactionId}")
is PaymentStatus.Failure -> println("Error ${status.errorCode}: ${status.message}")
is PaymentStatus.Pending -> println("Waiting...")
is PaymentStatus.Cancelled -> println("Cancelled")
}
}

Data Classes

Data classes are a concise way to create classes whose primary purpose is holding data. The compiler automatically generates equals(), hashCode(), toString(), copy(), and componentN() functions based on properties declared in the primary constructor. This eliminates boilerplate and ensures consistent implementations of these methods.

Why Data Classes Matter

Writing equals(), hashCode(), and toString() manually is tedious and error-prone. Forgetting to update these methods when adding properties leads to subtle bugs. Data classes automate this - add a property to the constructor and all methods automatically include it.

The copy() function is particularly valuable for working with immutable data. It creates a new instance with specified properties changed, keeping others unchanged. This enables functional-style updates without mutation. The componentN() functions enable destructuring, allowing you to extract properties positionally.

When to use data classes: For DTOs, value objects, and any class primarily holding data. Don't use data classes for classes with complex behavior, mutable state managed beyond simple properties, or inheritance hierarchies (data classes can extend interfaces but not other classes, and can't be extended).

// GOOD: Data class for DTOs
data class Payment(
val id: String,
val customerId: String,
val recipientName: String,
val amount: BigDecimal,
val currency: String,
val status: PaymentStatus,
val createdAt: Long,
val completedAt: Long? = null
) {
// Business logic methods are allowed
// But keep data classes focused on data
fun isCompleted(): Boolean = status == PaymentStatus.COMPLETED

fun isOverdue(): Boolean {
val daysSinceCreation = (System.currentTimeMillis() - createdAt) / (1000 * 60 * 60 * 24)
return status == PaymentStatus.PENDING && daysSinceCreation > 7
}
}

// Automatic implementations generated by compiler:
// - equals() - structural equality based on all properties
// - hashCode() - consistent with equals
// - toString() - readable string representation
// - copy() - create modified copies
// - componentN() - destructuring support

// BAD: Regular class requiring manual implementations
class Payment(
val id: String,
val amount: BigDecimal,
val status: PaymentStatus
) {
// Must manually implement equals, hashCode, toString, etc.
override fun equals(other: Any?): Boolean { ... }
override fun hashCode(): Int { ... }
override fun toString(): String { ... }
}

All properties in the primary constructor participate in generated functions. Properties declared in the class body don't. Use val for immutable properties - data classes work best with immutability.

Copy for Immutable Updates

The copy() function creates a new instance of a data class with some properties changed. This is fundamental to functional programming patterns - instead of mutating an object, you create a modified copy. The original remains unchanged, preventing unexpected side effects and making code easier to reason about.

copy() uses named arguments with defaults matching the original object's values. You only specify properties you want to change - unspecified properties are copied from the original. This makes immutable updates concise and safe.

Why immutability matters: Immutable objects are thread-safe by default, easier to test (no hidden state changes), and prevent bugs from unexpected mutations. When objects don't change after creation, you can pass them freely without defensive copying.

// GOOD: Using copy for updates
val originalPayment = Payment(
id = "123",
customerId = "customer1",
recipientName = "John Doe",
amount = BigDecimal("100.00"),
currency = "USD",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis()
)

// Create modified copy - original unchanged
val completedPayment = originalPayment.copy(
status = PaymentStatus.COMPLETED,
completedAt = System.currentTimeMillis()
)

// Original remains unchanged - immutability preserved
println(originalPayment.status) // PENDING
println(completedPayment.status) // COMPLETED

// GOOD: Chaining copies for multiple updates
val updatedPayment = payment
.copy(status = PaymentStatus.PROCESSING)
.copy(processingStarted = System.currentTimeMillis())

// GOOD: Conditional updates
fun updatePaymentStatus(payment: Payment, newStatus: PaymentStatus): Payment {
return if (payment.status != newStatus) {
payment.copy(status = newStatus, lastModified = System.currentTimeMillis())
} else {
payment // No change needed, return original
}
}

// BAD: Mutable data class (defeats the purpose)
data class Payment(
var status: PaymentStatus, // Mutable - anyone can change it
var amount: BigDecimal
)

fun processPayment(payment: Payment) {
payment.status = PaymentStatus.COMPLETED // Mutation - original is modified!
}

Using copy() with immutable properties (val) enables safe, predictable updates. This pattern is essential in state management for Android ViewModels and reactive programming with Flow.

Destructuring

Destructuring allows you to extract multiple properties from a data class in a single statement. The compiler generates component1(), component2(), etc., for each property in declaration order. This is convenient for extracting specific properties without verbose property access.

Use destructuring for: Extracting a few properties you need, especially in loops or when processing collections. Avoid destructuring for: All properties (use the object instead), or when property order isn't obvious (named access is clearer).

// GOOD: Destructuring data classes
val payment = Payment(...)

// Extract specific properties by position
val (id, customerId, recipientName, amount) = payment
println("Payment $id for $amount")

// GOOD: Destructuring in loops
for ((id, _, name, amount) in payments) {
// Use _ to skip properties you don't need
println("$name: $amount")
}

// GOOD: Destructuring for returns
fun findPayment(id: String): Pair<Boolean, Payment?> {
val payment = repository.findById(id)
return Pair(payment != null, payment)
}

val (found, payment) = findPayment("123")
if (found) {
process(payment!!)
}

// GOOD: Destructuring with lambda parameters
payments.forEach { (id, _, name, amount, currency) ->
println("Payment $id: $name - $amount $currency")
}

// BAD: Destructuring too many properties (confusing)
val (id, customerId, recipientName, amount, currency, status, createdAt, completedAt) = payment
// Hard to remember order, easy to mix up
// Better: val amount = payment.amount

// BAD: Destructuring when order isn't obvious
data class Rectangle(val x: Int, val y: Int, val width: Int, val height: Int)
val (a, b, c, d) = rectangle // What is 'c'? Width or height?
// Better: rectangle.width, rectangle.height

Destructuring is most valuable when you need a few specific properties and their order is obvious. For complex objects or when all properties are needed, use the object directly for better clarity.


Delegation

Delegation is a design pattern where an object handles a request by delegating to another object. Kotlin provides first-class support for delegation at both the property level (delegated properties) and class level (interface delegation), eliminating boilerplate code.

Property Delegation

Property delegation allows you to delegate the get/set logic of a property to another object. Instead of implementing getters and setters manually, you use the by keyword to delegate to a helper object that implements getValue() and setValue() operators. The standard library provides useful delegates like lazy, observable, and notNull.

Lazy delegation is particularly powerful - it defers expensive initialization until the property is first accessed, and caches the result. This is thread-safe by default. The initialization block runs exactly once, even if multiple threads access the property simultaneously.

Observable delegation allows you to react to property changes. This is useful for triggering side effects like updating UI, recalculating derived values, or logging changes without cluttering property setters.

// GOOD: Lazy initialization
class PaymentService {
// Computed only when first accessed, then cached
// Thread-safe by default
private val heavyResource: ExpensiveObject by lazy {
println("Initializing expensive object")
ExpensiveObject()
}

fun doWork() {
heavyResource.performOperation() // Initialized on first access
heavyResource.performOperation() // Uses cached instance
}
}

// GOOD: Observable properties for reactive updates
class PaymentViewModel : ViewModel() {
var paymentAmount: BigDecimal by Delegates.observable(BigDecimal.ZERO) { property, oldValue, newValue ->
if (oldValue != newValue) {
println("${property.name} changed from $oldValue to $newValue")
recalculateFees(newValue)
updateUI()
}
}
}

// GOOD: Vetoable delegation - can reject changes
class PaymentForm {
var amount: BigDecimal by Delegates.vetoable(BigDecimal.ZERO) { _, oldValue, newValue ->
// Reject negative amounts
newValue >= BigDecimal.ZERO
}
}

// GOOD: NotNull delegation for late initialization
class PaymentProcessor {
// Must be set before use, throws exception if accessed before set
var gateway: PaymentGateway by Delegates.notNull()

fun initialize(gateway: PaymentGateway) {
this.gateway = gateway
}
}

// GOOD: Custom delegation
class Preference<T>(private val key: String, private val default: T) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
return sharedPreferences.get(key) ?: default
}

operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
sharedPreferences.put(key, value)
}
}

class Settings {
var username: String by Preference("username", "")
var darkMode: Boolean by Preference("dark_mode", false)
}

Property delegation reduces boilerplate for common patterns like lazy initialization, observable properties, and property-level validation. It's more maintainable than manual getter/setter implementations.

Interface Delegation

Interface delegation (also called class delegation) allows a class to implement an interface by delegating to another object. The compiler automatically generates forwarding methods for all interface members. You can selectively override specific methods while delegating the rest, following the decorator pattern without boilerplate.

This is powerful for adding cross-cutting concerns (logging, caching, security checks) without modifying existing implementations. It also enables composition over inheritance - build complex behaviors by combining simple delegating classes.

// GOOD: Interface delegation pattern
interface PaymentProcessor {
fun process(payment: Payment): PaymentResult
fun cancel(paymentId: String): Boolean
fun getStatus(paymentId: String): PaymentStatus
}

class StandardPaymentProcessor : PaymentProcessor {
override fun process(payment: Payment): PaymentResult {
// Standard processing logic
return PaymentResult.Success(...)
}

override fun cancel(paymentId: String): Boolean {
// Standard cancellation logic
return true
}

override fun getStatus(paymentId: String): PaymentStatus {
// Standard status check
return PaymentStatus.COMPLETED
}
}

// Delegate to standard processor, override only what you need
class LoggingPaymentProcessor(
private val logger: Logger,
processor: PaymentProcessor
) : PaymentProcessor by processor {
// Only override process() - others delegated automatically
override fun process(payment: Payment): PaymentResult {
logger.info("Processing payment: ${payment.id}")
val result = processor.process(payment)
logger.info("Payment processed: $result")
return result
}
// cancel() and getStatus() automatically delegate to processor
}

// GOOD: Chaining decorators
val processor = LoggingPaymentProcessor(
logger,
CachingPaymentProcessor(
cache,
RetryPaymentProcessor(
retryPolicy,
StandardPaymentProcessor()
)
)
)

// BAD: Manual forwarding without delegation (boilerplate)
class LoggingPaymentProcessor(
private val processor: PaymentProcessor
) : PaymentProcessor {
override fun process(payment: Payment) = processor.process(payment)
override fun cancel(paymentId: String) = processor.cancel(paymentId)
override fun getStatus(paymentId: String) = processor.getStatus(paymentId)
// Must manually forward every method!
}

Interface delegation is essential for implementing decorators, proxies, and adapters without boilerplate. It's particularly valuable when interfaces have many methods but you only want to customize a few.


Collections and Sequences

Kotlin distinguishes between read-only and mutable collections at the type level. List, Set, and Map are read-only interfaces - you can't add or remove elements. MutableList, MutableSet, and MutableMap provide modification operations. This distinction helps prevent unintended mutations and makes code more predictable.

Immutable Collections

Read-only collections provide interface immutability - the interface doesn't allow modifications, but the underlying implementation might still be mutable (if someone holds a mutable reference). For true immutability, use persistent collections from kotlinx.collections.immutable or ensure the underlying implementation is immutable.

Why prefer read-only collections: They communicate intent - this collection won't change. They're safer to share - recipients can't modify shared data. They enable compiler optimizations and make concurrent code easier to reason about. Use mutable collections internally when needed, but expose read-only interfaces.

// GOOD: Prefer read-only collections
val currencies = listOf("USD", "EUR", "GBP")
val supportedStatuses = setOf(PaymentStatus.PENDING, PaymentStatus.COMPLETED)
val exchangeRates = mapOf("USD" to 1.0, "EUR" to 0.85, "GBP" to 0.73)

// BAD: Mutable collections when immutability isn't needed
val currencies = mutableListOf("USD", "EUR", "GBP")
// Exposes mutability when not required

// GOOD: Mutable internally, read-only externally
class PaymentRepository {
private val _payments = mutableListOf<Payment>()
val payments: List<Payment> get() = _payments // Read-only view

fun addPayment(payment: Payment) {
_payments.add(payment)
}
}

// GOOD: Building collections
val payments = buildList {
add(Payment(...))
add(Payment(...))
if (condition) {
add(Payment(...))
}
} // Returns read-only List

// GOOD: Converting mutable to read-only
val mutableList = mutableListOf(1, 2, 3)
val readOnlyList: List<Int> = mutableList // Read-only view
mutableList.add(4) // Still mutable through original reference
println(readOnlyList) // [1, 2, 3, 4] - reflects changes

// GOOD: True immutability (defensive copy)
fun getPayments(): List<Payment> = _payments.toList() // Creates immutable copy

The listOf(), setOf(), and mapOf() functions return read-only collections. The compiler prevents calling mutation methods on these types, catching errors at compile time rather than runtime.

Collection Operations

Kotlin's collection API provides rich functional operations - map, filter, reduce, groupBy, etc. These operations are eager - they process the entire collection immediately and return a new collection. This is fine for small collections but inefficient for large collections or when chaining multiple operations.

Key operations:

  • Transformations: map, flatMap, zip
  • Filtering: filter, filterNot, filterIsInstance, take, drop
  • Aggregations: reduce, fold, sumOf, count, groupBy
  • Element operations: first, last, find, single, elementAt
  • Boolean checks: any, all, none, contains
// GOOD: Functional collection operations
val completedPayments = payments.filter { it.status == PaymentStatus.COMPLETED }

val totalAmount = payments
.filter { it.status == PaymentStatus.COMPLETED }
.sumOf { it.amount }

// GOOD: groupBy for categorization
val paymentsByCustomer: Map<String, List<Payment>> = payments.groupBy { it.customerId }

val recipientNames = payments.map { it.recipientName }.distinct()

// GOOD: fold for complex aggregations
val summary = payments.fold(PaymentSummary()) { acc, payment ->
acc.copy(
total = acc.total + payment.amount,
count = acc.count + 1,
completed = if (payment.isCompleted()) acc.completed + 1 else acc.completed
)
}

// GOOD: partition splits into two lists
val (completed, pending) = payments.partition { it.status == PaymentStatus.COMPLETED }

// GOOD: Use sequences for large collections or multiple operations
val result = payments.asSequence()
.filter { it.amount > BigDecimal("1000") }
.map { it.customerId }
.distinct()
.take(10)
.toList()

// BAD: Imperative loops when functional operations are clearer
val completedPayments = mutableListOf<Payment>()
for (payment in payments) {
if (payment.status == PaymentStatus.COMPLETED) {
completedPayments.add(payment)
}
}
// Less declarative than filter

Functional collection operations are more declarative - they express what you want, not how to get it. They're also safer (no index errors) and more composable. For sequence optimization and lazy evaluation, see our Kotlin Performance guide.


Kotlin Idioms

Kotlin provides several language features that enable concise, expressive code. Understanding and using these idioms makes code more Kotlin-like and easier for Kotlin developers to read and maintain.

String Templates

String templates enable embedding expressions directly in strings. Use $variable for simple variables or ${expression} for complex expressions. This is more readable than string concatenation and prevents errors from missing spaces or operators.

Multi-line strings (raw strings) use triple quotes (""") and preserve formatting including newlines and indentation. Use trimIndent() to remove common leading whitespace, allowing you to indent multi-line strings naturally in code without affecting the result.

// GOOD: String templates for simple interpolation
val message = "Payment ${payment.id} for ${payment.amount} ${payment.currency}"

// GOOD: Expressions in templates
val status = "Payment is ${if (payment.isCompleted()) "completed" else "pending"}"

// GOOD: Multi-line strings with trimIndent
val json = """
{
"paymentId": "${payment.id}",
"amount": ${payment.amount},
"currency": "${payment.currency}",
"status": "${payment.status}"
}
""".trimIndent()

// GOOD: trimMargin for custom indentation
val sql = """
|SELECT *
|FROM payments
|WHERE status = 'COMPLETED'
| AND amount > 100
""".trimMargin()

// BAD: String concatenation (error-prone, less readable)
val message = "Payment " + payment.id + " for " + payment.amount + " " + payment.currency

// BAD: Not trimming multi-line strings
val json = """
{
"paymentId": "${payment.id}"
}
""" // Contains leading spaces

String templates are evaluated at runtime, so expensive expressions in templates are recalculated each time the string is used. For frequently used strings with expensive computations, consider caching the result.

Named Arguments

Named arguments explicitly specify which parameter you're providing. This improves readability when functions have many parameters, prevents errors from parameter reordering, and makes code self-documenting. Named arguments can appear in any order after positional arguments.

When to use named arguments:

  • Functions with multiple parameters of the same type
  • Boolean parameters (makes intent clear)
  • Parameters with default values (specify only what you need to override)
  • When parameter order isn't obvious
// GOOD: Named arguments for clarity
val payment = Payment(
id = "123",
customerId = "customer1",
recipientName = "John Doe",
amount = BigDecimal("100.00"),
currency = "USD",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis()
)

// GOOD: Named arguments for boolean parameters
file.copy(
to = destinationFile,
overwrite = true, // Clear intent
preserveFileDate = false
)

// GOOD: Mix positional and named (positional first)
processPayment(payment, retryOnFailure = true, maxRetries = 3)

// BAD: Boolean without name (unclear intent)
file.copy(destinationFile, true, false) // What do these booleans mean?

// BAD: Many parameters without names (hard to read)
val payment = Payment(
"123",
"customer1",
"John Doe",
BigDecimal("100.00"),
"USD",
PaymentStatus.PENDING,
System.currentTimeMillis()
) // Parameter order unclear

Named arguments are particularly valuable when calling Java methods or when updating code - if parameter order changes, named arguments prevent subtle bugs.

Default Parameters

Default parameters allow specifying default values for function parameters. Callers can omit arguments that have defaults, providing only values that differ from defaults. This eliminates the need for multiple overloaded functions.

Benefits: Reduces API surface (fewer overloads), makes common cases concise (use defaults), enables builder-like syntax with named arguments, and simplifies API evolution (add parameters without breaking callers).

// GOOD: Default parameters instead of overloads
fun getPayments(
customerId: String? = null,
status: PaymentStatus? = null,
limit: Int = 20,
offset: Int = 0,
sortBy: String = "createdAt",
ascending: Boolean = false
): List<Payment> {
// Implementation
}

// Usage - provide only what you need
getPayments() // All defaults
getPayments(customerId = "123") // Override only customerId
getPayments(customerId = "123", status = PaymentStatus.COMPLETED)
getPayments(limit = 50, ascending = true) // Skip early parameters

// BAD: Multiple overloads (verbose, harder to maintain)
fun getPayments(): List<Payment> = getPayments(null, null, 20, 0)
fun getPayments(customerId: String): List<Payment> = getPayments(customerId, null, 20, 0)
fun getPayments(customerId: String, status: PaymentStatus): List<Payment> =
getPayments(customerId, status, 20, 0)
fun getPayments(customerId: String?, status: PaymentStatus?, limit: Int, offset: Int): List<Payment> {
// Implementation
}

// GOOD: Default parameters for configuration
class PaymentProcessor(
private val timeout: Duration = 30.seconds,
private val retryPolicy: RetryPolicy = RetryPolicy.DEFAULT,
private val maxConcurrent: Int = 10
) {
// Easy to customize or use defaults
}

// Usage
val processor1 = PaymentProcessor() // All defaults
val processor2 = PaymentProcessor(timeout = 60.seconds)
val processor3 = PaymentProcessor(
timeout = 45.seconds,
retryPolicy = RetryPolicy.AGGRESSIVE
)

Default parameters work well with named arguments - you can override any parameter regardless of position. This is more flexible than method overloading and reduces boilerplate significantly.


Further Reading

Internal Documentation

External Resources


Summary

Key Takeaways

  1. Null safety - Leverage Kotlin's type system to eliminate NPEs
  2. Coroutines - Use structured concurrency with proper dispatchers
  3. Flow - Reactive streams with operators for data transformations
  4. Scope functions - let, apply, run, also, with for concise code
  5. Extension functions - Add functionality without inheritance
  6. Sealed classes - Type-safe state representation with exhaustive when
  7. Data classes - Automatic implementations for DTOs
  8. Delegation - Lazy initialization and interface delegation
  9. Immutability - Prefer val over var, immutable collections
  10. Kotlin idioms - String templates, named arguments, default parameters

Next Steps: Review Kotlin Testing for comprehensive testing strategies with Kotlin and Android Overview for Android-specific patterns with Kotlin.