Parte 4 de 8 Patrones GoF que Deciden si tu App Android Escala
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 Firebaseclass 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ódigointerface AnalyticsTracker { fun track(event: String, params: Map<String, Any> = emptyMap()) fun setUserId(id: String) fun reset()}// Paso 2: Firebase Adapter — envuelve el SDKclass 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 diferenteclass 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 correctoclass 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áneamenteclass 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() }}
// Usoval 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() }}
@Testfun `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 realclass 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 llamaninterface 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ónclass 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ísticoclass 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])@Moduleabstract class FakeRepositoryModule {
@Binds @Singleton abstract fun bindUserRepository(fake: FakeUserRepository): UserRepository}Patrón Factory Inline (Sin Framework de DI)
// Factory simple cuando Hilt es exageradoobject 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 entornoobject 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.
Siguiente
Parte 5: Strategy & Decorator → — cómo hacer A/B test en runtime sin reescrituras, y cómo extender comportamiento sin tocar jamás la lógica núcleo.