Chapter 2: Singleton Pattern
The Problem
Sometimes your application needs exactly one instance of a class—no more, no less. Creating multiple instances would cause problems: duplicate database connections consuming resources, inconsistent analytics data from multiple trackers, or conflicting states in a shared configuration manager. You need a way to ensure a class has only one instance while providing global access to it.
General Example
Consider a logging system for an application. Every part of your codebase needs to write logs, but you want all logs to go through a single point. Multiple logger instances could mean logs written to different files, inconsistent formatting, or race conditions when writing.
// Without Singleton - Problem
class Logger {
private val logFile = File("app.log")
fun log(message: String) {
logFile.appendText("${System.currentTimeMillis()}: $message\n")
}
}
// Every class creates its own instance
class ServiceA {
private val logger = Logger() // Instance 1
}
class ServiceB {
private val logger = Logger() // Instance 2 - Different instance!
}
With the Singleton pattern, we ensure only one Logger exists:
// With Singleton - Solution
object Logger {
private val logFile = File("app.log")
fun log(message: String) {
logFile.appendText("${System.currentTimeMillis()}: $message\n")
}
}
// Both use the same instance
class ServiceA {
fun doWork() {
Logger.log("ServiceA working")
}
}
class ServiceB {
fun doWork() {
Logger.log("ServiceB working")
}
}
In Kotlin, the object keyword creates a Singleton automatically. The compiler handles thread-safe lazy initialization behind the scenes.
Android/Compose Mapping
Room Database Instance
Room databases are expensive to create. The documentation explicitly recommends using a single instance throughout your app:
object DatabaseProvider {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
).build()
INSTANCE = instance
instance
}
}
}
// Usage anywhere in the app
val database = DatabaseProvider.getDatabase(context)
A cleaner approach using Kotlin's lazy:
object DatabaseProvider {
private lateinit var applicationContext: Context
fun initialize(context: Context) {
applicationContext = context.applicationContext
}
val database: AppDatabase by lazy {
Room.databaseBuilder(
applicationContext,
AppDatabase::class.java,
"app_database"
).build()
}
}
// In Application class
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
DatabaseProvider.initialize(this)
}
}
Retrofit Client
Network clients should be reused to benefit from connection pooling:
object NetworkClient {
private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
val retrofit: Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val apiService: ApiService = retrofit.create(ApiService::class.java)
}
// Usage
suspend fun fetchUsers(): List<User> {
return NetworkClient.apiService.getUsers()
}
DataStore Preferences
DataStore should be a Singleton scoped to a single process:
object PreferencesManager {
private lateinit var dataStore: DataStore<Preferences>
private val DARK_MODE_KEY = booleanPreferencesKey("dark_mode")
private val USER_TOKEN_KEY = stringPreferencesKey("user_token")
fun initialize(context: Context) {
dataStore = context.dataStore
}
val darkModeFlow: Flow<Boolean> = dataStore.data
.map { preferences ->
preferences[DARK_MODE_KEY] ?: false
}
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[DARK_MODE_KEY] = enabled
}
}
suspend fun getUserToken(): String? {
return dataStore.data.first()[USER_TOKEN_KEY]
}
suspend fun setUserToken(token: String) {
dataStore.edit { preferences ->
preferences[USER_TOKEN_KEY] = token
}
}
}
private val Context.dataStore by preferencesDataStore(name = "settings")
Analytics Tracker
A unified analytics manager ensures consistent event tracking:
object AnalyticsTracker {
private var firebaseAnalytics: FirebaseAnalytics? = null
fun initialize(context: Context) {
firebaseAnalytics = FirebaseAnalytics.getInstance(context)
}
fun trackScreenView(screenName: String) {
firebaseAnalytics?.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
param(FirebaseAnalytics.Param.SCREEN_NAME, screenName)
}
}
fun trackButtonClick(buttonName: String) {
firebaseAnalytics?.logEvent("button_click") {
param("button_name", buttonName)
}
}
fun trackPurchase(amount: Double, currency: String) {
firebaseAnalytics?.logEvent(FirebaseAnalytics.Event.PURCHASE) {
param(FirebaseAnalytics.Param.VALUE, amount)
param(FirebaseAnalytics.Param.CURRENCY, currency)
}
}
}
Using Singleton with Compose
In Compose, you often access Singletons through the ViewModel layer:
class SettingsViewModel : ViewModel() {
val darkMode: StateFlow<Boolean> = PreferencesManager.darkModeFlow
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = false
)
fun toggleDarkMode() {
viewModelScope.launch {
val current = darkMode.value
PreferencesManager.setDarkMode(!current)
}
}
}
@Composable
fun SettingsScreen(viewModel: SettingsViewModel = viewModel()) {
val darkMode by viewModel.darkMode.collectAsState()
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Dark Mode")
Switch(
checked = darkMode,
onCheckedChange = { viewModel.toggleDarkMode() }
)
}
}
}
When to Use
✅ Use Singleton when:
- You need exactly one instance of a resource (database, network client)
- Multiple instances would cause conflicts or resource waste
- You need a global access point for a shared service
- The object has no state that varies between use cases
When to Avoid
❌ Avoid Singleton when:
- The object holds mutable state that could cause race conditions
- You need different configurations in different contexts (testing vs. production)
- The object has dependencies that make it hard to test
- You're using it just because it's convenient (it might indicate poor architecture)
Singleton vs. Dependency Injection
While Singletons provide global access, they create tight coupling and make testing difficult:
// Hard to test - direct Singleton access
class UserRepository {
suspend fun getUser(id: String): User {
return NetworkClient.apiService.getUser(id) // Tight coupling
}
}
// Better - inject the dependency
class UserRepository(
private val apiService: ApiService // Can inject mock for testing
) {
suspend fun getUser(id: String): User {
return apiService.getUser(id)
}
}
// With Hilt, you get singleton scope with testability
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideApiService(): ApiService {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.build()
.create(ApiService::class.java)
}
}
Dependency injection frameworks like Hilt give you singleton behavior (one instance) with the flexibility to swap implementations for testing.
Common Mistakes
Storing Context References
Never store Activity or Fragment context in a Singleton—it causes memory leaks:
// Bad - Memory leak!
object BadSingleton {
private lateinit var context: Context // Could be Activity
fun init(context: Context) {
this.context = context // Leaks Activity
}
}
// Good - Use application context
object GoodSingleton {
private lateinit var appContext: Context
fun init(context: Context) {
this.appContext = context.applicationContext // Safe
}
}
Mutable State in Singletons
Mutable state in Singletons requires thread synchronization:
// Dangerous - Race condition
object Counter {
var count = 0 // Not thread-safe
fun increment() {
count++ // Race condition!
}
}
// Safe - Thread-safe state
object Counter {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() {
_count.update { it + 1 } // Thread-safe
}
}
Interview Questions
Q: How would you ensure only one Retrofit instance exists in your app?
A: "I'd use Kotlin's object keyword to create a Singleton that holds the Retrofit instance. The object ensures thread-safe lazy initialization. However, in a production app, I'd prefer using Hilt with the @Singleton scope. This gives me the single-instance guarantee while keeping the code testable—I can inject a mock ApiService in tests without touching the Retrofit configuration."
Q: What are the downsides of the Singleton pattern?
A: "Singletons have three main issues. First, they create tight coupling—code directly references the Singleton, making it hard to swap implementations. Second, they make testing difficult since you can't easily inject mocks. Third, they can hide dependencies; a class might use five Singletons internally, but you wouldn't know from its constructor. In Android, dependency injection with Hilt is usually preferred because it provides singleton scope with better testability and explicit dependencies."
Q: Is Kotlin's object keyword thread-safe?
A: "Yes, Kotlin's object declaration is initialized lazily and thread-safely by the JVM. The initialization happens when the object is first accessed, and the JVM guarantees that only one thread performs the initialization. This is implemented using the same mechanism as Java's static initialization blocks, which are inherently thread-safe."
Q: When would you use a Singleton instead of dependency injection?
A: "I'd consider a pure Singleton for truly application-global infrastructure with no state—things like a logging utility or constant configurations. But for anything with dependencies or state, like a database or network client, I'd use DI with singleton scope. The DI approach lets me mark a dependency as singleton while still being able to inject alternatives in tests. The convenience of direct Singleton access rarely outweighs the testing and flexibility benefits of DI."
Key Takeaways
- Singleton ensures a class has exactly one instance with global access
- Kotlin's
objectkeyword provides built-in thread-safe Singleton implementation - Common Android Singletons: Room database, Retrofit client, DataStore, Analytics
- Always use
applicationContextto avoid memory leaks - Prefer dependency injection with singleton scope for better testability
- Be cautious with mutable state in Singletons—use thread-safe constructs like StateFlow
What's Next
In Chapter 3, we'll explore the Factory pattern—essential for creating different objects based on runtime conditions, commonly seen in ViewModel creation and navigation.