<CarlosDev/>

Actual

8 patrones GoF que deciden si tu app Android escala

Ocho patrones del Gang of Four que deciden si una app Android escala — el patrón, el problema que resuelve y el Kotlin que lo hace funcionar.

Publicado
Actualizado
Lectura
24 min
Redacción
Estable
Ocho patrones del Gang of Four mapeados a preocupaciones de arquitectura Android.
En este registro8 secciones

La mayoría de las apps móviles no fracasan por malas funcionalidades. Fracasan por mala arquitectura.

Después de 7+ años publicando apps Android — desde proyectos personales hasta codebases de equipo que incorporan nuevos devs cada trimestre — siempre vuelvo a los mismos 8 patrones del libro del Gang of Four. No porque sean académicos, sino porque resuelven problemas reales y recurrentes a escala en producción.

Esta es una serie práctica. Sin teoría por sí misma — solo el patrón, el problema Android que resuelve, y el código Kotlin que lo hace funcionar.


Los 8 patrones de un vistazo

PatrónQué resuelveContexto Android
ObserverUI que nunca queda desactualizadaViewModel + StateFlow
StateEstados imposibles, imposibles de representarSealed class UiState
ProxyCache + retry, invisible para quien llamaCapa Repository
FacadeUna llamada oculta 5 casos de usoAPI de feature para ViewModels
AdapterCambiar cualquier SDK en un archivoSDKs de analíticas, pagos
FactoryFuentes mockeables desde el día unoInyección de dependencias
StrategyA/B test en runtime, sin reescriturasFeature flags
DecoratorAgregar comportamientos sin tocar el núcleoLogging, auth, caché

Las 4 reglas que aplico en cada proyecto

No son pautas — son restricciones que previenen los errores arquitectónicos más comunes:

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

Romper cualquiera está bien en un prototipo. En producción, cada una eventualmente tiene un costo.


Los números a escala

Cuando estos patrones se aplican consistentemente en un codebase:

  • 3× más rápido el onboarding del equipo — los nuevos devs encuentran estructura predecible en todas partes
  • 70% menos boilerplate — los patrones eliminan la toma de decisiones repetitiva
  • 0 vendor lock-in con Adapter — he cambiado SDKs de analíticas en una tarde
  • 10× más rápidas las pruebas unitarias — Factory + Adapter significa sin red real, sin disco real

Cómo está organizado

Lo que sigue toma los patrones de a dos, porque así es como aparecen en la práctica — cada par cubre la relación entre ellos, el código, y cómo interactúan. Observer y State resuelven la reactividad de la UI y los estados de pantalla forzados por el compilador. Proxy y Facade resuelven los cache gates y los ViewModels delgados. Adapter y Factory resuelven la independencia de SDKs y las fuentes testeables. Strategy y Decorator resuelven el comportamiento en runtime y las extensiones aditivas.


La arquitectura es la decisión que tomas a las 9am que salva a tu equipo a las 2am.

Parte 1 de 4

4 min

Observer & State: UI que nunca miente

Observer mantiene la UI sincronizada con los datos automáticamente; State elimina los estados imposibles de pantalla en tiempo de compilación.

Leer por separado: Observer & State: UI que nunca miente

Observer — UI que nunca queda desactualizada

El patrón Observer define una dependencia uno-a-muchos: cuando un objeto (el sujeto) cambia de estado, todos sus observadores son notificados automáticamente.

En Android esto antes significaba llamar manualmente notifyDataSetChanged(), alternar visibilidad en callbacks, y debuggear por qué la UI mostraba datos viejos después de una rotación. StateFlow resolvió todo eso.

El problema sin Observer

// Enfoque viejo — sincronización manual, garantizada a desviarse
class LoginActivity : AppCompatActivity() {

    private fun doLogin() {
        showLoading()
        api.login(email, password) { result ->
            hideLoading()
            if (result.isSuccess) {
                navigateToHome()
            } else {
                showError(result.error)
                // ¿Ocultamos el loading? ¿Reactivamos el botón?
                // ¿Y la rotación? ¿Los cambios de configuración? 🤯
            }
        }
    }
}

Cada callback agrega una nueva rama. La rotación destruye la Activity a mitad del vuelo. Los estados de error se olvidan.

Observer con StateFlow

// ViewModel es el sujeto — emite cambios de estado
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 es el observador — reacciona a cada emisión
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)
            }
        }
    }
}

El Fragment nunca llama a la API. El ViewModel nunca toca las Views. Rotación, back stack, muerte del proceso — todo manejado por el collector lifecycle-aware.

Con 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)
    }
}

Una expresión when. Cero alternancia manual de Views.


State — el compilador como tu red de seguridad

El patrón State permite que un objeto altere su comportamiento cuando su estado interno cambia. En términos Android: cada pantalla tiene un conjunto finito de estados, y debes hacer que los estados imposibles sean imposibles a nivel de tipos.

El problema: sopa de booleanos

// ¿Cuáles combinaciones son válidas? Nadie lo sabe.
class LoginViewModel : ViewModel() {
    val isLoading = MutableLiveData<Boolean>()
    val isError = MutableLiveData<Boolean>()
    val errorMessage = MutableLiveData<String?>()
    val isSuccess = MutableLiveData<Boolean>()
    val user = MutableLiveData<User?>()

    // ¿Pueden isLoading e isError ser true al mismo tiempo? ¿Puede isSuccess ser true con un user null?
    // El compilador no tiene idea. Los crashes en runtime te lo harán saber.
}

Con 4 booleanos tienes 16 combinaciones teóricas. Quizás 3 son válidas. El compilador no te dirá cuáles.

Sealed class UiState

// Cada estado válido es un tipo. Cada estado inválido es imposible.
sealed class LoginUiState {
    object Idle    : LoginUiState()
    object Loading : LoginUiState()
    data class Success(val user: User)      : LoginUiState()
    data class Error(val message: String?)  : LoginUiState()
}

Por qué importa:

// El compilador fuerza el manejo exhaustivo
when (state) {
    LoginUiState.Idle    -> ...
    LoginUiState.Loading -> ...
    is LoginUiState.Success -> ...  // El compilador sabe que state.user no es null
    is LoginUiState.Error   -> ...  // El compilador sabe que state.message existe
    // Olvidar una rama → error de compilación, no un crash en runtime
}

No puedes acceder a state.user cuando el estado es Loading. No puedes tener un Success con un user null. El sistema de tipos previene el bug antes de que el test siquiera se ejecute.

Ejemplo real de UiState

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()

    // Agrega estados a medida que la pantalla crece — los bloques when() existentes
    // producirán errores de compilación si olvidas manejar el nuevo estado
    object OutOfRegion : ProductDetailUiState()
}

Un sealed class por pantalla. Un collect en el Fragment o un when en el composable.


Observer + State juntos

El poder real viene de combinarlos:

// ViewModel combina ambos patrones
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()  // Observer
//                     ^^^^^^^^^^^^^
//                     Sealed class fuerza estados válidos        // State

Observer asegura que la UI siempre refleje el último estado. State asegura que ese último estado siempre sea válido.

Resultado: UI que nunca miente.

La reactividad resuelve lo que la pantalla muestra. Lo que está detrás es el siguiente par — un cache gate que permanece invisible para quien lo llama, y la disciplina que mantiene delgados a los ViewModels.

Parte 2 de 4

5 min

Proxy & Facade: cache gates y ViewModels delgados

Proxy agrega caché y reintentos invisibles para quien llama; Facade oculta un flujo de varios servicios detrás de una sola llamada para mantener delgados los ViewModels.

Leer por separado: Proxy & Facade: cache gates y ViewModels delgados

Proxy — cache + retry, invisible para quien llama

El patrón Proxy provee un sustituto para otro objeto, controlando el acceso a él. En Android: tu Repository es un Proxy. El ViewModel pide datos; el Repository decide si devolverlos desde memoria, disco o la red.

Quien llama nunca lo sabe.

Sin Proxy — ViewModels haciendo demasiado

// ViewModel no debería saber sobre caché, reintentos, ni estado de red
class UserProfileViewModel(private val api: UserApi) : ViewModel() {

    fun loadUser(id: String) {
        viewModelScope.launch {
            // ¿Lógica de caché en el ViewModel? ¿Lógica de retry? 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) {
                // ¿Reintentamos? ¿Cuántas veces? El ViewModel no debería decidir.
                _uiState.value = UiState.Error(e.message)
            }
        }
    }
}

Este ViewModel conoce sobre caché, política de reintentos y errores de red. Cambia cualquier detalle de infraestructura y estarás editando lógica de negocio.

Proxy: Repository como 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. Cache hit — retorno instantáneo
            cache.get(id)?.let { return@withContext Result.success(it) }

            // 2. Red con retry — quien llama nunca ve esto
            retry(times = 3, delayMs = 1000) {
                api.getUser(id)
            }.also { result ->
                result.getOrNull()?.let { cache.put(id, it, ttlMs = 300_000) }
            }
        }
}
// Helper de retry — función pura, fácil de testear
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))  // Backoff exponencial
    }
    return runCatching { block() }
}
// ViewModel ahora está limpio
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) }
        }
    }
}

El ViewModel no tiene idea de caché, reintentos ni estado de red. Cambia UserRepositoryImpl por una versión offline-first respaldada por Room — el ViewModel no cambia nada.

Proxy offline-first con Room + red

class UserRepositoryImpl(
    private val api: UserApi,
    private val dao: UserDao
) : UserRepository {

    override suspend fun getUser(id: String): Result<User> =
        withContext(Dispatchers.IO) {
            runCatching {
                // Primero intenta BD local
                dao.getUser(id)?.let { return@runCatching it }

                // Golpea la red, persiste el resultado
                api.getUser(id).also { dao.upsert(it) }
            }
        }

    // Room Flow — se actualiza automáticamente cuando cambia la BD
    fun observeUser(id: String): Flow<User?> = dao.observeUser(id)
}

Facade — una llamada oculta cinco casos de uso

El patrón Facade provee una interfaz simplificada a un subsistema complejo. En Android: tu API de feature es una Facade. El ViewModel llama un método; la Facade coordina múltiples servicios por debajo.

El problema: ViewModels gordos

// ViewModel orquestando todo — un anti-patrón común
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) { /* manejar */ return@launch }
            val result = paymentService.charge(cart.total, userId)
            analyticsService.track("checkout_completed", mapOf("total" to cart.total))
            notificationService.sendConfirmation(userId)
            // ... y sigue creciendo
        }
    }
}

Este ViewModel tiene 5 dependencias y conoce toda la coreografía del checkout. Cada nueva regla de negocio agrega una línea aquí.

Facade: API de feature

// La Facade — una interfaz, una responsabilidad
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)) {
                "Productos no disponibles"
            }

            val receipt = paymentService.charge(cart.total, userId)

            // Efectos secundarios fire-and-forget
            coroutineScope {
                launch { analyticsService.track("checkout_completed", cart.toMap()) }
                launch { notificationService.sendConfirmation(userId, receipt) }
            }

            receipt
        }
}
// ViewModel tiene UNA dependencia — se mantiene delgado
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) }
        }
    }
}

¿Nueva regla de negocio (puntos de fidelidad, verificación de fraude, tracking de referidos)? Agrégala a CheckoutFacadeImpl. El ViewModel nunca cambia.

Testeando la Facade

@Test
fun `checkout exitoso actualiza estado a 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)
}

Fake de una línea, sin framework de mocking. Ese es el beneficio.


Proxy + Facade en la misma arquitectura

ViewModel
   │
   └── CheckoutFacade          ← Facade: oculta complejidad
         │
         ├── CartRepository    ← Proxy: cache gate
         ├── PaymentService
         ├── InventoryService  ← Proxy: cache gate
         └── AnalyticsService

El ViewModel habla con la Facade. La Facade orquesta Proxies. Ninguna capa conoce los detalles de implementación de la otra.

Eso mantiene honestas a las capas por dentro. Mantenerlas honestas en el borde — cambiar cualquier SDK en un solo archivo, construir fuentes mockeables desde el día uno — es para lo que sirve el siguiente par.

Parte 3 de 4

5 min

Adapter & Factory: cero lock-in de SDK y tests 10× más rápidos

Adapter envuelve cualquier SDK de terceros detrás de tu propia interfaz; Factory crea esas implementaciones de forma mockeable desde el día uno.

Leer por separado: Adapter & Factory: cero lock-in de SDK y tests 10× más rápidos

Adapter — cambia cualquier SDK en un archivo

El patrón Adapter convierte la interfaz de una clase en otra interfaz que los clientes esperan. En Android: cada SDK de terceros se envuelve detrás de tu propia interfaz. Tu lógica de negocio depende de tu interfaz. El Adapter conecta tu interfaz con el SDK.

Cuando el SDK cambia — precio, API, deprecación — cambias un archivo.

El problema: dependencia directa del SDK

// Lógica de negocio acoplada directamente a 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)
    }
}

Ahora tu product manager quiere hacer A/B test entre Firebase y Amplitude. O el SDK sube los precios. O quieres testear EventTracker sin iniciar Firebase. Nada de esto es fácil con una dependencia directa.

Adapter: tu interfaz, su implementación

// Paso 1: Define TU interfaz — el contrato del que depende tu código
interface AnalyticsTracker {
    fun track(event: String, params: Map<String, Any> = emptyMap())
    fun setUserId(id: String)
    fun reset()
}
// Paso 2: Firebase Adapter — envuelve el 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)
}
// Paso 3: Amplitude Adapter — misma interfaz, SDK diferente
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()
}
// La lógica de negocio nunca cambia — solo inyecta el adapter correcto
class PurchaseUseCase(private val tracker: AnalyticsTracker) {

    suspend fun purchase(item: CartItem, userId: String): Result<Receipt> {
        // ... lógica de pago ...
        tracker.track("purchase", mapOf(
            "product_id" to item.id,
            "price" to item.price
        ))
        return Result.success(receipt)
    }
}

Cambia SDKs cambiando el adapter inyectado. PurchaseUseCase no sabe qué SDK de analíticas corre por debajo.

Adapter multi-destino

// Envía eventos a múltiples SDKs simultáneamente
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() }
}

// Uso
val tracker = CompositeAnalyticsTracker(listOf(
    FirebaseAnalyticsAdapter(firebase),
    AmplitudeAdapter(amplitude),
))

Testeando con un 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 registra evento de analíticas`() = 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")
}

Sin instancia de Firebase. Sin contexto Android. El test corre en milisegundos.


Factory — fuentes mockeables desde el día uno

El patrón Factory define una interfaz para crear objetos, dejando que las subclases o quien llama decida qué clase instanciar. En Android: las Factories crean tus implementaciones de Repository y servicio, haciéndolas intercambiables entre entornos de producción y test.

El problema: dependencias hardcodeadas

// Imposible de testear — red real, base de datos real
class UserViewModel : ViewModel() {
    // La construcción ocurre dentro de la clase — no hay forma de inyectar fakes
    private val api = RetrofitFactory.create(UserApi::class.java)
    private val dao = AppDatabase.getInstance(context).userDao()
    private val repo = UserRepositoryImpl(api, dao)
}

No puedes ejecutar un test unitario para este ViewModel sin una base de datos real y un servidor real.

Factory: separa la creación del uso

// La interfaz — de lo que dependen quienes llaman
interface UserRepository {
    suspend fun getUser(id: String): Result<User>
    suspend fun saveUser(user: User): Result<Unit>
    fun observeUser(id: String): Flow<User?>
}
// Implementación de producción
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)
}
// Fake para tests — en memoria, instantáneo, determinístico
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 }
}
// Módulo Hilt conecta la implementación correcta por entorno
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}

// Módulo de test lo sobreescribe
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [RepositoryModule::class]
)
@Module
abstract class FakeRepositoryModule {

    @Binds
    @Singleton
    abstract fun bindUserRepository(fake: FakeUserRepository): UserRepository
}

Patrón Factory inline (sin framework de DI)

// Factory simple cuando Hilt es exagerado
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 en la práctica

// Factory crea el Adapter correcto para el entorno
object AnalyticsFactory {

    fun create(context: Context): AnalyticsTracker =
        when {
            BuildConfig.DEBUG -> NoOpAnalyticsTracker()     // Sin eventos en debug
            isQABuild()       -> LoggingAnalyticsTracker()  // Loguea en consola
            else -> CompositeAnalyticsTracker(listOf(
                FirebaseAnalyticsAdapter(FirebaseAnalytics.getInstance(context)),
                AmplitudeAdapter(Amplitude.getInstance().also {
                    it.initialize(context, BuildConfig.AMPLITUDE_KEY)
                })
            ))
        }
}

La Factory decide cuál Adapter crear. El Adapter decide cómo llamar al SDK. Tu lógica de negocio nunca toca ninguna de las dos decisiones.


La afirmación de tests 10× más rápidos

Es conservadora. Un test de ViewModel con:

  • Retrofit real: ~3-8 segundos (timeout de red, parseo de respuesta)
  • Repository fake: ~30-80ms (búsqueda en mapa en memoria)

En un codebase con 200 tests de ViewModel, esa es la diferencia entre una CI de 30 minutos y una de 3 minutos.

Esos tres pares resuelven la estructura. El último resuelve el comportamiento: hacer A/B test en runtime sin reescrituras, y extender lo que una clase hace sin tocar jamás su lógica núcleo.

Parte 4 de 4

8 min

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.

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

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