wandres.dev
KOTLIN MULTIPLATFORM · compartir el estado

La estructura: commonMain, los source sets y expect/actual

Un proyecto Kotlin Multiplatform no se organiza por carpetas arbitrarias sino por una jerarquía de conjuntos de fuentes con reglas de visibilidad muy precisas: el conjunto común solo ve la biblioteca estándar y las dependencias multiplataforma, mientras que cada conjunto de plataforma ve además su propio sistema operativo y hereda todo lo común. Esta lección explica esa jerarquía y su compilación, presenta el mecanismo de declaraciones esperadas y reales como el hueco tipado que permite abrir agujeros controlados en el código común, y defiende una tesis incómoda: que en la mayoría de los casos una interfaz definida en común con implementaciones inyectadas por plataforma es preferible al mecanismo del lenguaje, que conviene reservar para lo que de verdad lo necesita.

⏱ 18 min

La estructura de un proyecto multiplataforma parece burocracia de configuración hasta que se entiende lo que realmente codifica: un sistema de visibilidad. Cada conjunto de fuentes declara, por su mera posición en la jerarquía, qué tiene derecho a ver. El conjunto común no ve Android ni iOS, y esa ceguera no es una limitación sino el mecanismo entero: es lo que garantiza que el código de dominio no pueda depender accidentalmente de un Context, y por tanto lo que hace que compile en las dos plataformas sin que nadie tenga que vigilarlo. Cuando ese código común necesita algo que solo existe en un sistema concreto, el lenguaje ofrece una salida tipada —declarar la forma en común y la implementación en cada plataforma— que resuelve el problema sin abrir la puerta a que todo lo demás se cuele por el mismo hueco.

🎯 Al terminar esta lección sabrás
  • Entender la jerarquía de conjuntos de fuentes como un sistema de visibilidad y no como una convención de carpetas.
  • Escribir declaraciones expect y actual correctas y saber qué comprueba el compilador y qué no.
  • Elegir entre el mecanismo del lenguaje y la inyección de una interfaz común según el tipo de dependencia.
  • Diagnosticar los tres errores estructurales frecuentes: el común contaminado, la proliferación de huecos y la jerarquía plana.

La jerarquía de conjuntos de fuentes

Un módulo multiplataforma se organiza en conjuntos de fuentes que forman un árbol. En la raíz está commonMain, cuyo código se compila una vez por cada destino y que solo puede usar la biblioteca estándar de Kotlin y dependencias que declaren soporte multiplataforma. Debajo cuelgan los conjuntos de plataforma —androidMain, iosMain y sus variantes por arquitectura— que heredan todo lo común y añaden acceso a las interfaces nativas correspondientes. Entre medias pueden existir conjuntos intermedios, como uno que agrupe todas las variantes de Apple, para compartir código que solo tiene sentido en ese subconjunto.

flowchart TB
CM[commonMain] --> AM[androidMain]
CM --> AP[appleMain]
AP --> I1[iosArm64Main]
AP --> I2[iosSimulatorArm64Main]
CT[commonTest] --> AT[androidUnitTest]
CT --> IT[iosTest]
style CM fill:#a6e3a1,color:#11111b
style AM fill:#89b4fa,color:#11111b
style AP fill:#f9e2af,color:#11111b

La regla de visibilidad es unidireccional y no admite excepciones: un conjunto ve lo que hay por encima de él y nunca lo que hay al lado o por debajo. Desde androidMain puedes usar cualquier cosa de commonMain; desde commonMain no puedes ver nada de androidMain. Esa asimetría es lo que convierte la arquitectura en una propiedad verificada por el compilador en lugar de en una recomendación escrita en un documento que nadie lee.

// build.gradle.kts del modulo compartido
kotlin {
    androidTarget()
    iosArm64()
    iosSimulatorArm64()

    sourceSets {
        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.kotlinx.serialization.json)
        }
        androidMain.dependencies {
            implementation(libs.ktor.client.okhttp)
        }
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
        }
    }
}

Merece atención el conjunto intermedio, porque resuelve un problema que aparece pronto y que sin él obliga a duplicar ficheros. Los destinos de Apple son varios —dispositivo, simulador, arquitecturas distintas— y comparten prácticamente todo su código de plataforma. Agruparlos bajo un conjunto común a todos ellos permite escribir una sola vez lo que de otro modo habría que copiar tres veces, con la garantía de que las tres copias no puedan divergir por el simple hecho de que no existen.

Conviene notar que el conjunto de tests replica la misma jerarquía. Un test escrito en commonTest se ejecuta una vez por destino, de modo que la misma suite valida la lógica compartida tanto sobre la máquina virtual de Android como sobre el binario nativo de iOS. Ese detalle, que parece menor, es en la práctica una de las razones más sólidas para adoptar el enfoque: los desacuerdos entre plataformas dejan de descubrirse en producción.

Declaraciones esperadas y reales

Cuando el código común necesita algo que no existe en la biblioteca estándar, se declara su forma con expect y se proporciona una implementación con actual en cada conjunto de plataforma. El compilador verifica que exista exactamente una implementación por destino y que la firma coincida; si falta una, el proyecto no compila.

// commonMain
expect class Almacen {
    fun guardar(clave: String, valor: String)
    fun leer(clave: String): String?
}

expect fun identificadorDeDispositivo(): String
// androidMain
actual class Almacen(private val prefs: android.content.SharedPreferences) {
    actual fun guardar(clave: String, valor: String) {
        prefs.edit().putString(clave, valor).apply()
    }
    actual fun leer(clave: String): String? = prefs.getString(clave, null)
}

El detalle que suele pasarse por alto es el del constructor. Una clase esperada no puede exigir parámetros que solo una plataforma necesita, de modo que la implementación de Android acaba requiriendo un objeto que el código común no puede construir. Ese roce es real y tiene dos salidas: declarar la parte esperada como interfaz y crear la instancia en el arranque de cada aplicación, o recurrir a alguna forma de inicialización por plataforma. La primera es más limpia casi siempre.

⚠️
Lo que expect no es

No es un mecanismo de condicionales por plataforma ni una forma de sustituir a la inyección de dependencias. Cada declaración esperada es un punto de la arquitectura donde el código común deja de ser común de verdad, y su número debe mantenerse deliberadamente bajo. Un proyecto con cuarenta declaraciones esperadas no tiene una capa compartida: tiene dos aplicaciones que comparten una plantilla de firmas.

La alternativa preferible: interfaz en común, implementación inyectada

En la mayoría de los casos, lo que se necesita no es una construcción del lenguaje sino una abstracción ordinaria. Se define una interfaz en commonMain, cada plataforma la implementa en su propio conjunto de fuentes, y el objeto se entrega al container por constructor. El resultado es indistinguible desde el punto de vista del código común, pero mucho mejor desde el punto de vista de las pruebas y de la evolución.

// commonMain
interface Reloj { fun ahora(): Long }
interface Almacen {
    suspend fun guardar(clave: String, valor: String)
    suspend fun leer(clave: String): String?
}

class SesionContainer(
    private val almacen: Almacen,
    private val reloj: Reloj,
)

Hay un segundo motivo, menos obvio pero igual de decisivo: la interfaz admite más de una implementación por plataforma. Una declaración esperada fija exactamente una realización por destino, de modo que si mañana necesitas dos almacenes distintos en Android —uno cifrado y otro no— el mecanismo se te queda corto y hay que rehacerlo. Con una interfaz, es simplemente otra clase que implementa el mismo contrato y otra línea en el grafo de dependencias.

La ventaja decisiva es la sustituibilidad. Una interfaz admite un doble de prueba trivial en commonTest; una declaración esperada, no, porque el compilador ya ha fijado cuál es la implementación real de cada destino y no hay forma de interponer otra sin acrobacias. Como el objetivo del reparto era precisamente poder probar la política una sola vez, cualquier mecanismo que dificulte esa prueba se paga en el sitio equivocado.

🧩

Usa una interfaz cuando…

La dependencia es una colaboración: almacenamiento, red, reloj, registro, analítica, base de datos, permisos. Todo lo que quieras sustituir en un test.

🪛

Usa expect cuando…

Necesitas un tipo o una función sin equivalente común y sin colaborador natural: envolver un tipo nativo, un identificador del sistema, un detalle de la plataforma sin estado.

🧭

Conjuntos intermedios

Si dos destinos comparten código que el común no puede tener, crea un conjunto intermedio en vez de duplicar el fichero en ambos lados.

🚨

Señal de alarma

Si el número de declaraciones esperadas crece cada semana, la frontera está mal trazada y el código común está intentando hacer trabajo de plataforma.

La ceguera del código común es su única superpotencia

Hay una inversión conceptual en todo esto que conviene mirar de frente, porque explica por qué el enfoque funciona y por qué tantos intentos de reutilización han fracasado antes. commonMain no es valioso por lo que puede hacer, sino por lo que tiene prohibido hacer. Su capacidad expresiva es estrictamente menor que la de cualquier conjunto de plataforma: no puede tocar el sistema de ficheros de Android, ni presentar una vista de Apple, ni consultar el estado del ciclo de vida. Y es exactamente esa incapacidad la que le confiere la propiedad que buscábamos: si no puede depender de un entorno, entonces es válido en todos, y su corrección puede establecerse una vez y darse por establecida en todas partes. El patrón se repite en todos los sitios donde el software ha conseguido algo parecido a la portabilidad o a la demostrabilidad. Una función pura vale más que un procedimiento no porque haga más, sino porque no puede tocar el mundo. Un tipo inmutable es más fácil de razonar porque le han quitado la capacidad de cambiar. Un sistema de tipos ayuda porque rechaza programas que, sin él, se habrían escrito y ejecutado. Todos son el mismo movimiento: pagar expresividad para comprar garantías. Las declaraciones esperadas son, en ese marco, agujeros deliberados en la muralla, y como todo agujero deben contarse, justificarse y minimizarse: cada uno reintroduce en el código común una porción del entorno que habíamos expulsado, y por tanto una porción de la incertidumbre que habíamos eliminado. Quien entiende esto deja de preguntarse cómo meter más cosas en el módulo compartido y empieza a preguntarse qué puede sacar de él, que es la pregunta que produce arquitecturas que sobreviven a su tercer año.

⚔️ Construye y audita tu jerarquía
  1. Dibuja el árbol de conjuntos de fuentes que necesitaría tu aplicación, incluyendo el conjunto intermedio de Apple y los de prueba.
  2. Elige tres dependencias de plataforma de tu código actual y decide, para cada una, si iría por interfaz inyectada o por declaración esperada, justificando la elección.
  3. Implementa un Reloj y un Almacen como interfaces en común con doble de prueba en commonTest, y escribe un test que no toque ninguna plataforma.
  4. Toma una declaración esperada que hayas escrito y conviértela en interfaz con implementaciones inyectadas: anota qué ganas y qué pierdes.
  5. Establece para tu equipo un límite numérico de declaraciones esperadas y escribe la regla que decide cuándo se puede superar.