Skip to main content

Creational Patterns

Creational patterns answer: who creates this object, and how? In Kotlin, many classic creational patterns collapse into idiomatic language features (object, data classes, named arguments) — but the named patterns still matter for communication and for cases where the idiom doesn't fit.

1. Singleton — exactly one instance

Intent: Ensure a class has one instance and give a global point of access.

Kotlin object — built-in singleton

object Logger {
private val timestamp = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)

fun d(tag: String, message: String) {
Log.d(tag, "${timestamp.format(Date())} $message")
}
}

// Usage
Logger.d("App", "started")

object is thread-safe, lazily initialized on first access, and has a single instance per classloader. The idiom replaces Java's double-checked- locking boilerplate.

@Singleton
class AuthSession @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
private val _state = MutableStateFlow<Session>(Session.SignedOut)
val state: StateFlow<Session> = _state.asStateFlow()
/* ... */
}

Prefer this over object for anything you might want to:

  • Swap in tests (inject a fake)
  • Observe lifecycle (Hilt tears down on test isolation)
  • Inject collaborators

When Singleton is the right choice

  • Stateless utilities (loggers, formatters, id generators)
  • Expensive-to-create shared resources (OkHttpClient, Retrofit, Room DB)
  • App-wide session / config / feature-flag state

When it isn't

  • Anything that holds per-user or per-screen state
  • Business logic that should be testable in isolation
  • "Because it's convenient" — convenience is a code smell

2. Factory Method — defer construction to a method

Intent: Define an interface for creating objects but let subclasses or implementations decide which concrete class to instantiate.

Classic form

interface PaymentProcessor {
suspend fun charge(amount: Cents): PaymentResult

// Factory method — subclasses produce the specific processor
abstract class Provider {
abstract fun create(config: PaymentConfig): PaymentProcessor
}
}

class StripeProcessor(private val config: PaymentConfig) : PaymentProcessor {
override suspend fun charge(amount: Cents) = /* ... */

class Provider : PaymentProcessor.Provider() {
override fun create(config: PaymentConfig) = StripeProcessor(config)
}
}

Kotlin-idiomatic form — companion operator fun invoke

interface PaymentProcessor {
suspend fun charge(amount: Cents): PaymentResult

companion object {
operator fun invoke(config: PaymentConfig): PaymentProcessor = when (config.provider) {
PaymentProvider.Stripe -> StripeProcessor(config)
PaymentProvider.Square -> SquareProcessor(config)
PaymentProvider.PayPal -> PayPalProcessor(config)
}
}
}

// Callers use the interface directly
val processor = PaymentProcessor(config) // looks like a constructor; it's a factory

Android examples

  • Fragment.newInstance(args) — factory method for Fragment + args
  • ViewModelProvider.Factory.create(T::class) — obsolete; replaced by Hilt
  • Intent.Builder (some APIs) — builder-style factory
  • OkHttpClient.newCall(request) — factory for Call

Intent: Provide an interface for creating families of related or dependent objects without specifying their concrete classes.

The shape

// Abstract factory — produces a family of related things
interface DatabaseFactory {
fun createConnection(): DbConnection
fun createMigrationRunner(): MigrationRunner
fun createBackupService(): BackupService
}

// Concrete factory — Postgres family
class PostgresFactory(private val config: DbConfig) : DatabaseFactory {
override fun createConnection() = PostgresConnection(config)
override fun createMigrationRunner() = FlywayMigrationRunner(createConnection())
override fun createBackupService() = PgDumpBackupService(config)
}

// Concrete factory — SQLite family (for testing / dev)
class SqliteFactory : DatabaseFactory {
override fun createConnection() = SqliteConnection(":memory:")
override fun createMigrationRunner() = SqlScriptRunner(createConnection())
override fun createBackupService() = SqliteFileBackup()
}

Real-world Android — TransportFactory

Firebase Datatransport library uses an abstract factory:

val factory: TransportFactory = TransportRuntime.getInstance()
.newFactory(CCTDestination.INSTANCE)

val analyticsTransport: Transport<AnalyticsEvent> =
factory.getTransport("ANALYTICS", AnalyticsEvent::class.java, AnalyticsEventEncoder())

val crashTransport: Transport<CrashEvent> =
factory.getTransport("CRASH", CrashEvent::class.java, CrashEventEncoder())

One factory produces a family of Transport instances wired to the same backend.

Hilt as abstract factory

Hilt components are abstract factories in disguise. The SingletonComponent produces a family of app-wide objects; the ViewModelComponent produces a family of per-ViewModel objects. Installing the same module in different components produces different "families."

@Module @InstallIn(SingletonComponent::class)
object ProdNetworkModule { /* OkHttp with cert pinning */ }

@Module @InstallIn(SingletonComponent::class)
@TestInstallIn(replaces = [ProdNetworkModule::class], components = [SingletonComponent::class])
object FakeNetworkModule { /* OkHttp with MockWebServer */ }

4. Builder — step-by-step construction

Intent: Separate the construction of a complex object from its representation so the same construction process can create different representations.

Java-style builder (still common in Android libraries)

val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notif)
.setContentTitle("Order shipped")
.setContentText("Arriving Friday")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setAutoCancel(true)
.addAction(R.drawable.ic_track, "Track", trackPendingIntent)
.build()

Every Jetpack library ships builders: Retrofit.Builder, OkHttpClient.Builder, Room.databaseBuilder, WorkManager.Configuration.Builder, etc. You'll write hundreds.

Kotlin-idiomatic — named arguments + defaults

For classes you own, Kotlin's named arguments often make builders unnecessary:

data class NotificationSpec(
val channel: String,
val title: String,
val body: String,
val priority: Int = NotificationCompat.PRIORITY_DEFAULT,
val category: String? = null,
val autoCancel: Boolean = true,
val actions: List<Action> = emptyList()
)

// Callers pick exactly what they need
val spec = NotificationSpec(
channel = CHANNEL_ID,
title = "Order shipped",
body = "Arriving Friday",
actions = listOf(Action("Track", trackIntent))
)

Kotlin DSL builder — best of both worlds

When construction has nesting or branching, a DSL with function receivers is elegant:

fun notification(init: NotificationSpec.Builder.() -> Unit): NotificationSpec =
NotificationSpec.Builder().apply(init).build()

class NotificationSpec.Builder {
var channel: String = ""
var title: String = ""
var body: String = ""
var priority = NotificationCompat.PRIORITY_DEFAULT
private val actions = mutableListOf<Action>()

fun action(label: String, intent: PendingIntent) {
actions += Action(label, intent)
}

fun build() = NotificationSpec(channel, title, body, priority, actions = actions.toList())
}

// Usage
val spec = notification {
channel = CHANNEL_ID
title = "Order shipped"
body = "Arriving Friday"
priority = NotificationCompat.PRIORITY_HIGH
action("Track", trackIntent)
action("Cancel", cancelIntent)
}

This is how Ktor, Exposed, and Jetpack Compose configure their APIs.


5. Prototype — clone existing objects

Intent: Specify the kinds of objects to create using a prototypical instance, and create new objects by copying this prototype.

Data class copy() — Kotlin's built-in prototype

data class AppConfig(
val apiBaseUrl: String,
val timeout: Duration,
val retryCount: Int,
val debugLogging: Boolean
)

val productionConfig = AppConfig(
apiBaseUrl = "https://api.example.com",
timeout = 30.seconds,
retryCount = 3,
debugLogging = false
)

// Clone with one change — the prototype pattern
val stagingConfig = productionConfig.copy(apiBaseUrl = "https://staging.api.example.com")

// Clone with multiple changes
val debugConfig = productionConfig.copy(
apiBaseUrl = "https://localhost:8080",
timeout = 60.seconds,
debugLogging = true
)

The copy() method generated by data class is the prototype pattern — make a new instance from an existing one, changing only the fields you need.

Deep copy vs shallow copy

copy() is shallow — nested mutable collections stay shared:

data class Cart(val items: MutableList<CartItem>)

val original = Cart(mutableListOf(CartItem("apple")))
val clone = original.copy()

clone.items.add(CartItem("banana"))
println(original.items) // [apple, banana] — original changed!

For true prototype semantics, use immutable collections:

data class Cart(val items: ImmutableList<CartItem>)

val clone = original.copy(items = original.items + CartItem("banana"))
// original.items is unchanged

See Domain Modeling for the immutability deep dive.

When prototype matters

  • Cloning expensive-to-build objects (pre-configured Retrofit, seeded DBs)
  • Creating variant configurations from a base (production → staging → dev)
  • Snapshot state for undo/redo
  • Pre-populated test fixtures

6. Object Pool — reuse expensive instances

Intent: Use a pool of ready-to-use objects instead of creating and destroying them on demand.

Why you'd want one

Some objects are expensive to construct:

  • Bitmap allocation at high resolution
  • OkHttp connections (already pooled, but the pattern applies)
  • Database prepared statements
  • Complex reflection-based parsers
  • Compose RecyclerView.RecycledViewPool (actual pool!)

A simple bitmap pool

class BitmapPool(private val maxSize: Int) {
private val available = ArrayDeque<Bitmap>(maxSize)
private val lock = Any()

fun acquire(width: Int, height: Int, config: Bitmap.Config): Bitmap {
synchronized(lock) {
val iter = available.iterator()
while (iter.hasNext()) {
val bmp = iter.next()
if (bmp.width == width && bmp.height == height && bmp.config == config) {
iter.remove()
return bmp
}
}
}
return Bitmap.createBitmap(width, height, config)
}

fun release(bitmap: Bitmap) {
synchronized(lock) {
if (available.size < maxSize && !bitmap.isRecycled) {
bitmap.eraseColor(Color.TRANSPARENT)
available.addLast(bitmap)
} else {
bitmap.recycle()
}
}
}
}

// Usage
class FrameRenderer(private val pool: BitmapPool) {
fun render(width: Int, height: Int): Bitmap {
val bitmap = pool.acquire(width, height, Bitmap.Config.ARGB_8888)
try {
// draw into the bitmap
return bitmap
} catch (t: Throwable) {
pool.release(bitmap)
throw t
}
}
}

Built-in Android pools

  • Glide/Coil — automatic bitmap pooling for image loading
  • RecyclerView.RecycledViewPool — ViewHolder reuse across adapters
  • Pools.SimplePool / SynchronizedPoolandroidx.core.util primitives
  • OkHttp ConnectionPool — HTTP/2 connection reuse
// androidx.core.util.Pools
class MessageEventPool {
private val pool = Pools.SynchronizedPool<MessageEvent>(32)

fun acquire(): MessageEvent = pool.acquire() ?: MessageEvent()

fun release(event: MessageEvent) {
event.reset()
pool.release(event)
}
}

When object pooling hurts

  • Short-lived objects — JVM GC handles them faster than a pool's overhead
  • Under-contention — lock contention becomes a bottleneck
  • Memory-constrained devices — cached instances compete with useful state
  • Debug complexity — use-after-release bugs are hard to find

Rule of thumb: measure first. If allocation shows up in CPU profiles or you see GC pauses, consider pooling. Otherwise, let the GC do its job.


The creational family at a glance

PatternKotlin-idiomatic replacementWhen the classic pattern still wins
Singletonobject + Hilt @SingletonRarely needed in classic form
Factory Methodcompanion.operator fun invokePolymorphic factory hierarchies
Abstract FactoryHilt modules + component hierarchyWhen you need runtime-switchable families
BuilderNamed args + defaults, or DSLLegacy Java APIs, complex nesting
Prototypedata class.copy()Already the idiom
Object PoolPools.* / RecycledViewPoolProfile-driven; rarely manual

Key takeaways

Practice exercises

  1. 01

    Convert a Java-style builder

    Find a hand-rolled builder class in your code. Replace with a data class + named args, or a Kotlin DSL with a function receiver.

  2. 02

    Write an abstract factory in Hilt

    Design a NetworkModule for prod and a replacing module for integration tests. Both produce Retrofit + OkHttp but with different interceptors.

  3. 03

    Prototype a config

    Define a BaseAppConfig and derive ProductionConfig, StagingConfig, DebugConfig via .copy() overrides.

  4. 04

    Pool an expensive object

    Profile a screen with allocation tracking. If you find frequent allocations of a fixed-size object (ByteBuffer, Bitmap), wrap it in a Pools.SynchronizedPool.

Continue reading