wandres.dev
MULTIPLATAFORMA · expect y actual

La alternativa: interfaces en común e implementaciones inyectadas

La mayoría de las bibliotecas multiplataforma serias apenas usan declaraciones esperadas: declaran interfaces en el código común y dejan que cada plataforma aporte su implementación, que entra en el sistema por el constructor. Esta lección explica por qué esa inversión funciona mejor, qué propiedades gana el diseño al sustituir emparejamiento en compilación por polimorfismo en ejecución, y cuál es el patrón mínimo de una sola costura que combina ambos mecanismos sin duplicarlos.

⏱ 22 min

Si abres el código de las bibliotecas multiplataforma más usadas del ecosistema y buscas declaraciones esperadas, encontrarás muchas menos de las que su tamaño haría suponer, y casi todas escondidas en el fondo, en funciones de tres líneas que llaman a una interfaz del sistema. Lo que verás en su lugar, ocupando la superficie que consume quien las usa, son interfaces corrientes declaradas en el código común e implementaciones por plataforma que se entregan al construir el objeto. Esa preferencia no es cuestión de gusto ni de purismo orientado a objetos: es la consecuencia de que el emparejamiento de una declaración esperada ocurre en tiempo de compilación y produce una sola realización, mientras que una interfaz produce un tipo que existe en el programa terminado y admite tantas implementaciones como haga falta. Cambiar de mecanismo cambia el momento en que se decide qué se ejecuta, y de ese cambio de momento se derivan la testabilidad, la extensibilidad por parte de terceros y la capacidad de sobrevivir a la aparición de un objetivo nuevo sin publicar una versión.

🎯 Al terminar esta lección sabrás
  • Reescribir una frontera de plataforma como interfaz común más implementaciones inyectadas, y explicar qué cambia respecto a la versión con declaración esperada.
  • Justificar por qué las bibliotecas publicadas prefieren interfaces, en términos de mecanismo abierto frente a mecanismo cerrado.
  • Reconocer el patrón de una sola costura y aplicarlo para no quedarse sin ningún punto de arranque.
  • Elegir entre ambos mecanismos con un criterio explícito basado en estado, sustitución y quién debe poder extender.

La misma frontera, resuelta con un tipo

Partamos de una frontera cualquiera: el código común necesita guardar y leer un valor pequeño de forma persistente. La versión con declaración esperada nombra una clase y confía en que cada objetivo la realice. La versión con interfaz nombra un contrato y no dice nada sobre quién lo cumple.

// commonMain
interface AlmacenDeClaves {
    suspend fun guardar(clave: String, valor: String)
    suspend fun leer(clave: String): String?
}

class ServicioDeSesion(private val almacen: AlmacenDeClaves) {
    suspend fun iniciar(token: String) = almacen.guardar("token", token)
}
// androidMain
class AlmacenPreferencias(private val prefs: SharedPreferences) : AlmacenDeClaves {
    override suspend fun guardar(clave: String, valor: String) { /* ... */ }
    override suspend fun leer(clave: String): String? = prefs.getString(clave, null)
}

Lo que ha cambiado no es la cantidad de código sino su topología. El código común ya no contiene ningún agujero que el compilador tenga que rellenar en cada objetivo; contiene una dependencia declarada en el constructor. La clase ServicioDeSesion se puede instanciar en una prueba con una implementación en memoria escrita en commonTest, y esa prueba se ejecuta en todos los objetivos sin necesidad de un dispositivo ni de un simulador. La divergencia semántica entre plataformas, que en el modelo anterior no tenía dónde ser detectada, ahora tiene un tipo común contra el que escribir una batería de pruebas de contrato que cada implementación debe pasar.

// commonTest: se ejecuta una vez por objetivo
abstract class PruebasDeAlmacen {
    abstract fun crear(): AlmacenDeClaves

    @Test fun loGuardadoSeLee() = runTest {
        val a = crear()
        a.guardar("k", "v")
        assertEquals("v", a.leer("k"))
    }
}

Esa clase abstracta es la pieza que el mecanismo del nivel anterior no puede ofrecer de ninguna manera, y conviene detenerse en por qué. Una prueba de contrato es una afirmación sobre todas las implementaciones de un tipo, y para escribirla hace falta que el tipo exista como entidad del programa a la que se le puedan pasar implementaciones distintas. El emparejamiento en compilación disuelve el tipo antes de que exista el programa: no queda nada a lo que pasarle nada. Con una interfaz, en cambio, cada plataforma solo tiene que aportar una subclase de tres líneas que devuelva su implementación real, y la divergencia semántica que antes solo descubría un usuario se convierte en un fallo de integración continua.

Hay además un beneficio de legibilidad que se aprecia al mantener el código y no al escribirlo. La dependencia declarada en el constructor es visible en la firma del tipo: quien lee ServicioDeSesion sabe, sin salir del archivo, que necesita un almacén y que no toca nada más del sistema. La versión con nombre global no dice nada, y averiguar qué capacidades del sistema usa una clase exige leerla entera y seguir cada llamada. En un proyecto multiplataforma esa información es especialmente valiosa, porque coincide exactamente con la lista de cosas que hay que proveer para llevar el módulo a un objetivo nuevo.

🧪

Sustituible

Una interfaz admite un doble en pruebas sin banderas ni condicionales dentro de la implementación real. El emparejamiento por compilación no lo admite en absoluto.

🌍

Abierta a terceros

Quien consume tu biblioteca puede aportar su propia implementación. Una declaración esperada publicada solo la puede realizar quien publicó la biblioteca.

🧬

Múltiple por plataforma

Nada impide tener dos implementaciones simultáneas en el mismo objetivo y elegir entre ellas en ejecución según configuración.

🧱

Ordinaria

Es Kotlin corriente. No arrastra reglas de emparejamiento, ni banderas de compilación, ni sorpresas al añadir objetivos.

Por qué las bibliotecas casi siempre eligen esto

Para una aplicación, la diferencia entre ambos mecanismos es sobre todo de comodidad en pruebas. Para una biblioteca publicada es existencial, y la razón cabe en una palabra: el mecanismo de declaraciones esperadas es cerrado. La lista de objetivos que una biblioteca soporta queda congelada en el momento de publicar, porque cada realización tiene que estar dentro del artefacto. Si mañana aparece un backend nuevo, o si un usuario quiere compilar para una arquitectura que tú no contemplaste, no hay nada que pueda hacer desde fuera: no existe forma de aportar una realización a una declaración esperada ajena. Con una interfaz, en cambio, ese usuario implementa el tipo en su propio módulo y sigue adelante sin esperar a tu siguiente versión.

El patrón que se ha impuesto en el ecosistema es reconocible en cuanto se sabe mirar, y es siempre el mismo. Una interfaz declarada en el código común describe la capacidad; una fábrica por plataforma la construye; el usuario recibe el objeto ya construido. Los clientes de red separan el contrato del motor concreto que lo ejecuta; las bibliotecas de persistencia separan la lógica generada del controlador que habla con cada motor de base de datos; las bibliotecas de entrada y salida separan la abstracción del sistema de ficheros de su realización. En todos los casos, el contrato es común y la pieza que toca el sistema entra por parámetro.

Hay una excepción coherente que confirma la regla: el propio tiempo de ejecución de las corrutinas usa declaraciones esperadas por dentro para cosas como el despachador por defecto. Es exactamente el caso legítimo del nivel anterior: una hoja, sin ciclo de vida que el usuario deba manejar, de la que solo puede haber una versión sensata por plataforma y que nadie de fuera necesitará sustituir.

Hay una segunda razón, más sutil, por la que las bibliotecas evitan exponer declaraciones esperadas, y tiene que ver con la evolución. Una interfaz publicada admite crecer con miembros que tengan implementación por defecto, de modo que añadir una capacidad no rompe a quien ya la implementaba. Una clase esperada no admite ese movimiento: cualquier miembro nuevo obliga a que todas las realizaciones aparezcan a la vez, y como las realizaciones viven dentro del mismo módulo, cada extensión es un cambio coordinado en tantos conjuntos de fuentes como objetivos haya. Lo que en un modelo es una adición compatible, en el otro es una obra.

// commonMain: crecer sin romper a quien ya implementaba
interface AlmacenDeClaves {
    suspend fun guardar(clave: String, valor: String)
    suspend fun leer(clave: String): String?
    suspend fun borrar(clave: String) { guardar(clave, "") }   // añadido después
}

Y hay una tercera, puramente pragmática: una interfaz es Kotlin corriente y por tanto todo lo que sabes sobre diseño de tipos sigue valiendo. Puedes componerla con delegación, decorarla para añadir registro o reintentos, restringirla con un tipo que solo exponga la parte de lectura, o combinar dos implementaciones en una que elija según configuración. Ninguna de esas maniobras, que forman el repertorio básico de cualquier diseño, está disponible sobre un nombre que el compilador resolvió antes de que existiera el programa.

ℹ️
No es purismo, es cronología

Lo único que decides al elegir entre los dos mecanismos es cuándo se fija la implementación: al compilar o al construir el objeto. Todo lo demás —pruebas, extensibilidad, objetivos futuros— se deduce de esa fecha.

El patrón mínimo: una sola costura

La objeción evidente es que alguien tiene que crear la implementación de plataforma, y ese alguien vive fuera del código común. Si todo el sistema depende de interfaces, hace falta un punto de arranque donde se decida qué implementación concreta se usa. La solución que mejor envejece es concentrar ese punto en una única declaración esperada, tan pequeña como sea posible, y construir todo lo demás con inyección ordinaria.

// commonMain
internal expect fun crearAlmacenDeClaves(): AlmacenDeClaves

fun construirServicio(): ServicioDeSesion =
    ServicioDeSesion(almacen = crearAlmacenDeClaves())

Una sola costura, marcada como interna, y a partir de ahí polimorfismo corriente. Con esa disposición el número de agujeros del proyecto crece con el número de fronteras reales y no con el número de clases, las pruebas comunes no pasan nunca por la costura porque construyen el grafo con dobles, y el día que llegue un objetivo nuevo lo único que hay que escribir es esa función.

Cabe todavía una variante que evita por completo la declaración esperada, y en una biblioteca suele ser la correcta: no crear nada dentro del código común y exigir que la implementación entre desde fuera, desde el punto de la aplicación donde ya se está compilando código específico de plataforma. Es un desplazamiento de una sola frontera, pero cambia quién manda: con la costura esperada, el módulo decide; sin ella, decide quien lo integra.

// commonMain: ninguna costura, la implementación entra desde el borde
class Aplicacion(private val almacen: AlmacenDeClaves) { /* ... */ }

Sobre el mecanismo de conexión conviene decir algo, porque genera discusiones desproporcionadas. Un contenedor de inyección de dependencias es cómodo cuando el grafo crece, y hay opciones multiplataforma maduras, pero no es un requisito del modelo: el paso por constructor desde una función de arranque cubre perfectamente proyectos de tamaño mediano y tiene la virtud de que el grafo es visible y verificado por el compilador. Lo que sí conviene evitar en todos los casos es el localizador de servicios global, porque devuelve el problema al punto de partida: vuelve a haber un nombre global que el código común consulta, la dependencia deja de estar en la firma, y se pierde exactamente la propiedad que motivaba todo el cambio.

flowchart TD
A[commonMain declara la interfaz] --> B[Codigo comun depende del tipo]
A --> C[commonTest usa un doble en memoria]
A --> D[Implementacion en androidMain]
A --> E[Implementacion en iosMain]
F[Unica costura esperada] --> D
F --> E
B --> G[Grafo construido en el borde de la aplicacion]
Elegir entre emparejamiento en compilación y polimorfismo en ejecución es elegir quién tiene derecho a completar tu sistema, y esa es una decisión política sobre tu código antes que una decisión técnica sobre tu programa

Todo mecanismo de extensión responde en silencio a una pregunta de gobierno: quién puede aportar la pieza que falta. Una declaración esperada responde que solo el autor del módulo donde vive, y lo responde con la máxima firmeza posible, porque no delega esa capacidad ni siquiera a quien compila el módulo después. Una interfaz responde que cualquiera que tenga el tipo en su ruta de clases. Ninguna de las dos respuestas es superior en abstracto, y precisamente por eso el criterio no puede ser una preferencia estilística. Hay piezas del sistema en las que la respuesta restrictiva es la correcta, porque una segunda implementación no sería una extensión sino una incoherencia: nadie necesita dos formas distintas de leer el reloj del sistema, y ofrecer un punto de extensión ahí solo crearía la posibilidad de configuraciones absurdas. Y hay piezas —almacenamiento, transporte, criptografía, registro de eventos, cualquier cosa que un integrador legítimamente quiera reemplazar por la que su empresa ya tiene auditada— donde la respuesta restrictiva convierte una biblioteca útil en un callejón sin salida, y el usuario descubre el problema demasiado tarde, cuando ya tiene el código escrito y su único recurso es bifurcar el proyecto. Lo interesante es que este razonamiento no tiene nada de multiplataforma: es la vieja distinción entre lo que se fija por construcción y lo que se deja abierto, que en otros contextos aparece como final frente a abierto, o como enumeración sellada frente a jerarquía extensible. Kotlin Multiplatform no inventa el dilema, solo lo hace inevitable, porque obliga a que cada frontera con el sistema operativo se declare explícitamente y por tanto obliga a responder la pregunta de gobierno en cada una de ellas. Un diseño maduro se reconoce porque la respuesta cambia según el caso y alguien puede explicar por qué; un diseño inmaduro se reconoce porque la respuesta es siempre la misma, y casi siempre lo es porque nunca se formuló la pregunta.

⚔️ Invierte una frontera y mide lo que ganas
  1. Elige la declaración esperada más grande de tu proyecto y reescríbela como interfaz común con implementaciones por plataforma.
  2. Escribe en el conjunto de pruebas común una implementación en memoria y una batería de pruebas de contrato que toda implementación deba pasar.
  3. Ejecuta esa batería contra las implementaciones reales de dos plataformas y anota cuántas diferencias de comportamiento aparecen. Eran divergencias que antes nadie podía ver.
  4. Reduce todas las fábricas de tu proyecto a una única costura esperada e interna, y comprueba cuántos agujeros quedaron eliminados.
  5. Para cada frontera que quede, escribe quién debe tener derecho a aportar la implementación. Si la respuesta es alguien de fuera, no puede ser una declaración esperada.