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ón | Qué resuelve | Contexto Android |
|---|---|---|
| Observer | UI que nunca queda desactualizada | ViewModel + StateFlow |
| State | Estados imposibles, imposibles de representar | Sealed class UiState |
| Proxy | Cache + retry, invisible para quien llama | Capa Repository |
| Facade | Una llamada oculta 5 casos de uso | API de feature para ViewModels |
| Adapter | Cambiar cualquier SDK en un archivo | SDKs de analíticas, pagos |
| Factory | Fuentes mockeables desde el día uno | Inyección de dependencias |
| Strategy | A/B test en runtime, sin reescrituras | Feature flags |
| Decorator | Agregar comportamientos sin tocar el núcleo | Logging, 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.
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 mienteObserver — 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.
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 delgadosProxy — 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.
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ápidosAdapter — 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.
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 aditivasStrategy — 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:
MetricsUserRepositoryrecibe la llamada → inicia el timerCachingUserRepositoryverifica la caché → cache miss → delegaLoggingUserRepositoryloguea la llamada → delegaUserRepositoryImplgolpea 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ón | Qué resuelve | Insight clave |
|---|---|---|
| Observer | UI desactualizada | ViewModel emite estado; Fragment/Composable reacciona |
| State | Estados imposibles de UI | Sealed class = estados válidos forzados por el compilador |
| Proxy | Llamadas repetidas a la red | Repository = cache gate transparente |
| Facade | ViewModels gordos | API de feature = una llamada oculta N servicios |
| Adapter | Lock-in de SDK | Tu interfaz, su implementación |
| Factory | Clases no testeables | Módulo DI = fake o real inyectable |
| Strategy | Ramas de algoritmo hardcodeadas | Inyecta el algoritmo, cambia en runtime |
| Decorator | Preocupaciones transversales en clases núcleo | Envuelve 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.