Skip to content
ioob.dev
Go back

Kotlin Part 12 — Practical Patterns

· 4 min read
Kotlin Series (12/12)
  1. Kotlin Beginner Part 1 — Variables and Types
  2. Kotlin Beginner Part 2 — Conditionals and Loops
  3. Kotlin Beginner Part 3 — Functions
  4. Kotlin Beginner Part 4 — Classes and Objects
  5. Kotlin Beginner Part 5 — Collections and Lambdas
  6. Kotlin Part 6 — Null Safety Advanced
  7. Kotlin Part 7 — Generics
  8. Kotlin Part 8 — sealed class and enum
  9. Kotlin Part 9 — Coroutines Basics
  10. Kotlin Part 10 — Coroutines Advanced
  11. Kotlin Part 11 — DSL and Advanced Functions
  12. Kotlin Part 12 — Practical Patterns
Table of contents

Table of contents

The Final Installment

It’s been a long journey since learning val and var in Part 1. We covered variables, control flow, functions, classes, collections, null safety, generics, sealed classes, coroutines, and DSLs. In this final part, we’ll look at four patterns frequently encountered in real-world projects: delegation, annotations, reflection, and Java interoperability.

Delegation — The by Keyword

Class Delegation

When implementing an interface, instead of writing every method yourself, you can delegate to another object with a “you handle it” approach. Kotlin supports this at the language level with the by keyword.

interface Logger {
    fun log(message: String)
    fun error(message: String)
}

class ConsoleLogger : Logger {
    override fun log(message: String) = println("[LOG] $message")
    override fun error(message: String) = println("[ERROR] $message")
}

// Delegate Logger implementation to ConsoleLogger
class UserService(logger: Logger) : Logger by logger {
    fun createUser(name: String) {
        log("User creation started: $name")
        // Business logic...
        log("User creation completed: $name")
    }

    // Override only specific methods if needed
    override fun error(message: String) {
        println("[CRITICAL] $message")  // Custom behavior
    }
}

fun main() {
    val service = UserService(ConsoleLogger())
    service.createUser("Kim")
    // [LOG] User creation started: Kim
    // [LOG] User creation completed: Kim

    service.error("DB connection failed")
    // [CRITICAL] DB connection failed
}

Logger by logger declares “let the logger object handle all methods of the Logger interface.” The log() calls are automatically forwarded to ConsoleLogger. You can also override specific methods to change their behavior.

Drawing how calls are forwarded to the delegate target as a sequence makes it clear.

sequenceDiagram
    participant Caller as Caller
    participant Service as UserService
    participant Logger as ConsoleLogger

    Caller->>Service: createUser("Kim")
    Service->>Service: log("...") call
    Service->>Logger: log("...") delegated (by)
    Logger-->>Service: println output
    Caller->>Service: error("...")
    Service->>Service: Execute overridden implementation
    Service-->>Caller: [CRITICAL] output

Using delegation instead of inheritance loosens coupling between classes. It’s Kotlin providing syntactic support for the principle “favor composition over inheritance.”

Property Delegation

You can also delegate a property’s getter/setter logic to a separate object. In fact, lazy from Part 6 is a prime example of property delegation.

import kotlin.properties.Delegates

class UserSettings {
    // observable — callback on every value change
    var theme: String by Delegates.observable("light") { _, old, new ->
        println("Theme changed: $old -> $new")
    }

    // vetoable — change only if the condition is met
    var fontSize: Int by Delegates.vetoable(14) { _, _, new ->
        new in 8..72  // Only allow range 8-72
    }
}

fun main() {
    val settings = UserSettings()

    settings.theme = "dark"     // Theme changed: light -> dark
    settings.theme = "solarized" // Theme changed: dark -> solarized

    settings.fontSize = 20      // Changed
    println(settings.fontSize)  // 20
    settings.fontSize = 100     // Rejected (out of range)
    println(settings.fontSize)  // 20 (previous value retained)
}

observable executes a callback every time the value changes. It’s useful for logging or triggering UI updates. vetoable can reject changes based on a condition, making it handy for validation.

You can also create custom delegation objects.

import kotlin.reflect.KProperty

class TrimmedString {
    private var value: String = ""

    operator fun getValue(thisRef: Any?, property: KProperty<*>): String = value

    operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
        value = newValue.trim()
    }
}

class UserForm {
    var name: String by TrimmedString()
    var email: String by TrimmedString()
}

fun main() {
    val form = UserForm()
    form.name = "  Kim  "
    form.email = "  kim@example.com  "

    println("'${form.name}'")   // 'Kim'
    println("'${form.email}'")  // 'kim@example.com'
}

By defining getValue and setValue operators, any object can serve as a property delegate.

Annotations

Kotlin annotations are nearly identical to Java’s. Define them with annotation class and use them with @.

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Logged(val level: String = "INFO")

@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class MaxLength(val value: Int)

class UserController {
    @MaxLength(50)
    var username: String = ""

    @Logged("DEBUG")
    fun getUser(id: Long): String {
        return "User-$id"
    }

    @Logged
    fun deleteUser(id: Long) {
        println("Deleting user: $id")
    }
}

@Target specifies where the annotation can be applied, and @Retention specifies how long the annotation information is retained. Setting it to RUNTIME makes it readable via reflection at execution time.

One thing to watch out for in Kotlin: when attaching annotations to properties, it can be ambiguous whether they apply to the Java field, getter, or setter.

class Example {
    @field:MaxLength(100)    // Apply to Java field
    var field1: String = ""

    @get:Logged("INFO")      // Apply to getter
    var field2: String = ""

    @set:Logged("WARN")      // Apply to setter
    var field3: String = ""
}

You can specify the target explicitly with @field:, @get:, @set:. This distinction becomes important when working with Spring or JPA.

Reflection

Reflection is the ability to analyze a class’s structure at runtime. Combined with annotations, it enables framework-level automation.

Using Kotlin reflection requires the kotlin-reflect dependency.

import kotlin.reflect.full.*

data class User(
    val id: Long,
    val name: String,
    val email: String,
    val age: Int
)

fun main() {
    val kClass = User::class

    // Class information
    println("Class: ${kClass.simpleName}")
    println("data class? ${kClass.isData}")

    // Property list
    kClass.memberProperties.forEach { prop ->
        println("  ${prop.name}: ${prop.returnType}")
    }
}
Class: User
data class? true
  age: kotlin.Int
  email: kotlin.String
  id: kotlin.Long
  name: kotlin.String

Here’s a practical example: a simple validator combining annotations and reflection.

import kotlin.reflect.full.*

@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class NotBlank

@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class Min(val value: Int)

data class SignupRequest(
    @NotBlank val username: String,
    @NotBlank val email: String,
    @Min(18) val age: Int
)

fun validate(obj: Any): List<String> {
    val errors = mutableListOf<String>()
    val kClass = obj::class

    for (prop in kClass.memberProperties) {
        val value = prop.getter.call(obj)

        for (ann in prop.annotations) {
            when (ann) {
                is NotBlank -> {
                    if (value is String && value.isBlank()) {
                        errors.add("${prop.name}: must not be blank")
                    }
                }
                is Min -> {
                    if (value is Int && value < ann.value) {
                        errors.add("${prop.name}: minimum value ${ann.value}")
                    }
                }
            }
        }
    }
    return errors
}

fun main() {
    val valid = SignupRequest("kim", "kim@mail.com", 25)
    println(validate(valid))    // []

    val invalid = SignupRequest("", "kim@mail.com", 15)
    println(validate(invalid))  // [username: must not be blank, age: minimum value 18]
}

Spring Validation and Jackson work in a similar way internally. You won’t often use reflection directly in practice, but understanding how frameworks operate under the hood is valuable.

Note that reflection lacks compile-time safety and has performance overhead, so use it only when truly necessary.

Java Interoperability

One of Kotlin’s greatest strengths is 100% compatibility with Java. You can use existing Java code as-is, and Java can call Kotlin code too.

Using Java Code from Kotlin

import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap

fun main() {
    // Use Java classes directly
    val now = LocalDateTime.now()
    val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
    println(now.format(formatter))

    // Java collections work naturally too
    val map = ConcurrentHashMap<String, Int>()
    map["kotlin"] = 1
    map["java"] = 2
    println(map)  // {kotlin=1, java=2}
}

In most cases, Java libraries work without any special configuration. The Kotlin compiler automatically maps Java types: java.lang.String becomes kotlin.String, int becomes kotlin.Int.

Platform Types — The Gray Area of Null Safety

Java has no null safety information. There are cases where the Kotlin compiler can’t determine whether a value from Java is null or not — this is called a platform type.

// Java code (hypothetical)
// public class JavaUser {
//     public String getName() { return null; }  // Can return null
// }

fun processJavaUser() {
    // val name: String = javaUser.getName()   // NPE risk!
    // val name: String? = javaUser.getName()  // Safe

    // When using Java code, the rule is to receive as Nullable
}

When using Java libraries, unless @Nullable or @NotNull annotations are present, it’s safest to receive return values as nullable like String?. Building this habit virtually eliminates NPEs in Java interop.

Using Kotlin Code from Java

// Kotlin file: StringUtils.kt
@file:JvmName("StringUtils")  // Specify class name visible from Java

@JvmStatic
fun String.isEmail(): Boolean {
    return this.contains("@") && this.contains(".")
}

object AppConfig {
    @JvmStatic
    val version = "1.0.0"

    @JvmStatic
    fun getMaxRetries(): Int = 3
}

data class ApiResponse(
    @JvmField val code: Int,
    @JvmField val message: String
)

This can be called from Java like this:

// Calling from Java
StringUtils.isEmail("test@mail.com");   // Thanks to @JvmName
AppConfig.getVersion();                  // Thanks to @JvmStatic
AppConfig.getMaxRetries();

ApiResponse resp = new ApiResponse(200, "OK");
int code = resp.code;  // Direct field access thanks to @JvmField

Here’s a summary of the key annotations.

AnnotationPurpose
@JvmStaticExpose companion object / object functions as Java static
@JvmFieldExpose property as a Java field directly (no getter/setter)
@JvmNameChange the name visible from Java
@JvmOverloadsGenerate Java-style overloads for functions with default parameters
@ThrowsPass checked exception info to Java

@JvmOverloads

Kotlin’s default parameters aren’t recognized by Java. @JvmOverloads automatically generates Java-style overloaded methods.

class HttpClient {
    @JvmOverloads
    fun get(
        url: String,
        timeout: Int = 5000,
        headers: Map<String, String> = emptyMap()
    ): String {
        println("GET $url (timeout=${timeout}ms, headers=$headers)")
        return "response"
    }
}

fun main() {
    val client = HttpClient()
    client.get("https://api.example.com")
    client.get("https://api.example.com", 10000)
    client.get("https://api.example.com", 3000, mapOf("Auth" to "Bearer token"))
}
GET https://api.example.com (timeout=5000ms, headers={})
GET https://api.example.com (timeout=10000ms, headers={})
GET https://api.example.com (timeout=3000ms, headers={Auth=Bearer token})

With @JvmOverloads, Java can call all three versions: get(url), get(url, timeout), and get(url, timeout, headers).


Over 12 parts, we’ve covered Kotlin from basics to advanced topics. Starting with variables and types, we passed through coroutines and DSLs and arrived at Java interoperability. This provides a sufficient foundation to start a real-world Kotlin project.

Of course, there are topics this series didn’t cover. Multiplatform, Compose, Ktor, testing strategies — each of these is deep enough to warrant its own separate series. But from the foundation these 12 parts provide, you can extend in any direction.


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Kotlin Part 11 — DSL and Advanced Functions
Next Post
ArgoCD Part 1 — What Is ArgoCD