<CarlosDev/>

Parte 4 de 4

Strategy & Decorator: A/B tests en runtime y capas aditivas

Strategy cambia algoritmos en runtime sin tocar la lógica de negocio; Decorator envuelve una interfaz para agregar una preocupación transversal a la vez.

En este registro4 secciones

Strategy — A/B test en runtime, cero reescrituras

El patrón Strategy define una familia de algoritmos, encapsula cada uno, y los hace intercambiables. En Android: las reglas de negocio que cambian en runtime — lógica de precios, algoritmos de recomendación, flujos de onboarding — son Strategies. Cambias el algoritmo sin tocar el código que lo usa.

El problema: ramas de lógica hardcodeadas

// Agregar una variante significa editar esta clase cada vez
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
        }
    }
}

Cada nuevo A/B test agrega una rama aquí. Después de 5 experimentos, esta función es ilegible. Después de 10, es una fábrica de bugs.

Strategy: encapsula el algoritmo

// La interfaz de strategy — un algoritmo, un contrato
fun interface PricingStrategy {
    fun calculate(product: Product, userId: String): Double
}
// Cada variante es una strategy autocontenida
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 nunca cambia — solo inyecta la strategy correcta
class PricingEngine(private val strategy: PricingStrategy) {

    fun calculatePrice(product: Product, userId: String): Double =
        strategy.calculate(product, userId)
}

Remote config: cambia strategies en runtime

// Factory selecciona la strategy activa según la config remota
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 lo conecta todo
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)
        }
    }
}

Empuja una actualización de config remota → los usuarios obtienen un algoritmo de precios diferente en la próxima apertura de la app. Sin release necesario.

Strategy para ordenamiento y filtrado

// Las strategies se componen entre sí limpiamente
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)
}

// Uso
val processor = ProductListProcessor(
    sort   = SortStrategy { items -> items.sortedByDescending { (it as Product).rating } },
    filter = FilterStrategy { items -> items.filter { (it as Product).inStock } }
)

Testeando strategies de forma aislada

@Test
fun `premium pricing aplica markup del 15%`() {
    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 delega a la strategy inyectada`() {
    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)
}

Cada strategy es una función pura. Los tests corren en microsegundos sin mocking.


Decorator — agrega comportamientos sin tocar el núcleo

El patrón Decorator adjunta responsabilidades adicionales a un objeto dinámicamente. En Android: las preocupaciones transversales — logging, caché, encabezados de auth, lógica de retry, analíticas — son Decorators. Envuelven tu interfaz, agregan un comportamiento, y delegan todo lo demás.

El problema: feature creep en clases núcleo

// Repository que "solo necesita" algunos extras
class UserRepositoryImpl(private val api: UserApi) : UserRepository {

    override suspend fun getUser(id: String): Result<User> {
        Log.d("UserRepo", "getUser($id)")  // Preocupación de logging
        val cached = cache[id]             // Preocupación de caché
        if (cached != null) return Result.success(cached)

        return try {
            val user = api.getUser(id)
            cache[id] = user
            analyticsService.track("user_fetched") // Preocupación de analíticas
            Result.success(user)
        } catch (e: Exception) {
            Log.e("UserRepo", "Failed", e)
            Result.failure(e)
        }
    }
}

Tres preocupaciones diferentes en una función. Agrega encabezados de auth, agrega retry, agrega métricas — la clase sigue creciendo. Testear cualquier preocupación significa testear todas.

Decorator: un wrapper, una preocupación

// Implementación núcleo — solo obtiene usuarios
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 — agrega logging estructurado
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 — agrega caché en memoria con TTL
class CachingUserRepository(
    private val delegate: UserRepository,
    private val ttlMs: Long = 300_000 // 5 minutos
) : 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 — rastrea conteos de llamadas y latencia
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)
}

Componiendo Decorators

// Hilt conecta la cadena completa de decorators
@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) }
}

El orden importa. En esta cadena:

  1. MetricsUserRepository recibe la llamada → inicia el timer
  2. CachingUserRepository verifica la caché → cache miss → delega
  3. LoggingUserRepository loguea la llamada → delega
  4. UserRepositoryImpl golpea la red

Un cache hit omite los pasos 3 y 4. Las métricas siempre se disparan.

Decorator para interceptores OkHttp

El patrón ya está integrado en OkHttp — cada Interceptor es un Decorator:

val client = OkHttpClient.Builder()
    .addInterceptor(AuthInterceptor(tokenProvider))      // Agrega headers de auth
    .addInterceptor(LoggingInterceptor())                // Loguea request/response
    .addInterceptor(RetryInterceptor(maxRetries = 3))    // Reintenta en 5xx
    .addNetworkInterceptor(CacheInterceptor())           // Headers Cache-Control
    .build()

Cada interceptor envuelve la Chain — el mismo patrón, aplicado a HTTP.

Testeando cada Decorator de forma aislada

@Test
fun `caching repository devuelve valor cacheado en segunda llamada`() = 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)  // Segunda llamada usó la caché
}

@Test
fun `logging decorator loguea en fallo`() = runTest {
    val fakeDelegate = object : UserRepository {
        override suspend fun getUser(id: String) =
            Result.failure<User>(IOException("Error de red"))
        override suspend fun saveUser(user: User) = Result.success(Unit)
    }

    // Sin aserciones sobre Log — verificamos que no crashea y propaga el fallo
    val repo = LoggingUserRepository(fakeDelegate)
    val result = repo.getUser("user_1")
    assertThat(result.isFailure).isTrue()
}

Strategy + Decorator en la misma arquitectura

Resuelven problemas diferentes pero se componen naturalmente:

ViewModel
   │
   └── PricingEngine(strategy: PricingStrategy)     ← Strategy: ¿qué algoritmo?
   │
   └── UserRepository (cadena de decorators)        ← Decorator: ¿qué envuelve al algoritmo?
         ├── MetricsUserRepository
         ├── CachingUserRepository
         ├── LoggingUserRepository
         └── UserRepositoryImpl

Strategy responde: ¿cuál algoritmo corre? Decorator responde: ¿qué envuelve al algoritmo?


Cerrando la serie

Ahora tienes el toolkit completo:

PatrónQué resuelveInsight clave
ObserverUI desactualizadaViewModel emite estado; Fragment/Composable reacciona
StateEstados imposibles de UISealed class = estados válidos forzados por el compilador
ProxyLlamadas repetidas a la redRepository = cache gate transparente
FacadeViewModels gordosAPI de feature = una llamada oculta N servicios
AdapterLock-in de SDKTu interfaz, su implementación
FactoryClases no testeablesMódulo DI = fake o real inyectable
StrategyRamas de algoritmo hardcodeadasInyecta el algoritmo, cambia en runtime
DecoratorPreocupaciones transversales en clases núcleoEnvuelve la interfaz, agrega un comportamiento

Las cuatro reglas de Observer & State se mantienen en los 8:

→ Cada SDK obtiene un Adapter
→ Repository siempre = Proxy (cache gate)
→ Un sealed UiState por pantalla
→ Facade por feature — ViewModels delgados

Agrega Strategy y Decorator y tienes el cuadro completo:

→ Cada algoritmo variable en runtime = Strategy
→ Cada preocupación transversal = Decorator

Estas no son abstracciones. Son las decisiones que marcan la diferencia entre un codebase que escala y uno que se convierte en una conversación de reescritura.

Clasificado en

  • Android
  • Kotlin
  • Arquitectura
Volver a8 patrones GoF que deciden si tu app Android escala