wandres.dev
MVI A MANO · sin librería

Una clase base genérica: extraer el patrón sin encadenarse a él

Escrito el esqueleto dos veces, la tercera pide abstracción: una clase base parametrizada por estado, intent y efecto que centralice el flujo mutable, el canal y el despacho. Esta lección construye ese BaseViewModel con precisión sobre los parámetros de tipo y sus cotas, discute qué merece vivir en la base y qué debe quedarse fuera, y expone con honestidad el coste de la herencia: la rigidez del molde, la tentación de acumular utilidades y la alternativa por composición que casi siempre envejece mejor.

⏱ 20 min

Hay un momento predecible en todo proyecto que implementa MVI a mano: la tercera pantalla. Las dos primeras se escriben con gusto, porque el patrón todavía se está aprendiendo y repetirlo enseña. En la tercera, copiar por tercera vez el mismo flujo privado, el mismo canal, la misma proyección y el mismo despacho deja de ser pedagógico y empieza a ser deuda, porque cada copia es un sitio donde el próximo cambio habrá que aplicar de nuevo y donde alguien podrá olvidarse. La respuesta natural es extraer el patrón a una clase base parametrizada por tres tipos. Es una buena respuesta y también una encrucijada: la clase base que hoy elimina duplicación es la misma que, si se deja crecer sin criterio, dentro de un año será el fichero que nadie se atreve a tocar porque de él cuelgan cuarenta pantallas.

🎯 Al terminar esta lección sabrás
  • Diseñar una clase base parametrizada por estado, intent y efecto con cotas de tipo justificadas.
  • Decidir qué responsabilidades pertenecen a la base y cuáles deben permanecer en cada pantalla.
  • Reconocer los síntomas de una clase base que ha empezado a acumular utilidades ajenas al patrón.
  • Comparar la extracción por herencia con la equivalente por composición y elegir con criterio.

Tres parámetros de tipo y ni uno más

La abstracción correcta tiene exactamente tres huecos, uno por cada vértice del triángulo: el estado que la pantalla publica, el vocabulario de intenciones que acepta y el vocabulario de efectos que produce. Cualquier parámetro adicional es casi siempre una responsabilidad que se ha colado.

abstract class BaseViewModel<S : Any, I : Any, E : Any>(
    estadoInicial: S,
) : ViewModel() {

    private val _estado = MutableStateFlow(estadoInicial)
    val estado: StateFlow<S> = _estado.asStateFlow()

    private val _efectos = Channel<E>(capacity = Channel.BUFFERED)
    val efectos: Flow<E> = _efectos.receiveAsFlow()

    protected val actual: S get() = _estado.value

    abstract fun procesar(intent: I)

    protected fun reducir(bloque: (S) -> S) = _estado.update(bloque)

    protected fun emitir(efecto: E) {
        _efectos.trySend(efecto).getOrElse { log.warn("efecto perdido: $efecto") }
    }

    override fun onCleared() {
        _efectos.close()
        super.onCleared()
    }
}

La cota superior en los tres parámetros no es decorativa. Sin ella, Kotlin admitiría tipos anulables, y un estado que pueda ser nulo destruye la garantía más valiosa del StateFlow: que siempre hay un valor renderizable. Restringir a un tipo no nulo cierra esa puerta en la definición y no en cada uso.

Los modificadores de visibilidad hacen aquí un trabajo silencioso pero decisivo. Las funciones de reducción y emisión son visibles para las subclases y no para el exterior, con lo que la única escritura posible del estado ocurre dentro de una pantalla concreta; el acceso al valor actual también es protegido, porque una interfaz que consulte el valor puntualmente en vez de observarlo estaría leyendo una foto que puede quedar obsoleta entre la lectura y el uso.

flowchart TD
B[BaseViewModel generico con estado intent y efecto] --> C[CarritoViewModel]
B --> S[BusquedaViewModel]
B --> P[PerfilViewModel]
B -.-> R[Aporta flujo privado canal y despacho]
C -.-> D[Aporta solo sus transiciones]
style B fill:#f9e2af,color:#11111b
style D fill:#a6e3a1,color:#11111b

Una pantalla concreta queda entonces reducida a lo que realmente le es propio: su tipo de estado, su vocabulario y sus transiciones. Todo lo demás desaparece del fichero.

class CarritoViewModel(
    private val repo: CarritoRepo,
) : BaseViewModel<CarritoState, CarritoIntent, CarritoEfecto>(CarritoState()) {

    override fun procesar(intent: CarritoIntent) = when (intent) {
        CarritoIntent.Cargar -> cargar()
        is CarritoIntent.Borrar -> borrar(intent.id)
    }

    private fun cargar() = viewModelScope.launch {
        reducir { it.copy(cargando = true) }
        val lineas = repo.lineas()
        reducir { it.copy(cargando = false, lineas = lineas) }
    }
}
💡
La firma de la subclase ya es documentacion

La línea de declaración de la subclase enuncia el contrato completo de la pantalla: qué publica, qué acepta y qué provoca. Alguien que abra el fichero por primera vez sabe en esa única línea todo lo que necesita para orientarse, sin leer una sola función. Esa legibilidad no es un accidente de la genericidad: es la razón principal por la que merece la pena parametrizar los tres tipos en vez de dejar alguno implícito o usar un tipo común para todas las pantallas.

Qué merece vivir en la base

La pregunta difícil no es cómo escribir la clase base, sino dónde detenerla. La regla que mejor resiste el paso del tiempo es que la base solo debe contener aquello que es idéntico para todas las pantallas y que forma parte del patrón, no del dominio ni de la conveniencia.

Pertenece a la base

El flujo de estado, el canal de efectos, sus proyecciones públicas, el acceso protegido a la reducción y el cierre en la destrucción.

⚠️

Discutible

Un manejador de errores común, un despachador inyectable o una bandera de carga genérica: útiles, pero acoplan la base al dominio.

No pertenece

Acceso a repositorios, analítica, navegación, formateo de textos o cualquier cosa que solo necesiten algunas pantallas.

La columna del medio es donde se pierden los proyectos. Un manejador de errores común parece inofensivo y resuelve un caso real, pero exige que todos los estados tengan un campo de error, lo cual obliga a introducir una interfaz que todos los estados implementen, lo cual introduce un cuarto parámetro implícito y una jerarquía paralela. Al cabo de un año la clase base tiene doscientas líneas, seis responsabilidades y una subclase que hace una de ellas de forma distinta mediante una propiedad abierta que nadie recuerda por qué existe.

El síntoma diagnóstico es sencillo y conviene vigilarlo en cada revisión: si un cambio en la clase base obliga a mirar qué pantallas se ven afectadas antes de aceptarlo, la base ha dejado de ser un patrón compartido y se ha convertido en un acoplamiento compartido.

⚠️
La herencia solo se gasta una vez

Kotlin no tiene herencia múltiple de clases, así que heredar de tu clase base consume el único cupo disponible. Si mañana necesitas que una pantalla herede de otra base —una del equipo de diseño, una impuesta por una librería de terceros, una para pantallas con paginación— tendrás que elegir. Ese coste es invisible el día que escribes la base y decisivo el día que aparece el conflicto, y es la razón principal por la que muchos equipos experimentados prefieren la vía de composición aunque escriban una línea más por pantalla.

La alternativa por composición

La misma extracción admite una forma que no gasta la herencia: un objeto contenedor que encapsula el estado, el canal y sus proyecciones, y que la pantalla posee en vez de heredar. Es, esencialmente, lo que hacen las librerías serias del ecosistema, y no por casualidad.

class Contenedor<S : Any, E : Any>(
    estadoInicial: S,
    private val alcance: CoroutineScope,
) {
    private val _estado = MutableStateFlow(estadoInicial)
    val estado: StateFlow<S> = _estado.asStateFlow()

    private val _efectos = Channel<E>(capacity = Channel.BUFFERED)
    val efectos: Flow<E> = _efectos.receiveAsFlow()

    fun reducir(bloque: (S) -> S) = _estado.update(bloque)
    fun emitir(efecto: E) { _efectos.trySend(efecto) }
    fun lanzar(bloque: suspend () -> Unit) = alcance.launch { bloque() }
}

La pantalla declara entonces una propiedad de este tipo y delega en ella. Se gana ortogonalidad —la clase puede heredar de lo que quiera—, se gana testabilidad —el contenedor se prueba aislado, sin instanciar ninguna pantalla— y se gana la posibilidad de tener dos contenedores en un mismo componente cuando el caso lo justifica. Se pierde una pizca de concisión: hay que escribir el prefijo del contenedor en cada llamada, aunque una función delegada o una interfaz con implementación por delegación recupera casi toda la brevedad.

Toda clase base es una prediccion sobre el futuro

Extraer una clase base parece un acto técnico y es sobre todo un acto de pronóstico: estás afirmando que todas las pantallas presentes y futuras compartirán exactamente esta forma, y estás cobrándote por adelantado el beneficio de esa afirmación. Cuando aciertas, el ahorro es real y se acumula pantalla a pantalla. Cuando fallas —y se falla más de lo que se admite, porque las pantallas que rompen el molde llegan siempre y llegan tarde— el coste no es volver atrás sino algo peor: la base se llena de propiedades abiertas, banderas y casos especiales que documentan, uno a uno, los sitios donde el pronóstico no se cumplió, y cada uno de esos parches hace más difícil el siguiente. Por eso la heurística de las tres repeticiones es tan resistente: no es superstición sobre un número, es la observación de que hacen falta tres ejemplos para distinguir lo que es esencial al patrón de lo que era accidental en los dos primeros. Y por eso la composición envejece mejor que la herencia en este terreno concreto: cuando el pronóstico falla, un objeto que posees se sustituye o se ignora en la pantalla que no encaja, mientras que una superclase que ya está en la firma de cuarenta ficheros solo se puede parchear. La pregunta que debes hacerte antes de escribir abstract class no es si el patrón se repite —evidentemente se repite— sino qué harás el día que una pantalla no lo cumpla, y si esa respuesta te incomoda, la respuesta correcta hoy era componer.

⚔️ Extrae, mide y cuestiona tu abstracción
  1. Escribe tres pantallas sin abstracción, señala línea por línea qué se repite exactamente y solo entonces extrae la base.
  2. Implementa la versión por herencia y la versión por composición de la misma pantalla, y compara líneas escritas y dependencias arrastradas al test.
  3. Añade a la base un manejador de errores común y documenta cuántos ficheros ha habido que tocar y qué has tenido que asumir sobre todos los estados.
  4. Inventa una pantalla que rompa el molde, por ejemplo una con dos estados independientes, e implementa la solución en ambas variantes.
  5. Escribe la regla explícita que tu equipo aplicará para decidir qué entra en la base, y sitúala en el propio fichero como comentario.