Most mobile apps don’t fail because of bad features. They fail because of bad architecture.
After 7+ years shipping Android apps — from solo projects to team codebases that onboard new devs every quarter — I keep coming back to the same 8 patterns from the Gang of Four book. Not because they’re academic, but because they solve real, recurring problems at production scale.
This is a practical series. No theory for its own sake — just the pattern, the Android problem it solves, and the Kotlin code that makes it click.
The 8 patterns at a glance
| Pattern | What it solves | Android context |
|---|---|---|
| Observer | UI that never goes stale | ViewModel + StateFlow |
| State | Impossible states made impossible | Sealed UiState class |
| Proxy | Cache + retry, invisible to callers | Repository layer |
| Facade | One call hides 5 use cases | Feature API for ViewModels |
| Adapter | Swap any SDK in one file | Analytics, payment SDKs |
| Factory | Mockable sources from day one | Dependency injection |
| Strategy | A/B test at runtime, zero rewrites | Feature flags |
| Decorator | Add behaviours without touching core | Logging, auth, caching |
The 4 rules I apply on every project
These aren’t guidelines — they’re constraints that prevent the most common architectural mistakes:
→ Every SDK gets an Adapter
→ Repository always = Proxy (cache gate)
→ One sealed UiState per screen
→ Facade per feature — keep ViewModels thin
Breaking any of these is fine in a prototype. In production, each one eventually costs you.
The numbers at scale
When these patterns are applied consistently across a codebase:
- 3× faster team onboarding — new devs find predictable structure everywhere
- 70% less boilerplate — patterns eliminate repeated decision-making
- 0 SDK lock-in with Adapter — I’ve swapped analytics SDKs in a single afternoon
- 10× faster unit tests — Factory + Adapter means no real network, no real disk
How this is organised
What follows takes the patterns two at a time, because that is how they actually show up — each pair covering the relationship between them, the code, and how they interact. Observer and State handle UI reactivity and compiler-enforced screen states. Proxy and Facade handle cache gates and thin ViewModels. Adapter and Factory handle SDK independence and testable sources. Strategy and Decorator handle runtime behavior and additive extensions.
Architecture is the decision you make at 9am that saves your team at 2am.
Observer & State: UI that never lies
Observer keeps the UI in sync with data automatically; State makes impossible screen states impossible at compile time.
Read on its own: Observer & State: UI that never liesObserver — UI that never goes stale
The Observer pattern defines a one-to-many dependency: when one object (the subject) changes state, all its observers are notified automatically.
In Android this used to mean manually calling notifyDataSetChanged(), toggling visibility in callbacks, and debugging why the UI showed old data after a rotation. StateFlow fixed all of that.
The problem without Observer
// Old approach — manual sync, guaranteed to drift
class LoginActivity : AppCompatActivity() {
private fun doLogin() {
showLoading()
api.login(email, password) { result ->
hideLoading()
if (result.isSuccess) {
navigateToHome()
} else {
showError(result.error)
// Did we hide loading? Did we re-enable the button?
// What about rotation? Config changes? 🤯
}
}
}
}
Each callback adds a new branch. Rotation destroys the Activity mid-flight. Error states get forgotten.
Observer with StateFlow
// ViewModel is the subject — emits state changes
class LoginViewModel(
private val authRepo: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun login(email: String, password: String) {
viewModelScope.launch {
_uiState.value = LoginUiState.Loading
authRepo.login(email, password)
.onSuccess { _uiState.value = LoginUiState.Success(it) }
.onFailure { _uiState.value = LoginUiState.Error(it.message) }
}
}
}
// Fragment is the observer — reacts to every emission
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
when (state) {
is LoginUiState.Idle -> showForm()
is LoginUiState.Loading -> showLoader()
is LoginUiState.Success -> navigateToHome(state.user)
is LoginUiState.Error -> showError(state.message)
}
}
}
}
The Fragment never calls the API. The ViewModel never touches Views. Rotation, back stack, process death — all handled by the lifecycle-aware collector.
With Jetpack Compose
@Composable
fun LoginScreen(viewModel: LoginViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
when (state) {
is LoginUiState.Idle -> LoginForm(onLogin = viewModel::login)
is LoginUiState.Loading -> CircularProgressIndicator()
is LoginUiState.Success -> LaunchedEffect(Unit) { onNavigateHome() }
is LoginUiState.Error -> ErrorSnackbar(state.message)
}
}
One when expression. Zero manual View toggling.
State — the compiler as your safety net
The State pattern allows an object to alter its behaviour when its internal state changes. In Android terms: every screen has a finite set of states, and you should make impossible states impossible at the type level.
The problem: boolean soup
// Which combinations are valid? Nobody knows.
class LoginViewModel : ViewModel() {
val isLoading = MutableLiveData<Boolean>()
val isError = MutableLiveData<Boolean>()
val errorMessage = MutableLiveData<String?>()
val isSuccess = MutableLiveData<Boolean>()
val user = MutableLiveData<User?>()
// Can isLoading and isError both be true? Can isSuccess be true with a null user?
// The compiler has no idea. Runtime crashes will find out for you.
}
With 4 booleans you have 16 theoretical combinations. Maybe 3 are valid. The compiler won’t tell you.
Sealed class UiState
// Every valid state is a type. Every invalid state is impossible.
sealed class LoginUiState {
object Idle : LoginUiState()
object Loading : LoginUiState()
data class Success(val user: User) : LoginUiState()
data class Error(val message: String?) : LoginUiState()
}
Why this matters:
// The compiler enforces exhaustive handling
when (state) {
LoginUiState.Idle -> ...
LoginUiState.Loading -> ...
is LoginUiState.Success -> ... // Compiler knows state.user is non-null
is LoginUiState.Error -> ... // Compiler knows state.message exists
// Forget a branch → compile error, not a runtime crash
}
You cannot access state.user when the state is Loading. You cannot have a Success with a null user. The type system prevents the bug before the test ever runs.
Real-world UiState example
sealed class ProductDetailUiState {
object Loading : ProductDetailUiState()
data class Content(
val product: Product,
val relatedProducts: List<Product>,
val isFavorite: Boolean,
val stockStatus: StockStatus
) : ProductDetailUiState()
data class Error(
val message: String,
val canRetry: Boolean
) : ProductDetailUiState()
// Add states as the screen grows — existing when() blocks will
// produce compile errors if you forget to handle the new state
object OutOfRegion : ProductDetailUiState()
}
One sealed class per screen. One collect in the Fragment or one when in the composable.
Observer + State together
The real power comes from combining them:
// ViewModel combines both patterns
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() // Observer
// ^^^^^^^^^^^^^
// Sealed class enforces valid states // State
Observer ensures the UI always reflects the latest state. State ensures that latest state is always valid.
Result: UI that never lies.
Reactivity settles what the screen shows. What sits behind it is the next pair — a cache gate that stays invisible to its callers, and the discipline that keeps ViewModels thin.
Proxy & Facade: cache gates and thin ViewModels
Proxy adds caching and retry invisible to callers; Facade hides a multi-service flow behind a single call so ViewModels stay thin.
Read on its own: Proxy & Facade: cache gates and thin ViewModelsProxy — cache + retry, invisible to callers
The Proxy pattern provides a substitute for another object, controlling access to it. In Android: your Repository is a Proxy. The ViewModel asks for data; the Repository decides whether to return it from memory, disk, or the network.
The caller never knows which one.
Without Proxy — ViewModels doing too much
// ViewModel shouldn't know about caching, retries, or network state
class UserProfileViewModel(private val api: UserApi) : ViewModel() {
fun loadUser(id: String) {
viewModelScope.launch {
// Cache logic in ViewModel? Retry logic? No.
if (memoryCache.contains(id)) {
_uiState.value = UiState.Content(memoryCache[id]!!)
return@launch
}
try {
val user = api.getUser(id)
memoryCache[id] = user
_uiState.value = UiState.Content(user)
} catch (e: Exception) {
// Do we retry? How many times? The ViewModel shouldn't decide.
_uiState.value = UiState.Error(e.message)
}
}
}
}
This ViewModel knows about caching, retry policy, and network errors. Change any infrastructure detail and you edit business logic.
Proxy: Repository as cache gate
interface UserRepository {
suspend fun getUser(id: String): Result<User>
}
class UserRepositoryImpl(
private val api: UserApi,
private val cache: UserCache,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : UserRepository {
override suspend fun getUser(id: String): Result<User> =
withContext(dispatcher) {
// 1. Memory hit — instant return
cache.get(id)?.let { return@withContext Result.success(it) }
// 2. Network with retry — caller never sees this
retry(times = 3, delayMs = 1000) {
api.getUser(id)
}.also { result ->
result.getOrNull()?.let { cache.put(id, it, ttlMs = 300_000) }
}
}
}
// Retry helper — pure function, easy to test
suspend fun <T> retry(
times: Int,
delayMs: Long = 500,
block: suspend () -> T
): Result<T> {
repeat(times - 1) { attempt ->
runCatching { block() }
.onSuccess { return Result.success(it) }
delay(delayMs * (attempt + 1)) // Exponential backoff
}
return runCatching { block() }
}
// ViewModel is now clean
class UserProfileViewModel(private val repo: UserRepository) : ViewModel() {
fun loadUser(id: String) {
viewModelScope.launch {
_uiState.value = UiState.Loading
repo.getUser(id)
.onSuccess { _uiState.value = UiState.Content(it) }
.onFailure { _uiState.value = UiState.Error(it.message) }
}
}
}
The ViewModel has no idea about cache, retry, or network state. Swap UserRepositoryImpl for an offline-first Room-backed version — the ViewModel changes nothing.
Offline-first Proxy with Room + network
class UserRepositoryImpl(
private val api: UserApi,
private val dao: UserDao
) : UserRepository {
override suspend fun getUser(id: String): Result<User> =
withContext(Dispatchers.IO) {
runCatching {
// Try local DB first
dao.getUser(id)?.let { return@runCatching it }
// Hit network, persist result
api.getUser(id).also { dao.upsert(it) }
}
}
// Room Flow — auto-updates when DB changes
fun observeUser(id: String): Flow<User?> = dao.observeUser(id)
}
Facade — one call hides five use cases
The Facade pattern provides a simplified interface to a complex subsystem. In Android: your feature API is a Facade. The ViewModel calls one method; the Facade coordinates multiple services underneath.
The problem: fat ViewModels
// ViewModel orchestrating everything — a common anti-pattern
class CheckoutViewModel(
private val cartRepo: CartRepository,
private val paymentService: PaymentService,
private val inventoryService: InventoryService,
private val analyticsService: AnalyticsService,
private val notificationService: NotificationService
) : ViewModel() {
fun checkout(userId: String) {
viewModelScope.launch {
val cart = cartRepo.getCart(userId)
val reserved = inventoryService.reserve(cart.items)
if (!reserved) { /* handle */ return@launch }
val result = paymentService.charge(cart.total, userId)
analyticsService.track("checkout_completed", mapOf("total" to cart.total))
notificationService.sendConfirmation(userId)
// ... and it keeps growing
}
}
}
This ViewModel has 5 dependencies and knows the entire checkout choreography. Every new business rule adds a line here.
Facade: feature API
// The Facade — one interface, one responsibility
interface CheckoutFacade {
suspend fun checkout(userId: String): Result<CheckoutReceipt>
}
class CheckoutFacadeImpl(
private val cartRepo: CartRepository,
private val paymentService: PaymentService,
private val inventoryService: InventoryService,
private val analyticsService: AnalyticsService,
private val notificationService: NotificationService
) : CheckoutFacade {
override suspend fun checkout(userId: String): Result<CheckoutReceipt> =
runCatching {
val cart = cartRepo.getCart(userId)
check(inventoryService.reserve(cart.items)) {
"Items not available"
}
val receipt = paymentService.charge(cart.total, userId)
// Fire-and-forget side effects
coroutineScope {
launch { analyticsService.track("checkout_completed", cart.toMap()) }
launch { notificationService.sendConfirmation(userId, receipt) }
}
receipt
}
}
// ViewModel has ONE dependency — stays thin
class CheckoutViewModel(private val checkout: CheckoutFacade) : ViewModel() {
fun checkout(userId: String) {
viewModelScope.launch {
_uiState.value = CheckoutUiState.Processing
checkout.checkout(userId)
.onSuccess { _uiState.value = CheckoutUiState.Success(it) }
.onFailure { _uiState.value = CheckoutUiState.Error(it.message) }
}
}
}
New business rule (loyalty points, fraud check, referral tracking)? Add it to CheckoutFacadeImpl. The ViewModel never changes.
Testing the Facade
@Test
fun `checkout success updates state to Success`() = runTest {
val fakeFacade = object : CheckoutFacade {
override suspend fun checkout(userId: String) =
Result.success(CheckoutReceipt(id = "receipt_123", total = 99.0))
}
val viewModel = CheckoutViewModel(checkout = fakeFacade)
viewModel.checkout("user_1")
assertThat(viewModel.uiState.value).isInstanceOf(CheckoutUiState.Success::class.java)
}
One-line fake, no mocking framework needed. That’s the payoff.
Proxy + Facade in the same architecture
ViewModel
│
└── CheckoutFacade ← Facade: hides complexity
│
├── CartRepository ← Proxy: cache gate
├── PaymentService
├── InventoryService ← Proxy: cache gate
└── AnalyticsService
The ViewModel talks to the Facade. The Facade orchestrates Proxies. Neither layer knows the other’s implementation details.
That keeps the layers honest on the inside. Keeping them honest at the boundary — swapping any SDK in a single file, building sources that are mockable from day one — is what the next pair is for.
Adapter & Factory: zero SDK lock-in and 10× faster tests
Adapter wraps any third-party SDK behind your own interface; Factory creates those implementations so they're mockable from day one.
Read on its own: Adapter & Factory: zero SDK lock-in and 10× faster testsAdapter — swap any SDK in one file
The Adapter pattern converts the interface of a class into another interface that clients expect. In Android: every third-party SDK gets wrapped in your own interface. Your business logic depends on your interface. The Adapter connects your interface to the SDK.
When the SDK changes — pricing, API, deprecation — you change one file.
The problem: direct SDK dependency
// Business logic coupled directly to Firebase
class EventTracker(private val firebase: FirebaseAnalytics) {
fun trackPurchase(productId: String, price: Double) {
val bundle = Bundle().apply {
putString("product_id", productId)
putDouble("price", price)
}
firebase.logEvent("purchase", bundle)
}
}
Now your product manager wants to A/B test Firebase vs Amplitude. Or the SDK raises prices. Or you want to test EventTracker without spinning up Firebase. None of this is easy with a direct dependency.
Adapter: your interface, their implementation
// Step 1: Define YOUR interface — the contract your code depends on
interface AnalyticsTracker {
fun track(event: String, params: Map<String, Any> = emptyMap())
fun setUserId(id: String)
fun reset()
}
// Step 2: Firebase Adapter — wraps the SDK
class FirebaseAnalyticsAdapter(
private val firebase: FirebaseAnalytics
) : AnalyticsTracker {
override fun track(event: String, params: Map<String, Any>) {
val bundle = Bundle().apply {
params.forEach { (k, v) -> putString(k, v.toString()) }
}
firebase.logEvent(event, bundle)
}
override fun setUserId(id: String) = firebase.setUserId(id)
override fun reset() = firebase.setUserId(null)
}
// Step 3: Amplitude Adapter — same interface, different SDK
class AmplitudeAdapter(
private val amplitude: Amplitude
) : AnalyticsTracker {
override fun track(event: String, params: Map<String, Any>) {
amplitude.track(event, params)
}
override fun setUserId(id: String) = amplitude.setUserId(id)
override fun reset() = amplitude.reset()
}
// Business logic never changes — just inject the right adapter
class PurchaseUseCase(private val tracker: AnalyticsTracker) {
suspend fun purchase(item: CartItem, userId: String): Result<Receipt> {
// ... payment logic ...
tracker.track("purchase", mapOf(
"product_id" to item.id,
"price" to item.price
))
return Result.success(receipt)
}
}
Swap SDKs by changing the injected adapter. The PurchaseUseCase has no idea which analytics SDK runs underneath.
Multi-destination Adapter
// Send events to multiple SDKs simultaneously
class CompositeAnalyticsTracker(
private val trackers: List<AnalyticsTracker>
) : AnalyticsTracker {
override fun track(event: String, params: Map<String, Any>) {
trackers.forEach { it.track(event, params) }
}
override fun setUserId(id: String) = trackers.forEach { it.setUserId(id) }
override fun reset() = trackers.forEach { it.reset() }
}
// Usage
val tracker = CompositeAnalyticsTracker(listOf(
FirebaseAnalyticsAdapter(firebase),
AmplitudeAdapter(amplitude),
))
Testing with a fake Adapter
class FakeAnalyticsTracker : AnalyticsTracker {
val events = mutableListOf<Pair<String, Map<String, Any>>>()
override fun track(event: String, params: Map<String, Any>) {
events += event to params
}
override fun setUserId(id: String) {}
override fun reset() { events.clear() }
}
@Test
fun `purchase tracks analytics event`() = runTest {
val fakeTracker = FakeAnalyticsTracker()
val useCase = PurchaseUseCase(tracker = fakeTracker)
useCase.purchase(CartItem(id = "item_1", price = 29.99), userId = "user_1")
assertThat(fakeTracker.events).hasSize(1)
assertThat(fakeTracker.events.first().first).isEqualTo("purchase")
}
No Firebase instance. No Android context. Test runs in milliseconds.
Factory — mockable sources from day one
The Factory pattern defines an interface for creating objects, letting subclasses or callers decide which class to instantiate. In Android: Factories create your Repository and service implementations, making them swappable between production and test environments.
The problem: hard-coded dependencies
// Impossible to test — real network, real database
class UserViewModel : ViewModel() {
// Construction happens inside the class — no way to inject fakes
private val api = RetrofitFactory.create(UserApi::class.java)
private val dao = AppDatabase.getInstance(context).userDao()
private val repo = UserRepositoryImpl(api, dao)
}
You can’t run a unit test for this ViewModel without a real database and a real server.
Factory: separate creation from use
// The interface — what callers depend on
interface UserRepository {
suspend fun getUser(id: String): Result<User>
suspend fun saveUser(user: User): Result<Unit>
fun observeUser(id: String): Flow<User?>
}
// Production implementation
class UserRepositoryImpl(
private val api: UserApi,
private val dao: UserDao,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : UserRepository {
override suspend fun getUser(id: String) = withContext(dispatcher) {
runCatching { dao.getUser(id) ?: api.getUser(id).also { dao.upsert(it) } }
}
override suspend fun saveUser(user: User) = withContext(dispatcher) {
runCatching { dao.upsert(user) }
}
override fun observeUser(id: String): Flow<User?> = dao.observeUser(id)
}
// Test fake — in-memory, instant, deterministic
class FakeUserRepository : UserRepository {
private val store = mutableMapOf<String, User>()
private val updates = MutableStateFlow<User?>(null)
fun seed(user: User) {
store[user.id] = user
updates.value = user
}
override suspend fun getUser(id: String) =
store[id]?.let { Result.success(it) } ?: Result.failure(NoSuchElementException(id))
override suspend fun saveUser(user: User): Result<Unit> {
store[user.id] = user
updates.value = user
return Result.success(Unit)
}
override fun observeUser(id: String): Flow<User?> =
updates.filterNotNull().filter { it.id == id }
}
// Hilt module wires the right implementation per environment
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
// Test module overrides it
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
@Module
abstract class FakeRepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(fake: FakeUserRepository): UserRepository
}
Inline Factory pattern (no DI framework)
// Simple factory when Hilt is overkill
object RepositoryFactory {
fun createUserRepository(context: Context): UserRepository =
if (BuildConfig.DEBUG && isRunningTests()) {
FakeUserRepository()
} else {
UserRepositoryImpl(
api = RetrofitFactory.create(UserApi::class.java),
dao = AppDatabase.getInstance(context).userDao()
)
}
}
Adapter + Factory in practice
// Factory creates the right adapter for the environment
object AnalyticsFactory {
fun create(context: Context): AnalyticsTracker =
when {
BuildConfig.DEBUG -> NoOpAnalyticsTracker() // No events in debug
isQABuild() -> LoggingAnalyticsTracker() // Logs to console
else -> CompositeAnalyticsTracker(listOf(
FirebaseAnalyticsAdapter(FirebaseAnalytics.getInstance(context)),
AmplitudeAdapter(Amplitude.getInstance().also {
it.initialize(context, BuildConfig.AMPLITUDE_KEY)
})
))
}
}
The Factory decides which Adapter to create. The Adapter decides how to call the SDK. Your business logic never touches either decision.
The 10× test speed claim
It’s conservative. A ViewModel test with:
- Real Retrofit: ~3-8 seconds (network timeout, response parsing)
- Fake Repository: ~30-80ms (in-memory map lookup)
On a codebase with 200 ViewModel tests, that’s the difference between a 30-minute CI run and a 3-minute CI run.
Those three pairs settle structure. The last one settles behavior: A/B testing at runtime without rewrites, and extending what a class does without ever touching its core logic.
Strategy & Decorator: A/B tests at runtime and additive layers
Strategy switches algorithms at runtime without touching business logic; Decorator wraps an interface to add one cross-cutting concern at a time.
Read on its own: Strategy & Decorator: A/B tests at runtime and additive layersStrategy — A/B test at runtime, zero rewrites
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. In Android: business rules that change at runtime — pricing logic, recommendation algorithms, onboarding flows — are Strategies. You swap the algorithm without touching the code that uses it.
The problem: hardcoded logic branches
// Adding a variant means editing this class every time
class PricingEngine {
fun calculatePrice(product: Product, userId: String): Double {
return when {
isUserInExperiment("premium_pricing", userId) -> product.price * 1.15
isUserInExperiment("discount_pricing", userId) -> product.price * 0.90
else -> product.price
}
}
}
Every new A/B test adds a branch here. After 5 experiments, this function is unreadable. After 10, it’s a bug factory.
Strategy: encapsulate the algorithm
// The strategy interface — one algorithm, one contract
fun interface PricingStrategy {
fun calculate(product: Product, userId: String): Double
}
// Each variant is a self-contained strategy
object StandardPricing : PricingStrategy {
override fun calculate(product: Product, userId: String) = product.price
}
object PremiumPricing : PricingStrategy {
override fun calculate(product: Product, userId: String) = product.price * 1.15
}
object DiscountPricing : PricingStrategy {
override fun calculate(product: Product, userId: String) = product.price * 0.90
}
class PersonalizedPricing(
private val userRepo: UserRepository
) : PricingStrategy {
override fun calculate(product: Product, userId: String): Double {
val tier = userRepo.getUserTier(userId)
return product.price * tier.multiplier
}
}
// PricingEngine never changes — just inject the right strategy
class PricingEngine(private val strategy: PricingStrategy) {
fun calculatePrice(product: Product, userId: String): Double =
strategy.calculate(product, userId)
}
Remote config: switch strategies at runtime
// Factory selects the active strategy based on remote config
class PricingStrategyFactory(
private val remoteConfig: RemoteConfig,
private val userRepo: UserRepository
) {
fun get(userId: String): PricingStrategy =
when (remoteConfig.getString("pricing_variant", "standard")) {
"premium" -> PremiumPricing
"discount" -> DiscountPricing
"personalized" -> PersonalizedPricing(userRepo)
else -> StandardPricing
}
}
// ViewModel wires it up
class ProductViewModel(
private val productRepo: ProductRepository,
private val pricingFactory: PricingStrategyFactory,
private val userId: String
) : ViewModel() {
fun loadProduct(id: String) {
viewModelScope.launch {
val product = productRepo.getProduct(id).getOrReturn { return@launch }
val strategy = pricingFactory.get(userId)
val price = strategy.calculate(product, userId)
_uiState.value = ProductUiState.Content(product, price)
}
}
}
Push a remote config update → users get a different pricing algorithm on next app open. No release needed.
Strategy for sorting and filtering
// Strategies compose cleanly with each other
fun interface SortStrategy<T> {
fun sort(items: List<T>): List<T>
}
fun interface FilterStrategy<T> {
fun filter(items: List<T>): List<T>
}
class ProductListProcessor<T>(
private val sort: SortStrategy<T>,
private val filter: FilterStrategy<T>
) {
fun process(items: List<T>): List<T> = filter.filter(items).let(sort::sort)
}
// Usage
val processor = ProductListProcessor(
sort = SortStrategy { items -> items.sortedByDescending { (it as Product).rating } },
filter = FilterStrategy { items -> items.filter { (it as Product).inStock } }
)
Testing strategies in isolation
@Test
fun `premium pricing applies 15% markup`() {
val product = Product(id = "p1", price = 100.0)
val result = PremiumPricing.calculate(product, userId = "user_1")
assertThat(result).isEqualTo(115.0)
}
@Test
fun `pricing engine delegates to injected strategy`() {
val strategy = PricingStrategy { product, _ -> product.price * 2 }
val engine = PricingEngine(strategy)
val result = engine.calculatePrice(Product(id = "p1", price = 50.0), "user_1")
assertThat(result).isEqualTo(100.0)
}
Each strategy is a pure function. Tests run in microseconds with no mocking.
Decorator — add behaviors without touching core
The Decorator pattern attaches additional responsibilities to an object dynamically. In Android: cross-cutting concerns — logging, caching, auth headers, retry logic, analytics — are Decorators. They wrap your interface, add one behavior, and delegate everything else.
The problem: feature creep in core classes
// Repository that "just needs" a few extras
class UserRepositoryImpl(private val api: UserApi) : UserRepository {
override suspend fun getUser(id: String): Result<User> {
Log.d("UserRepo", "getUser($id)") // Logging concern
val cached = cache[id] // Caching concern
if (cached != null) return Result.success(cached)
return try {
val user = api.getUser(id)
cache[id] = user
analyticsService.track("user_fetched") // Analytics concern
Result.success(user)
} catch (e: Exception) {
Log.e("UserRepo", "Failed", e)
Result.failure(e)
}
}
}
Three different concerns in one function. Add auth headers, add retry, add metrics — the class keeps growing. Testing any one concern means testing all of them.
Decorator: one wrapper, one concern
// Core implementation — only fetches users
class UserRepositoryImpl(
private val api: UserApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : UserRepository {
override suspend fun getUser(id: String): Result<User> =
withContext(dispatcher) { runCatching { api.getUser(id) } }
override suspend fun saveUser(user: User): Result<Unit> =
withContext(dispatcher) { runCatching { api.saveUser(user) } }
}
// Logging Decorator — adds structured logging
class LoggingUserRepository(
private val delegate: UserRepository,
private val tag: String = "UserRepository"
) : UserRepository {
override suspend fun getUser(id: String): Result<User> {
Log.d(tag, "getUser($id) →")
return delegate.getUser(id).also { result ->
result
.onSuccess { Log.d(tag, "getUser($id) ← ${it.name}") }
.onFailure { Log.e(tag, "getUser($id) ← ERROR", it) }
}
}
override suspend fun saveUser(user: User) = delegate.saveUser(user)
}
// Caching Decorator — adds in-memory cache with TTL
class CachingUserRepository(
private val delegate: UserRepository,
private val ttlMs: Long = 300_000 // 5 minutes
) : UserRepository {
private data class CacheEntry(val user: User, val expiresAt: Long)
private val cache = ConcurrentHashMap<String, CacheEntry>()
override suspend fun getUser(id: String): Result<User> {
val entry = cache[id]
if (entry != null && System.currentTimeMillis() < entry.expiresAt) {
return Result.success(entry.user)
}
return delegate.getUser(id).also { result ->
result.getOrNull()?.let {
cache[id] = CacheEntry(it, System.currentTimeMillis() + ttlMs)
}
}
}
override suspend fun saveUser(user: User): Result<Unit> =
delegate.saveUser(user).also { result ->
result.getOrNull()?.let { cache.remove(user.id) }
}
}
// Metrics Decorator — tracks call counts and latency
class MetricsUserRepository(
private val delegate: UserRepository,
private val metrics: MetricsService
) : UserRepository {
override suspend fun getUser(id: String): Result<User> {
val start = System.currentTimeMillis()
return delegate.getUser(id).also { result ->
val duration = System.currentTimeMillis() - start
metrics.record("user_repo.get_user", duration,
tags = mapOf("success" to result.isSuccess.toString()))
}
}
override suspend fun saveUser(user: User) = delegate.saveUser(user)
}
Composing Decorators
// Hilt wires the full decorator chain
@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
@Provides
@Singleton
fun provideUserRepository(
api: UserApi,
metrics: MetricsService
): UserRepository =
UserRepositoryImpl(api)
.let { LoggingUserRepository(it) }
.let { CachingUserRepository(it, ttlMs = 300_000) }
.let { MetricsUserRepository(it, metrics) }
}
The order matters. In this chain:
MetricsUserRepositoryreceives the call → starts timerCachingUserRepositorychecks cache → cache miss → delegatesLoggingUserRepositorylogs the call → delegatesUserRepositoryImplhits the network
Cache hit skips steps 3 and 4. Metrics always fires.
Decorator for OkHttp interceptors
The pattern is already baked into OkHttp — each Interceptor is a Decorator:
val client = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider)) // Adds auth headers
.addInterceptor(LoggingInterceptor()) // Logs request/response
.addInterceptor(RetryInterceptor(maxRetries = 3)) // Retries on 5xx
.addNetworkInterceptor(CacheInterceptor()) // Cache-Control headers
.build()
Each interceptor wraps the Chain — the same pattern, applied to HTTP.
Testing each Decorator in isolation
@Test
fun `caching repository returns cached value on second call`() = runTest {
var callCount = 0
val fakeDelegate = object : UserRepository {
override suspend fun getUser(id: String): Result<User> {
callCount++
return Result.success(User(id, "Alice"))
}
override suspend fun saveUser(user: User) = Result.success(Unit)
}
val repo = CachingUserRepository(fakeDelegate, ttlMs = 60_000)
repo.getUser("user_1")
repo.getUser("user_1")
assertThat(callCount).isEqualTo(1) // Second call hit cache
}
@Test
fun `logging decorator logs on failure`() = runTest {
val fakeDelegate = object : UserRepository {
override suspend fun getUser(id: String) =
Result.failure<User>(IOException("Network error"))
override suspend fun saveUser(user: User) = Result.success(Unit)
}
// No assertions on Log — we're verifying it doesn't crash and propagates the failure
val repo = LoggingUserRepository(fakeDelegate)
val result = repo.getUser("user_1")
assertThat(result.isFailure).isTrue()
}
Strategy + Decorator in the same architecture
They solve different problems but compose naturally:
ViewModel
│
└── PricingEngine(strategy: PricingStrategy) ← Strategy: which algorithm?
│
└── UserRepository (decorator chain) ← Decorator: which concerns?
├── MetricsUserRepository
├── CachingUserRepository
├── LoggingUserRepository
└── UserRepositoryImpl
Strategy answers: which algorithm runs? Decorator answers: what wraps around the algorithm?
Wrapping up the series
You now have the complete toolkit:
| Pattern | What it solves | Key insight |
|---|---|---|
| Observer | Stale UI | ViewModel emits state; Fragment/Composable reacts |
| State | Impossible UI states | Sealed class = compiler-enforced valid states |
| Proxy | Repeated network calls | Repository = transparent cache gate |
| Facade | Fat ViewModels | Feature API = one call hides N services |
| Adapter | SDK lock-in | Your interface, their implementation |
| Factory | Untestable classes | DI module = injectable fake or real |
| Strategy | Hardcoded algorithm branches | Inject the algorithm, swap at runtime |
| Decorator | Cross-cutting concerns bloating core | Wrap the interface, add one behavior |
The four rules from Observer & State hold across all 8:
→ Every SDK gets an Adapter
→ Repository always = Proxy (cache gate)
→ One sealed UiState per screen
→ Facade per feature — keep ViewModels thin
Add Strategy and Decorator and you have the full picture:
→ Every runtime-variable algorithm = Strategy
→ Every cross-cutting concern = Decorator
These aren’t abstract. They’re the decisions that make the difference between a codebase that scales and one that becomes a rewrite conversation.