<CarlosDev/>

Current

8 GoF patterns that decide if your Android app scales

Eight Gang-of-Four patterns that decide whether an Android app scales — the pattern, the problem it solves, and the Kotlin that makes it click.

Published
Updated
Reading
23 min
Write-up
Stable
Eight Gang-of-Four patterns mapped to Android architecture concerns.
In this record8 sections

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

PatternWhat it solvesAndroid context
ObserverUI that never goes staleViewModel + StateFlow
StateImpossible states made impossibleSealed UiState class
ProxyCache + retry, invisible to callersRepository layer
FacadeOne call hides 5 use casesFeature API for ViewModels
AdapterSwap any SDK in one fileAnalytics, payment SDKs
FactoryMockable sources from day oneDependency injection
StrategyA/B test at runtime, zero rewritesFeature flags
DecoratorAdd behaviours without touching coreLogging, 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.

Part 1 of 4

4 min

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 lies

Observer — 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.

Part 2 of 4

4 min

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 ViewModels

Proxy — 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.

Part 3 of 4

5 min

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 tests

Adapter — 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.

Part 4 of 4

8 min

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 layers

Strategy — 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:

  1. MetricsUserRepository receives the call → starts timer
  2. CachingUserRepository checks cache → cache miss → delegates
  3. LoggingUserRepository logs the call → delegates
  4. UserRepositoryImpl hits 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:

PatternWhat it solvesKey insight
ObserverStale UIViewModel emits state; Fragment/Composable reacts
StateImpossible UI statesSealed class = compiler-enforced valid states
ProxyRepeated network callsRepository = transparent cache gate
FacadeFat ViewModelsFeature API = one call hides N services
AdapterSDK lock-inYour interface, their implementation
FactoryUntestable classesDI module = injectable fake or real
StrategyHardcoded algorithm branchesInject the algorithm, swap at runtime
DecoratorCross-cutting concerns bloating coreWrap 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.

Filed under