Understanding the Singleton Pattern in Kotlin
Understanding the Singleton Pattern in Kotlin
The Singleton pattern ensures that a class has exactly one instance throughout the application's lifetime and provides a global point of access to it. It is one of the most commonly reached-for patterns and one of the most commonly misused.
When a Singleton Is Justified#
The pattern makes sense in specific scenarios:
- Resource management: A single instance of a database connection pool or thread pool prevents resource exhaustion.
- Configuration: A central, shared configuration object that is initialized once and read many times.
- Coordination: Components that need to share state through a single point of control.
- Lazy initialization: Deferring expensive initialization until the instance is first needed.
Implementing Singleton in Kotlin#
Kotlin provides cleaner singleton support than most languages.
Basic Implementation: object Declaration#
The idiomatic Kotlin approach:
object Logger {
fun log(message: String) {
println("${System.currentTimeMillis()}: $message")
}
}
Kotlin's object declaration is thread-safe, lazily initialized, and concise. The instance is created on first access, and the JVM guarantees initialization safety. Usage:
fun main() {
Logger.log("Application started")
}
Use object by default when you need a singleton in Kotlin.
Parameterized Singleton#
When the singleton requires initialization parameters, object is not sufficient. Two options:
Option A: by lazy (preferred)
class ConfigManager private constructor(val serverUrl: String) {
companion object {
private val instance by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
ConfigManager("https://default.server.com")
}
fun getInstance(): ConfigManager = instance
}
fun getConfig(): String = "Configuration for $serverUrl"
}
Option B: Double-checked locking
class ConfigManager private constructor(val serverUrl: String) {
companion object {
@Volatile
private var instance: ConfigManager? = null
fun getInstance(serverUrl: String): ConfigManager {
return instance ?: synchronized(this) {
instance ?: ConfigManager(serverUrl).also { instance = it }
}
}
}
}
The by lazy approach is preferred—it is more concise and less prone to implementation errors.
Dependency Injection as an Alternative#
In most modern Kotlin applications, dependency injection frameworks are a better solution than manually implemented singletons. With Dagger Hilt or Koin:
@Singleton
class DatabaseService @Inject constructor(
private val config: Config
) {
fun query(sql: String): Result {
// Implementation
}
}
DI frameworks provide the same single-instance behavior while keeping dependencies explicit and substitutable. Tests can inject a different implementation without fighting global state.
Known Problems#
Global State#
A singleton is global state. Global state produces:
- Unpredictable behavior when multiple code paths modify the singleton
- Hidden dependencies between components
- Test ordering dependencies when state persists between tests
Minimize mutable state in singletons. If the singleton is essentially a state bag, the design probably needs rethinking.
Testing Difficulty#
Singletons make testing hard because:
- State persists between test cases
- Dependencies are hidden from the constructor
- Parallel test execution shares the single instance
Design singletons with testability in mind by extracting an interface:
interface LoggerService {
fun log(message: String)
}
object ProductionLogger : LoggerService {
override fun log(message: String) {
// Real implementation
}
}
class TestLogger : LoggerService {
val logs = mutableListOf<String>()
override fun log(message: String) {
logs.add(message)
}
}
Memory Leaks#
Singletons live for the entire application lifetime. If they hold references to short-lived objects—UI components, request-scoped resources—those objects cannot be garbage collected. Implement cleanup mechanisms when the singleton manages resources, and avoid storing anything that should not live as long as the application.
Best Practices#
- Prefer dependency injection over manual singleton implementation in application code.
- When a singleton is appropriate, use Kotlin's
objectdeclaration for the simple case. - Design singletons to be immutable where possible.
- Extract interfaces to keep singletons testable.
- Document thread-safety guarantees explicitly.
Conclusion#
The Singleton pattern solves a real problem—ensuring a single instance of a resource-heavy or globally shared object. Kotlin's object declaration makes the common case trivial. For anything more complex, dependency injection typically provides better maintainability and testability. Apply the Singleton consciously, acknowledge its costs, and design for the problems it reliably introduces.