Kotlin Design Patterns
Introduction
Design patterns are tried and tested solutions to common problems that arise during software development. They represent best practices evolved over time by experienced developers. In Kotlin, design patterns can be implemented more elegantly and concisely compared to some other languages, thanks to Kotlin's modern features.
Understanding design patterns will help you write more maintainable, flexible, and scalable code. This guide introduces the most common design patterns categorized into creational, structural, and behavioral patterns, with Kotlin-specific implementations.
What Are Design Patterns?
Design patterns are reusable solutions to common problems in software design. They are templates designed to help you write code that's easy to understand and reuse. Design patterns aren't completed designs that can be directly converted to code—they are guidelines for solving certain types of problems.
Types of Design Patterns
Design patterns are typically categorized into three main groups:
- Creational Patterns: Focus on object creation mechanisms
- Structural Patterns: Deal with object composition and relationships between objects
- Behavioral Patterns: Concern communication between objects
Let's explore some commonly used patterns in each category.
Creational Design Patterns
Singleton Pattern
The Singleton pattern ensures that a class has only one instance and provides a global point of access to it.
Kotlin Implementation:
object DatabaseConnection {
init {
println("Database connection initialized")
}
fun connect() = "Connected to database"
}
// Usage
fun main() {
println(DatabaseConnection.connect()) // Initialization happens here
println(DatabaseConnection.connect()) // Same instance is reused
}
Output:
Database connection initialized
Connected to database
Connected to database
Notice how the initialization message only prints once, demonstrating that only a single instance exists.
Factory Method Pattern
The Factory Method pattern defines an interface for creating an object but lets subclasses decide which class to instantiate.
Kotlin Implementation:
interface Animal {
fun speak(): String
}
class Dog : Animal {
override fun speak() = "Woof!"
}
class Cat : Animal {
override fun speak() = "Meow!"
}
enum class AnimalType { DOG, CAT }
class AnimalFactory {
fun createAnimal(type: AnimalType): Animal {
return when (type) {
AnimalType.DOG -> Dog()
AnimalType.CAT -> Cat()
}
}
}
// Usage
fun main() {
val factory = AnimalFactory()
val dog = factory.createAnimal(AnimalType.DOG)
println("Dog says: ${dog.speak()}")
val cat = factory.createAnimal(AnimalType.CAT)
println("Cat says: ${cat.speak()}")
}
Output:
Dog says: Woof!
Cat says: Meow!
Builder Pattern
The Builder pattern separates the construction of a complex object from its representation, allowing the same construction process to create various representations.
Kotlin Implementation:
Kotlin has a better way to implement builder patterns using named arguments and default parameter values:
data class Pizza(
val size: String = "medium",
val cheese: Boolean = true,
val pepperoni: Boolean = false,
val bacon: Boolean = false,
val pineapple: Boolean = false
)
// Usage
fun main() {
// Traditional way in other languages would require a builder
// In Kotlin, we can simply use named parameters
val myPizza = Pizza(
size = "large",
pepperoni = true,
pineapple = true
)
println("My pizza: $myPizza")
}
Output:
My pizza: Pizza(size=large, cheese=true, pepperoni=true, bacon=false, pineapple=true)
For more complex scenarios, you can still implement the classic builder pattern:
class EmailBuilder {
private var to: String = ""
private var from: String = ""
private var subject: String = ""
private var body: String = ""
private var cc: MutableList<String> = mutableListOf()
fun to(to: String) = apply { this.to = to }
fun from(from: String) = apply { this.from = from }
fun subject(subject: String) = apply { this.subject = subject }
fun body(body: String) = apply { this.body = body }
fun cc(cc: String) = apply { this.cc.add(cc) }
fun build(): Email = Email(to, from, subject, body, cc)
}
data class Email(
val to: String,
val from: String,
val subject: String,
val body: String,
val cc: List<String>
)
// Usage
fun main() {
val email = EmailBuilder()
.from("[email protected]")
.to("[email protected]")
.subject("Meeting Tomorrow")
.body("Hi, can we meet tomorrow?")
.cc("[email protected]")
.build()
println("Email: $email")
}
Structural Design Patterns
Adapter Pattern
The Adapter pattern allows incompatible interfaces to work together by creating a middle-layer adapter class.
Kotlin Implementation:
// Old system interface
interface OldPrinter {
fun printDocument(text: String)
}
// Implementation of old system
class OldLaserPrinter : OldPrinter {
override fun printDocument(text: String) {
println("Old Printer: $text")
}
}
// New system interface
interface ModernPrinter {
fun printContent(content: String, format: String = "Text")
}
// Adapter to make old printer compatible with new system
class PrinterAdapter(private val oldPrinter: OldPrinter) : ModernPrinter {
override fun printContent(content: String, format: String) {
println("Converting modern format '$format' to old format...")
oldPrinter.printDocument(content)
}
}
// Usage
fun main() {
val oldPrinter = OldLaserPrinter()
val modernPrinterAdapter = PrinterAdapter(oldPrinter)
// Using new interface while using old printer internally
modernPrinterAdapter.printContent("Hello World!", "PDF")
}
Output:
Converting modern format 'PDF' to old format...
Old Printer: Hello World!
Decorator Pattern
The Decorator pattern attaches additional responsibilities to an object dynamically, providing a flexible alternative to subclassing for extending functionality.
Kotlin Implementation:
interface Coffee {
fun getCost(): Double
fun getDescription(): String
}
class SimpleCoffee : Coffee {
override fun getCost() = 5.0
override fun getDescription() = "Simple Coffee"
}
abstract class CoffeeDecorator(private val decoratedCoffee: Coffee) : Coffee {
override fun getCost() = decoratedCoffee.getCost()
override fun getDescription() = decoratedCoffee.getDescription()
}
class MilkDecorator(coffee: Coffee) : CoffeeDecorator(coffee) {
override fun getCost() = super.getCost() + 1.5
override fun getDescription() = "${super.getDescription()}, with milk"
}
class SugarDecorator(coffee: Coffee) : CoffeeDecorator(coffee) {
override fun getCost() = super.getCost() + 0.5
override fun getDescription() = "${super.getDescription()}, with sugar"
}
// Usage
fun main() {
var coffee: Coffee = SimpleCoffee()
println("${coffee.getDescription()}: $${coffee.getCost()}")
coffee = MilkDecorator(coffee)
println("${coffee.getDescription()}: $${coffee.getCost()}")
coffee = SugarDecorator(coffee)
println("${coffee.getDescription()}: $${coffee.getCost()}")
}
Output:
Simple Coffee: $5.0
Simple Coffee, with milk: $6.5
Simple Coffee, with milk, with sugar: $7.0
Behavioral Design Patterns
Observer Pattern
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
Kotlin Implementation:
interface Observer {
fun update(temperature: Float, humidity: Float, pressure: Float)
}
class WeatherDisplay(private val displayName: String) : Observer {
override fun update(temperature: Float, humidity: Float, pressure: Float) {
println("$displayName - Temperature: $temperature°C, Humidity: $humidity%, Pressure: $pressure hPa")
}
}
class WeatherStation {
private val observers = mutableListOf<Observer>()
private var temperature: Float = 0f
private var humidity: Float = 0f
private var pressure: Float = 0f
fun registerObserver(observer: Observer) {
observers.add(observer)
}
fun removeObserver(observer: Observer) {
observers.remove(observer)
}
fun setMeasurements(temperature: Float, humidity: Float, pressure: Float) {
this.temperature = temperature
this.humidity = humidity
this.pressure = pressure
notifyObservers()
}
private fun notifyObservers() {
for (observer in observers) {
observer.update(temperature, humidity, pressure)
}
}
}
// Usage
fun main() {
val weatherStation = WeatherStation()
val phoneDisplay = WeatherDisplay("Phone App")
val windowsWidget = WeatherDisplay("Desktop Widget")
weatherStation.registerObserver(phoneDisplay)
weatherStation.registerObserver(windowsWidget)
println("Weather update 1:")
weatherStation.setMeasurements(25.2f, 65f, 1013f)
println("\nWeather update 2:")
weatherStation.setMeasurements(26.5f, 70f, 1010f)
}
Output:
Weather update 1:
Phone App - Temperature: 25.2°C, Humidity: 65.0%, Pressure: 1013.0 hPa
Desktop Widget - Temperature: 25.2°C, Humidity: 65.0%, Pressure: 1013.0 hPa
Weather update 2:
Phone App - Temperature: 26.5°C, Humidity: 70.0%, Pressure: 1010.0 hPa
Desktop Widget - Temperature: 26.5°C, Humidity: 70.0%, Pressure: 1010.0 hPa
Strategy Pattern
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from clients that use it.
Kotlin Implementation:
interface PaymentStrategy {
fun pay(amount: Int): String
}
class CreditCardStrategy(
private val name: String,
private val cardNumber: String,
private val cvv: String,
private val expiryDate: String
) : PaymentStrategy {
override fun pay(amount: Int): String {
return "$amount paid using Credit Card (${cardNumber.takeLast(4)})"
}
}
class PayPalStrategy(private val email: String) : PaymentStrategy {
override fun pay(amount: Int): String {
return "$amount paid using PayPal account: $email"
}
}
class ShoppingCart {
private var paymentStrategy: PaymentStrategy? = null
fun setPaymentStrategy(strategy: PaymentStrategy) {
this.paymentStrategy = strategy
}
fun checkout(amount: Int): String {
return paymentStrategy?.pay(amount) ?: "No payment method set"
}
}
// Usage
fun main() {
val cart = ShoppingCart()
// First payment with credit card
cart.setPaymentStrategy(
CreditCardStrategy(
"John Doe",
"1234567890123456",
"123",
"12/25"
)
)
println(cart.checkout(100))
// Second payment with PayPal
cart.setPaymentStrategy(
PayPalStrategy("[email protected]")
)
println(cart.checkout(200))
}
Output:
100 paid using Credit Card (3456)
200 paid using PayPal account: [email protected]
Real-World Application: Building a Task Management System
Let's combine several design patterns to build a simple task management system:
// Task State - Strategy Pattern
interface TaskState {
fun displayState(): String
}
class TodoState : TaskState {
override fun displayState() = "TODO"
}
class InProgressState : TaskState {
override fun displayState() = "IN PROGRESS"
}
class DoneState : TaskState {
override fun displayState() = "DONE"
}
// Observer Pattern
interface TaskObserver {
fun update(task: Task)
}
class TaskLogger : TaskObserver {
override fun update(task: Task) {
println("NOTIFICATION: Task '${task.title}' is now ${task.state.displayState()}")
}
}
class EmailNotifier(private val email: String) : TaskObserver {
override fun update(task: Task) {
println("EMAIL to $email: Task '${task.title}' is now ${task.state.displayState()}")
}
}
// Builder Pattern combined with Observable Subject
class Task private constructor(
val id: Long,
val title: String,
val description: String,
var state: TaskState,
val assignee: String?
) {
private val observers = mutableListOf<TaskObserver>()
fun registerObserver(observer: TaskObserver) {
observers.add(observer)
}
fun removeObserver(observer: TaskObserver) {
observers.remove(observer)
}
fun changeState(newState: TaskState) {
state = newState
notifyObservers()
}
private fun notifyObservers() {
for (observer in observers) {
observer.update(this)
}
}
// Builder class
class Builder {
private var id: Long = 0
private var title: String = ""
private var description: String = ""
private var state: TaskState = TodoState()
private var assignee: String? = null
fun id(id: Long) = apply { this.id = id }
fun title(title: String) = apply { this.title = title }
fun description(description: String) = apply { this.description = description }
fun state(state: TaskState) = apply { this.state = state }
fun assignee(assignee: String?) = apply { this.assignee = assignee }
fun build() = Task(id, title, description, state, assignee)
}
override fun toString(): String {
return "Task #$id: $title [${state.displayState()}]" +
(assignee?.let { " assigned to $it" } ?: "")
}
}
// Factory Method Pattern
object TaskFactory {
private var lastId: Long = 0
fun createSimpleTask(title: String): Task {
return Task.Builder()
.id(++lastId)
.title(title)
.description("Simple task")
.build()
}
fun createAssignedTask(title: String, assignee: String): Task {
return Task.Builder()
.id(++lastId)
.title(title)
.description("Assigned task")
.assignee(assignee)
.build()
}
}
// Usage: Task Management System
fun main() {
// Create tasks with Factory
val task1 = TaskFactory.createSimpleTask("Fix login page")
val task2 = TaskFactory.createAssignedTask("Design new logo", "Alice")
// Add observers
val logger = TaskLogger()
val emailNotifier = EmailNotifier("[email protected]")
task1.registerObserver(logger)
task2.registerObserver(logger)
task2.registerObserver(emailNotifier)
println("Initial tasks:")
println(task1)
println(task2)
println("\nUpdating tasks:")
task1.changeState(InProgressState())
task2.changeState(DoneState())
}
Output:
Initial tasks:
Task #1: Fix login page [TODO]
Task #2: Design new logo [TODO] assigned to Alice
Updating tasks:
NOTIFICATION: Task 'Fix login page' is now IN PROGRESS
NOTIFICATION: Task 'Design new logo' is now DONE
EMAIL to [email protected]: Task 'Design new logo' is now DONE
When to Use Design Patterns
Design patterns should be used when:
- You face a common problem with a well-established solution
- You want to improve code readability and maintainability
- You need to communicate solutions efficiently with your team
- You want to apply proven practices rather than inventing new solutions
However, be cautious of:
- Overusing patterns: Sometimes a simple solution is better than forcing a pattern
- Pattern-itis: Don't use patterns just to showcase your knowledge
- Premature optimization: Start with simple code and refactor to patterns as needed
Summary
Design patterns are powerful tools in a developer's toolkit that help solve common problems with elegant, tested solutions. In Kotlin, many patterns can be implemented more concisely than in traditional languages, thanks to its modern features.
In this guide, we've explored:
- Creational patterns like Singleton, Factory Method, and Builder
- Structural patterns like Adapter and Decorator
- Behavioral patterns like Observer and Strategy
We also saw how these patterns can be combined to create a more complex real-world application like a task management system.
By understanding and correctly applying design patterns, you can write more maintainable, flexible, and scalable Kotlin code.
Additional Resources
- Design Patterns: Elements of Reusable Object-Oriented Software - The original "Gang of Four" book
- Refactoring.Guru - Great visual explanations of design patterns
- Kotlin Design Patterns and Best Practices - Book focused on Kotlin implementations
Exercises
- Implement a Logger using the Singleton pattern that ensures only a single logging instance exists.
- Create a Document class hierarchy using the Composite pattern to represent folders containing files and subfolders.
- Implement a Command pattern for a remote control that can control various home devices.
- Build a simple application using the MVC (Model-View-Controller) pattern.
- Refactor the task management system above to include a Memento pattern for undoing state changes.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)