Bibliotecas del ecosistema: red, persistencia e inyección compartidas
Un módulo común solo puede contener lo que sus dependencias permitan, de modo que la viabilidad práctica de Kotlin Multiplatform se decide en el catálogo de bibliotecas más que en el compilador. Esta lección recorre la capa de red con Ktor y su arquitectura de motores intercambiables por plataforma, compara con criterio las dos opciones reales de persistencia compartida —SQLDelight con su generación de código desde SQL y Room en su versión multiplataforma—, examina cómo se resuelve la inyección de dependencias cuando el grafo vive en el código común pero sus raíces son objetos nativos, y establece una lista de comprobación para auditar cualquier dependencia antes de dejarla entrar en el conjunto común.
La restricción que define un proyecto Kotlin Multiplatform no la impone el lenguaje sino el catálogo: el código común solo puede apoyarse en bibliotecas que publiquen artefactos para todos los destinos configurados, y una sola dependencia que no lo haga basta para expulsar de commonMain a todo lo que la use. Por eso la primera tarea real de cualquier equipo que evalúa KMP no es escribir código sino inventariar dependencias, y por eso el estado del ecosistema es una variable de decisión tan importante como la arquitectura. La buena noticia es que las tres capas que más pesan en una aplicación de datos —red, persistencia y composición del grafo de objetos— tienen hoy soluciones maduras y probadas en producción; la letra pequeña es que cada una de ellas resuelve la portabilidad de una manera distinta, y entender esas maneras es lo que permite elegir sin arrepentirse.
- Construir la capa de red común con Ktor y entender el papel de los motores por plataforma.
- Comparar SQLDelight y Room multiplataforma según el modelo de verdad que impone cada uno.
- Componer el grafo de dependencias en el común y enraizarlo desde cada punto de arranque nativo.
- Auditar cualquier dependencia candidata antes de admitirla en el conjunto común.
Red: Ktor y el motor intercambiable
Ktor resuelve la portabilidad con una separación limpia entre el cliente y el motor. El cliente, con su configuración, sus plugins de serialización, reintento, tiempo de espera, autenticación y registro, vive entero en el código común; el motor —lo que abre de verdad el socket— se elige por plataforma y se inyecta en la construcción. En Android suele apoyarse en OkHttp, con lo que se conserva todo lo que ya sabes sobre interceptores, caché de disco y depuración; en iOS se apoya en la infraestructura de red del sistema, con lo que hereda las políticas de seguridad del transporte y la reanudación en segundo plano.
// commonMain: toda la configuracion es comun
fun crearCliente(motor: HttpClientEngine) = HttpClient(motor) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(HttpRequestRetry) { retryOnServerErrors(maxRetries = 3); exponentialDelay() }
defaultRequest { url("https://api.ejemplo.com/") }
}
// androidMain
actual fun motorDePlataforma(): HttpClientEngine = OkHttp.create { }
Que el motor de Android sea OkHttp tiene además una implicación que tranquiliza a los equipos con años de rodaje: los interceptores existentes, la configuración de confianza en certificados, la caché de disco y las herramientas de inspección de tráfico siguen funcionando exactamente igual, porque debajo del cliente común sigue estando la pila que ya conocías. Adoptar Ktor en el conjunto común no obliga a renunciar a nada de lo que ya funcionaba en Android; obliga a describirlo una capa más arriba.
La consecuencia práctica es que la API remota se describe una sola vez. Los modelos anotados para serialización, los mapeadores hacia el dominio, la política de reintentos y el tratamiento de errores dejan de existir por duplicado, y con ellos desaparece la clase entera de fallos en la que una plataforma interpretaba un campo opcional de manera distinta a la otra.
Si tuvieras que compartir una sola cosa de la capa de red, comparte los modelos y su serialización, no el transporte. El transporte es código aburrido que rara vez falla; los modelos son donde vive el contrato con el servidor, y donde una diferencia de una letra en el nombre de un campo produce un campo nulo silencioso que solo se manifiesta en una plataforma y solo con ciertos usuarios.
Persistencia: dos filosofías, no dos sabores
flowchart TD
A[Necesito base de datos comun] --> B{Cual es mi fuente de verdad}
B -->|El esquema SQL| C[SQLDelight]
B -->|Las clases de Kotlin| D[Room multiplataforma]
C --> E[Genera Kotlin tipado desde los .sq]
D --> F[Genera SQL desde entidades y DAO]
E --> G[Driver por plataforma]
F --> G
G --> H[Android usa SQLite del sistema]
G --> I[iOS usa SQLite nativo]SQLDelight invierte la relación habitual: la verdad es el archivo de SQL, y de él se genera código Kotlin con tipos comprobados en compilación, incluidas las consultas. Quien viene de Room percibe el cambio como una pérdida de comodidad y termina descubriendo que es una ganancia de precisión, porque las consultas complejas dejan de escribirse dentro de una cadena de texto anotada y pasan a ser objetos verificados. Su ecosistema en KMP es el más veterano, con controladores estables para todos los destinos y una integración natural con flujos.
Room multiplataforma llegó después y ofrece la ventaja decisiva para un equipo que ya tiene una aplicación Android grande: el modelo mental, las anotaciones y buena parte del código existente se conservan, de modo que la migración deja de ser una reescritura de la capa de persistencia para convertirse en un cambio de configuración y de controlador. La contrapartida es un ecosistema más joven y una atadura mayor a la evolución de la biblioteca.
La regla de decisión que resiste el contacto con la realidad es sencilla: si el proyecto parte de cero o si el esquema es complejo y las consultas son el centro del trabajo, SQLDelight; si el proyecto es una aplicación Android existente con muchas entidades ya escritas y la prioridad es no detener la entrega, Room multiplataforma. Lo que no funciona es elegir por preferencia estética y descubrir seis meses después que la biblioteca no permite lo que el esquema necesita.
Inyección compartida y el punto de arranque
El grafo de objetos plantea un problema específico en KMP: se describe en el código común, pero sus raíces son objetos nativos —el contexto de Android, el directorio de documentos de iOS, el motor de red— que solo existen abajo. La solución universal, con cualquier biblioteca, consiste en declarar los módulos en el común dejando esas raíces como parámetros y en completarlos desde cada punto de arranque.
// commonMain
fun moduloDatos(raices: Raices) = module {
single { crearCliente(raices.motor) }
single { crearBaseDeDatos(raices.driver) }
single { RepositorioSuscripcion(get(), get()) }
factory { CalcularEstadoSuscripcion(get()) }
}
// androidMain: el arranque aporta lo que solo existe aqui
class App : Application() {
override fun onCreate() {
super.onCreate()
startKoin { modules(moduloDatos(Raices(OkHttp.create { }, driverAndroid(this@App)))) }
}
}
Koin sigue siendo la opción más extendida por su resolución en tiempo de ejecución y su tolerancia a configuraciones asimétricas, mientras que las soluciones de generación de código en compilación ofrecen la verificación estática del grafo a cambio de más rigidez. Sea cual sea la elección, hay una disciplina que importa más que la biblioteca: el grafo debe declararse una sola vez en el común y no volver a declararse en cada plataforma, porque dos declaraciones que hoy coinciden son dos declaraciones que mañana no coincidirán y el síntoma será un objeto con dos ciclos de vida distintos según dónde se ejecute.
Hay una tentación adicional que conviene nombrar para poder rechazarla: exponer el contenedor de dependencias como parte de la API pública del framework, de manera que el código nativo pida objetos por su tipo. Funciona, ahorra escribir una fachada y convierte el contenedor en una variable global compartida por dos aplicaciones, con lo que cualquier cambio en el grafo se vuelve incompatible sin que ningún compilador lo advierta. La alternativa cuesta media hora más y consiste en exponer un objeto de fábrica explícito con las pocas operaciones que el lado nativo necesita.
Auditar una dependencia antes de admitirla
Todo lo anterior se reduce a un procedimiento que debería aplicarse a cualquier candidata antes de que aparezca en el archivo de compilación del conjunto común, porque una vez dentro el coste de sacarla ya no es técnico sino político.
flowchart TD
A[Dependencia candidata] --> B{Publica artefactos para todos mis destinos}
B -->|no| Z[Fuera del comun - envolver por plataforma]
B -->|si| C{Y para los destinos que quiza quiera manana}
C -->|no| Y[Admitir con plan de salida escrito]
C -->|si| D{Quien la mantiene y con que respaldo}
D -->|una sola persona| Y
D -->|organizacion o fundacion| E{Cuanto tarda en soportar un Kotlin nuevo}
E -->|meses| Y
E -->|semanas| F[Admitir en commonMain]La primera pregunta es la obvia y la que todo el mundo hace. La segunda ya no es obvia y es la que más disgustos evita, porque el valor futuro de un módulo común está en poder añadir destinos sin reescribirlo, y una dependencia que hoy cubre Android e iOS pero nunca cubrirá la web es una hipoteca sobre esa opción. La tercera y la cuarta miden algo distinto del catálogo de funcionalidades: miden la probabilidad de que el proyecto siga vivo y sincronizado con la evolución del lenguaje, que en KMP es un factor crítico porque cada versión de Kotlin obliga a recompilar toda la cadena de dependencias nativas.
Para las que no pasan el filtro existe siempre la misma salida, y no es un consuelo sino una técnica de primera categoría: declarar en el común la interfaz que necesitas, implementarla en cada plataforma con la mejor biblioteca disponible allí, e inyectarla. El código común no pierde ni una línea de lógica, cada plataforma usa lo que mejor funciona en su ecosistema, y el día que aparezca una alternativa multiplataforma sustituirla es cambiar una implementación.
// commonMain: la capacidad se declara, no se importa
interface AnaliticaDeUso {
fun evento(nombre: String, propiedades: Map<String, String> = emptyMap())
fun identificar(usuario: String)
}
Los proveedores de analítica y de informes de fallos publican bibliotecas nativas por plataforma y rara vez artefactos multiplataforma de primera clase. Intentar forzar su entrada en el común produce envoltorios frágiles que se rompen en cada actualización del proveedor. Declarar la interfaz en el dominio y dejar dos implementaciones triviales cuesta menos, sobrevive mejor y además tiene un efecto lateral valioso: los nombres de los eventos quedan escritos una sola vez en el código compartido, que es donde de verdad importa que coincidan para que los informes sean comparables entre plataformas.
Cliente común, motor local
Ktor comparte toda la configuración y deja el socket en manos de cada plataforma.
Elige por fuente de verdad
SQL primero con SQLDelight, entidades primero con Room; no es cuestión de gusto.
Grafo común, raíces nativas
Declara los módulos una vez y pásale desde el arranque lo que solo existe abajo.
Audita antes de admitir
Una dependencia sin artefactos para todos los destinos expulsa del común a cuanto la use.
Elegir una biblioteca para el módulo compartido no se parece a elegirla para una aplicación Android, y la diferencia rara vez se contempla en la reunión donde se decide. En Android, si una biblioteca se abandona, el daño está acotado: la aplicación deja de recibir mejoras, alguien fija la versión y en algún momento se sustituye con un esfuerzo proporcional a su superficie. En un módulo común, la misma biblioteca abandonada bloquea a todas las plataformas a la vez y, lo que es peor, bloquea la incorporación de destinos nuevos, porque el día que el equipo quiera compilar para escritorio o para la web descubrirá que su capa de persistencia no llega hasta allí. La superficie de riesgo no es la suma de los riesgos por plataforma: es su producto, porque basta con que un destino quede sin soporte para que el módulo entero deje de ser común. Esto convierte el criterio de selección en algo mucho más conservador de lo que un desarrollador acostumbrado a probar cosas encuentra natural, y la pregunta correcta deja de ser cuál es la mejor biblioteca para pasar a ser cuál sigue existiendo dentro de cinco años con soporte en todos los destinos que quizá quieras. Las señales que sirven son antiguas y poco emocionantes: quién la mantiene y si cobra por ello, si el mantenimiento depende de una persona o de una organización, cuánto tarda en publicar soporte para una nueva versión de Kotlin, si el proyecto ha sobrevivido ya a un cambio grande del ecosistema y si existe una vía de salida practicable. Esa última importa más que todas las demás juntas, y se comprueba de una manera concreta: si toda la aplicación habla con la biblioteca a través de interfaces que tú has definido en tu dominio, cambiarla es un trabajo delimitado; si el tipo de la biblioteca aparece en las firmas de tus casos de uso y en el estado de tus pantallas, la biblioteca ya no es una dependencia, es tu arquitectura, y de eso no se sale sin reescribir. En un proyecto de una sola plataforma esa distinción es una buena práctica; en un módulo común es la diferencia entre poder cambiar de opinión y no poder.
- Lista todas las dependencias de tu capa de datos y marca cuáles publican artefactos para Android, iOS y escritorio.
- Sustituye una llamada de red real por Ktor con motor inyectado y ejecuta la misma prueba en los dos destinos.
- Modela dos entidades relacionadas en SQLDelight y en Room multiplataforma, y compara qué le cuesta a cada una una consulta con agregación.
- Declara el grafo de dependencias en el común y enráizalo desde Android y desde iOS sin duplicar ni una sola definición.
- Para cada dependencia común, escribe en una línea cuál sería tu plan de salida si mañana anunciasen su abandono.