Skip to main content

Design Patterns in Android

Patterns are named solutions to recurring problems. You'll spot them in every Android API: Notification.Builder, OkHttpClient.Builder, LiveData (observer), ViewModelProvider.Factory. Knowing the names helps you read code, communicate with teammates, and choose the right shape for new code.

Creational

Singleton — exactly one instance

// Hilt manages the singleton scope for you
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder().build()
}

In pure Kotlin, the object keyword creates a thread-safe singleton:

object Logger { fun d(tag: String, msg: String) = Log.d(tag, msg) }

Factory — centralize construction

class ViewModelFactory @Inject constructor(
private val repo: UserRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass == ProfileViewModel::class.java) { "Unknown VM" }
@Suppress("UNCHECKED_CAST")
return ProfileViewModel(repo) as T
}
}

Hilt's @HiltViewModel is the modern replacement — you rarely write factories by hand.

Builder — step-by-step construction

val notification = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Order shipped")
.setContentText("Arriving Friday")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.build()

Kotlin's apply scope function makes builders even nicer:

val request = OneTimeWorkRequestBuilder<SyncWorker>().apply {
setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
}.build()

Structural

Adapter — convert one interface to another

The classic Android example is RecyclerView.Adapter — it adapts your data to the View recycling protocol. In modern code, mappers are adapters in disguise:

fun ProductDto.toEntity() = ProductEntity(
id = id,
name = name,
priceCents = priceCents,
fetchedAt = System.currentTimeMillis()
)

fun ProductEntity.toDomain() = Product(
id = id,
name = name,
price = Money.fromCents(priceCents)
)

Decorator — add behavior at runtime

class CachingProductRepository(
private val delegate: ProductRepository,
private val cache: Cache<String, Product>
) : ProductRepository {
override suspend fun fetch(id: String): Product =
cache.getOrPut(id) { delegate.fetch(id) }
}

Compose's Modifier chain is a decorator pipeline — each modifier wraps the previous behavior:

Modifier
.padding(8.dp) // outer padding
.clip(RoundedCornerShape(8.dp))
.background(Color.Red)
.clickable { onClick() } // click hits the rounded background
.padding(16.dp) // inner padding

Facade — simplify a complex subsystem

A repository is a facade. Callers see userRepo.fetch(id); behind it sit Retrofit, OkHttp, Moshi, Room, and a dispatcher. The complexity is hidden.

Behavioral

Observer — push updates to subscribers

StateFlow and SharedFlow are observers. The Compose State<T> mechanism is also an observer — it tracks reads inside a composition and triggers recomposition on writes.

class CartViewModel : ViewModel() {
private val _items = MutableStateFlow<List<CartItem>>(emptyList())
val items: StateFlow<List<CartItem>> = _items.asStateFlow()
}

@Composable
fun CartBadge(viewModel: CartViewModel) {
val items by viewModel.items.collectAsStateWithLifecycle()
BadgedBox(badge = { Badge { Text("${items.size}") } }) { Icon(Icons.Default.ShoppingCart, null) }
}

Strategy — interchangeable algorithms

interface SortStrategy {
fun sort(products: List<Product>): List<Product>
}

object PriceAscending : SortStrategy {
override fun sort(p: List<Product>) = p.sortedBy { it.price.cents }
}

object NewestFirst : SortStrategy {
override fun sort(p: List<Product>) = p.sortedByDescending { it.createdAt }
}

class CatalogViewModel(private val sort: SortStrategy) : ViewModel() {
val sorted = source.map { sort.sort(it) }
}

Command — encapsulate a request as an object

Use cases (Module 04) are commands. Each one encapsulates a single intent that can be executed, queued, or undone:

class CheckoutUseCase @Inject constructor(
private val cart: CartRepository,
private val payment: PaymentGateway
) {
suspend operator fun invoke(method: PaymentMethod): OrderId {
val items = cart.items()
val charge = payment.charge(items.total, method)
cart.clear()
return charge.orderId
}
}

State — encode behavior in state objects

Sealed classes shine for state machines:

sealed interface DownloadState {
data object Idle : DownloadState
data class Downloading(val progress: Int) : DownloadState
data class Done(val file: File) : DownloadState
data class Failed(val error: Throwable) : DownloadState
}

@Composable
fun DownloadView(state: DownloadState, onRetry: () -> Unit) {
when (state) {
DownloadState.Idle -> Button(onClick = { /* start */ }) { Text("Download") }
is DownloadState.Downloading -> LinearProgressIndicator(progress = { state.progress / 100f })
is DownloadState.Done -> Text("Saved to ${state.file.name}")
is DownloadState.Failed -> ErrorRow(state.error.message ?: "Failed", onRetry)
}
}

Android-specific patterns

🗄️

Repository

Hide the source-of-truth (network/DB/cache) behind a single interface for the rest of the app.

🧩

Use Case

Encapsulate a single business operation as an object — testable, composable, undoable.

🔄

Unidirectional Data Flow

State flows down, events flow up. The single rule that makes Compose tractable.

💉

Dependency Injection

Hilt builds the object graph; you focus on declaring needs, not constructing them.

📦

Sealed UI State

One UiState class per screen describes everything the View renders.

Reducer / MVI

Single state, intents in, state out. The most disciplined version of MVVM.

Continue reading