Introduction to Design Patterns in Kotlin
Design patterns are proven solutions to typical development problems. In Kotlin, many classic patterns are implemented more concisely and safely thanks to built-in features: null-safety, extension functions, coroutines, and a powerful type system.
In this article, we will cover three groups of patterns: creational, structural, and behavioral — with real examples in Kotlin. You will learn how to use them in everyday development to create flexible and maintainable architecture.
Creational Patterns
1. Singleton
In Kotlin, a singleton is implemented using the object keyword. This is a thread-safe lazy singleton without boilerplate code.
object DatabaseConnection { private val url = "jdbc:postgresql://localhost:5432/mydb" private val user = "admin" private val password = "secret"
fun connect(): String { return "Connected to $url as $user" }}
fun main() { println(DatabaseConnection.connect()) // Check that it is the same object val ref1 = DatabaseConnection val ref2 = DatabaseConnection println(ref1 === ref2) // true}Advantage: no need to write synchronized or volatile — Kotlin guarantees a single instance.
2. Factory Method
Allows delegating object creation to subclasses. In Kotlin, it is convenient to use sealed class and companion object.
sealed class Notification { data class Email(val to: String, val subject: String) : Notification() data class SMS(val phone: String, val message: String) : Notification() data class Push(val deviceId: String, val title: String) : Notification()
companion object Factory { fun createEmail(to: String, subject: String): Notification = Email(to, subject) fun createSMS(phone: String, message: String): Notification = SMS(phone, message) fun createPush(deviceId: String, title: String): Notification = Push(deviceId, title) }}
fun send(notification: Notification) { when (notification) { is Notification.Email -> println("Email to ${notification.to}") is Notification.SMS -> println("SMS to ${notification.phone}") is Notification.Push -> println("Push to ${notification.deviceId}") }}
fun main() { val email = Notification.createEmail("user@example.com", "Hello") send(email)}3. Builder
In Kotlin, the Builder can be replaced with named arguments and default values. However, for complex objects with validation, the classic Builder remains relevant.
class Pizza private constructor( val size: String, val cheese: Boolean, val pepperoni: Boolean, val mushrooms: Boolean) { class Builder { private var size: String = "Medium" private var cheese: Boolean = false private var pepperoni: Boolean = false private var mushrooms: Boolean = false
fun size(size: String) = apply { this.size = size } fun cheese() = apply { this.cheese = true } fun pepperoni() = apply { this.pepperoni = true } fun mushrooms() = apply { this.mushrooms = true }
fun build(): Pizza { require(size in listOf("Small", "Medium", "Large")) { "Invalid size" } return Pizza(size, cheese, pepperoni, mushrooms) } }}
fun main() { val pizza = Pizza.Builder() .size("Large") .cheese() .pepperoni() .build() println("Pizza: ${pizza.size}, cheese=${pizza.cheese}, pepperoni=${pizza.pepperoni}")}Structural Patterns
4. Adapter
The Adapter allows objects with incompatible interfaces to collaborate. In Kotlin, extension functions can serve as a lightweight adapter.