La estructura: source sets, expect, actual y la migración sin reescritura
La topología de un proyecto Kotlin Multiplatform no es una convención de carpetas sino un grafo de dependencias que el compilador verifica: cada source set ve solo las APIs que todos sus destinos garantizan, y esa restricción es la que hace que el código común sea realmente común. Esta lección explica la jerarquía de source sets y los conjuntos intermedios, el mecanismo de declaración esperada e implementación real que permite atravesar la frontera de la plataforma sin romper la comprobación estática, las alternativas basadas en interfaces e inyección que suelen envejecer mejor, y la estrategia incremental para convertir una aplicación Android existente en un proyecto multiplataforma extrayendo módulos por orden de dependencia y sin detener la entrega.
La primera vez que se abre un proyecto Kotlin Multiplatform la reacción habitual es que hay demasiadas carpetas para tan poco código. Es una impresión engañosa, porque esas carpetas no son organización sino tipado: cada una declara un conjunto de plataformas de destino, y el compilador deduce de ahí qué APIs tiene derecho a ver el código que vive dentro. En el conjunto común no existe java.io.File no porque alguien lo haya prohibido, sino porque iOS no lo tiene y por tanto ninguna afirmación sobre él sería cierta en todos los destinos. Entender esta idea —que la estructura de carpetas es un sistema de garantías verificado en compilación— convierte lo que parecía burocracia en la propiedad más valiosa de KMP: es imposible escribir código común que solo funcione en una plataforma, porque no compila.
- Leer la jerarquía de source sets como un grafo de dependencias con visibilidad de API deducida.
- Usar el par de declaración esperada e implementación real y conocer sus formas y sus límites.
- Preferir interfaces e inyección cuando el mecanismo del compilador no aporta nada.
- Planificar la conversión incremental de una aplicación Android existente sin congelar la entrega.
La topología de los source sets
Un source set es un conjunto de fuentes asociado a uno o varios destinos de compilación. commonMain es el único que todos comparten y por tanto el más restringido: solo ve la biblioteca estándar de Kotlin y las dependencias que a su vez sean multiplataforma. androidMain compila para Android y ve todo el SDK; iosMain compila para las variantes de iOS y ve las APIs de la plataforma a través de la interoperabilidad con Objective-C. Entre medias caben conjuntos intermedios: si tres destinos comparten una capacidad que el común no tiene, se crea un source set padre para ellos y se evita duplicar tres veces el mismo archivo.
flowchart TD A[commonMain] --> B[androidMain] A --> C[appleMain] A --> D[jvmDesktopMain] C --> E[iosMain] C --> F[macosMain] E --> G[iosArm64Main] E --> H[iosSimulatorArm64Main] A -.->|solo API garantizada por todos| I[stdlib y dependencias KMP] B -.->|ve el SDK completo| J[android y androidx] E -.->|ve la plataforma via cinterop| K[Foundation y UIKit]
La regla que gobierna el grafo es sencilla y tiene una consecuencia que sorprende: un source set hijo puede usar todo lo que declara su padre, pero el padre nunca ve nada del hijo. Por eso el código común no puede llamar a una función de Android ni siquiera envuelta en una comprobación de plataforma, y por eso la única vía legítima de descender es declarar en el común algo cuya implementación se aporta abajo.
kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
androidMain.dependencies { implementation(libs.androidx.security.crypto) }
commonTest.dependencies { implementation(kotlin("test")) }
}
}
Las pruebas escritas en commonTest se ejecutan en todos los destinos configurados, lo que significa que la misma batería valida la lógica compilada como bytecode y compilada como binario nativo. No es redundancia: las diferencias en aritmética de coma flotante, en el orden de iteración de estructuras o en el comportamiento de zonas horarias han aparecido históricamente justo ahí, y descubrirlas en una prueba compartida cuesta minutos frente a los meses que tarda en llegar el informe desde producción en la otra plataforma.
Declaración esperada e implementación real
Cuando el código común necesita algo que solo existe abajo, se declara con expect una firma sin cuerpo y cada plataforma aporta su actual. El compilador comprueba que toda declaración esperada tiene su correspondiente implementación en cada destino, con la misma firma, y falla si falta una. Es un mecanismo de completitud, no de abstracción: no crea polimorfismo en tiempo de ejecución, se resuelve enteramente al compilar y por eso no tiene coste.
// commonMain
expect class AlmacenSeguro {
fun guardar(clave: String, valor: String)
fun leer(clave: String): String?
}
expect fun identificadorDeDispositivo(): String
// androidMain
actual class AlmacenSeguro(private val ctx: Context) {
private val prefs = EncryptedSharedPreferences.create(/* ... */)
actual fun guardar(clave: String, valor: String) { prefs.edit().putString(clave, valor).apply() }
actual fun leer(clave: String): String? = prefs.getString(clave, null)
}
Hay una asimetría que conviene ver pronto: la clase esperada obliga a que los constructores coincidan, y en Android el constructor necesita un contexto que en iOS no existe. Ese roce es sistemático, y es la razón por la que muchos equipos maduros usan expect y actual solo para funciones sueltas y triviales y resuelven el resto con una interfaz declarada en el común, implementada en cada plataforma e inyectada desde el punto de arranque nativo. La interfaz permite implementaciones falsas para pruebas, admite varias implementaciones por plataforma y no impone simetría en los constructores; el mecanismo del compilador, a cambio, garantiza que nadie olvide una plataforma. La heurística práctica es usar expect cuando la garantía de completitud importa más que la flexibilidad, y una interfaz en cualquier otro caso.
Adaptar una aplicación Android existente
La migración que funciona no empieza creando un proyecto nuevo. Empieza midiendo qué parte del código actual ya es Kotlin puro sin importaciones de la plataforma, porque esa parte ya está lista y solo necesita mudarse. El orden correcto es por dependencias inversas: primero lo que no depende de nada, luego lo que depende solo de lo anterior.
flowchart LR
A[App Android monolitica] --> B[Extraer modulo de dominio puro]
B --> C[Convertir ese modulo a KMP]
C --> D[Sustituir dependencias JVM por equivalentes KMP]
D --> E[Mover la capa de datos]
E --> F[Compilar para iOS y publicar el framework]
F --> G{Merece la pena seguir subiendo}
G -->|si| H[Evaluar presentacion compartida]
G -->|no| I[Frontera estable en datos]El paso que más sorprende es el tercero. Un módulo de dominio aparentemente puro suele arrastrar dependencias que solo existen en la máquina virtual de Java: fechas, expresiones regulares con extensiones concretas, generadores de números aleatorios, formateo de números. Sustituirlas por sus equivalentes multiplataforma es trabajo mecánico pero no gratuito, y es donde se consume la mayor parte del tiempo de la primera migración. Conviene hacerlo con el módulo todavía dentro del proyecto Android y con la batería de pruebas verde en cada paso, porque así la migración nunca deja de ser reversible.
Hay una tabla mental de sustituciones que todo el mundo acaba escribiendo y que conviene tener a mano desde el primer día, porque anticipa el noventa por ciento de los errores de compilación de la primera semana.
// antes: solo existe en la maquina virtual de Java
val ahora = java.time.Instant.now()
val id = java.util.UUID.randomUUID().toString()
val texto = String.format("%.2f", cantidad)
// despues: disponible en todos los destinos
val ahora = Clock.System.now() // kotlinx-datetime
val id = Uuid.random().toString() // stdlib reciente
val texto = cantidad.toStringConDosDecimales() // funcion propia en commonMain
Las fechas y los identificadores tienen sustituto directo y el compilador los señala. El formateo de números y de fechas según la configuración regional del usuario no lo tiene del todo, porque es justamente una capacidad del sistema operativo, y su ausencia no produce un error de compilación sino un texto ligeramente distinto en una plataforma. La salida limpia consiste en declarar una interfaz de formateo en el común e implementarla con las APIs de cada sistema, en lugar de intentar reproducir en Kotlin lo que dos sistemas operativos llevan décadas afinando.
El puente hacia iOS y la disciplina de la migración
El módulo compartido llega a iOS como un framework, y de ahí nace la restricción que más condiciona el diseño de la API pública del común: lo que se exporta debe ser expresable en Objective-C, porque es esa la interfaz que Swift consume. Los genéricos se aplanan, los tipos sellados pierden la exhaustividad que el compilador de Kotlin garantizaba, y las funciones suspendidas se traducen a completadores o a la concurrencia estructurada de Swift según la configuración. Nada de esto impide trabajar, pero sí aconseja que la superficie pública del módulo sea deliberadamente estrecha y esté formada por tipos simples.
// commonMain: superficie publica pensada para cruzar la frontera
class SuscripcionFacade(private val repo: RepositorioSuscripcion) {
fun observar(alCambiar: (EstadoVisible) -> Unit): Cancelable = repo.flujo.suscribir(alCambiar)
suspend fun renovar(): ResultadoSimple = repo.renovar().aResultadoSimple()
}
Exponer una fachada en lugar del grafo interno cumple dos funciones a la vez: reduce lo que hay que traducir y convierte la API compartida en un contrato explícito que se puede versionar. El equipo de iOS deja de depender de la forma interna del módulo y pasa a depender de una lista corta de operaciones, que es exactamente la relación que se quiere entre dos equipos que publican por separado.
La disciplina de la migración se resume en tres reglas que parecen obvias y que casi nadie cumple entera. La primera es que nunca haya una rama de migración viva más de dos semanas, porque una rama larga contra una aplicación que sigue evolucionando es una deuda que crece más rápido de lo que se paga. La segunda es que cada módulo movido llegue a producción en Android antes de tocar iOS, de manera que cualquier regresión introducida por la conversión se detecte con la plataforma que ya sabes depurar. La tercera es que exista, en todo momento, un procedimiento escrito para dar marcha atrás; si nadie lo ha escrito es que en realidad no existe.
La carpeta es un tipo
Cada source set declara qué APIs puede afirmar, y el compilador lo verifica por ti.
Completitud, no polimorfismo
La pareja esperada y real garantiza que ninguna plataforma quede sin implementar.
Pruebas comunes
Una sola batería ejecutándose en dos compiladores caza divergencias antes de que existan.
Migración por capas
De abajo arriba, con las pruebas en verde y sin dejar de publicar en ningún momento.
Hay una tentación recurrente en todo proyecto KMP que consiste en pedirle al compilador que se aparte. Aparece cuando alguien necesita una función que solo existe en Android dentro de una clase que está en commonMain, y en lugar de aceptar que esa clase no pertenece al común, busca la manera de colar la llamada: una declaración esperada apresurada, una interfaz inyectada que en iOS lanza una excepción, un parámetro opcional que en una plataforma nunca se rellena. Cada una de esas maniobras funciona la primera vez y compila, y por eso son peligrosas: han convertido una verdad verificada estáticamente —este código se ejecuta correctamente en todos los destinos— en una promesa verbal que solo se comprueba ejecutando. La restricción del conjunto común es incómoda precisamente porque es la única barrera que existe; no hay revisión de código, ni convención de equipo, ni documento de arquitectura que sostenga durante tres años lo que el compilador sostiene sin esfuerzo, y el día que se le enseña a mirar hacia otro lado no queda nada. Lo que se pierde además no es solo corrección: se pierde la capacidad de razonar sobre el módulo sin saber en qué plataforma se ejecuta, que es exactamente el bien que se estaba comprando al adoptar KMP. Un módulo común en el que hay que preguntarse dónde se está ejecutando ya no es un módulo común, es un módulo con dos comportamientos que comparte archivo. La disciplina correcta, y es una disciplina emocional más que técnica, consiste en interpretar cada error de compilación en commonMain como información arquitectónica gratuita: el compilador te está diciendo que ese trozo de lógica tiene una dependencia con la plataforma que no habías reconocido, y esa dependencia existía igualmente antes de que existiera KMP, solo que nadie te la había señalado. Los equipos que aprenden a agradecer ese error terminan con una arquitectura más limpia también en Android; los que aprenden a esquivarlo terminan con un módulo común que nadie se atreve a tocar.
- Configura un módulo KMP nuevo con destinos Android e iOS y mueve a
commonMainuna sola clase de dominio ya escrita. - Recorre los errores de compilación uno a uno y clasifícalos en dependencia real de plataforma o dependencia accidental de la máquina virtual de Java.
- Resuelve al menos una de esas dependencias con
expectyactual, y otra equivalente con una interfaz inyectada; compara la legibilidad de ambas. - Escribe una prueba en
commonTestque falle si la lógica difiere y ejecútala en los dos destinos hasta verla verde en ambos. - Añade un tercer destino de escritorio sin escribir código nuevo y anota qué implementaciones reales te reclama el compilador.