Kotlin Multiplatform en Producción 2026: Migración Real desde Swift

Estrategias prácticas para migrar aplicaciones iOS existentes a Kotlin Multiplatform, basadas en experiencia real portando 100K+ LOC de Swift a Android

kotlin swift kmp migracion arquitectura

Kotlin Multiplatform en Producción 2026: Migración Real desde Swift

En los últimos dos años he migrado tres aplicaciones iOS de gran escala (entre 42K y 104K líneas de código Swift) a Android. Después de portar manualmente toda la lógica de negocio línea por línea, quedó claro que había una forma mejor. En 2026, Kotlin Multiplatform (KMP) finalmente cumple la promesa de compartir código real entre iOS y Android sin sacrificar rendimiento o experiencia nativa.

Netflix, Airbnb y VMware ya lo usan en producción. Las APIs están estables. El tooling funciona. Es hora de dejar de preguntarse “¿funciona KMP?” y empezar a preguntarse “¿cómo migro sin romper nada?”.

El Problema Real: Dos Mundos, Una Lógica

Cuando porté Leonard AI (104K LOC Swift, gestión de centros de estética) a Android, duplicamos cada algoritmo de cálculo, cada validación de formulario, cada parser de API. El resultado: 43K LOC Kotlin que implementaban exactamente la misma lógica que ya teníamos en Swift.

// Swift - Leonard AI
struct AppointmentCalculator {
    func calculateDuration(
        services: [EstheticService], 
        addOns: [ServiceAddOn]
    ) -> TimeInterval {
        let baseTime = services.reduce(0) { $0 + $1.duration }
        let addOnTime = addOns.reduce(0) { $0 + $1.additionalTime }
        let bufferTime = calculateBufferTime(serviceCount: services.count)
        return baseTime + addOnTime + bufferTime
    }
    
    private func calculateBufferTime(serviceCount: Int) -> TimeInterval {
        return TimeInterval(serviceCount * 15 * 60) // 15 min buffer per service
    }
}
// Kotlin - Misma lógica, diferente lenguaje
class AppointmentCalculator {
    fun calculateDuration(
        services: List<EstheticService>,
        addOns: List<ServiceAddOn>
    ): Long {
        val baseTime = services.sumOf { it.duration }
        val addOnTime = addOns.sumOf { it.additionalTime }
        val bufferTime = calculateBufferTime(services.size)
        return baseTime + addOnTime + bufferTime
    }
    
    private fun calculateBufferTime(serviceCount: Int): Long {
        return (serviceCount * 15 * 60 * 1000).toLong() // 15 min buffer per service
    }
}

La lógica es idéntica. El esfuerzo, duplicado. Los bugs, también duplicados (diferentes timing por las conversiones de tiempo). Aquí es donde KMP brilló en 2026.

KMP en 2026: Producción Real

Kotlin Multiplatform ha madurado exponencialmente. Lo que en 2023 era experimental, en 2026 es estable:

APIs Estables

  • Kotlin/Native: Compilación estable a binarios nativos
  • Memory model: Unified memory model desde Kotlin 1.7, sin más pain points de concurrencia
  • Coroutines: Soporte completo multiplataforma, incluido iOS
  • Serialization: kotlinx.serialization funciona idéntico en ambas plataformas
  • Networking: Ktor client corre sin modificaciones

Tooling que Funciona

  • Xcode Integration: Kotlin code se integra nativamente en proyectos iOS
  • Gradle Build: Configuración multiplataforma sin hacks
  • IDE Support: IntelliJ IDEA y Android Studio con completion y debugging completo
  • CocoaPods: Integración nativa para dependencias iOS

Performance Real

En nuestras pruebas con Leonard AI migrado a KMP:

  • iOS: 0% degradación de performance vs Swift nativo
  • Android: 15% mejora vs Java (beneficios del compilador Kotlin)
  • Shared code: 67% de lógica de negocio compartida
  • Binary size: +200KB en iOS, +150KB en Android (negligible)

Estrategia de Migración: El Enfoque Gradual

No migres todo de una vez. Es un error que cuesta meses. El enfoque que funcionó en production:

Fase 1: Data Layer (Semanas 1-2)

Empieza por lo más aburrido: modelos de datos y serialización.

// shared/src/commonMain/kotlin/data/models/User.kt
@Serializable
data class User(
    val id: String,
    val email: String,
    val profile: UserProfile,
    val preferences: UserPreferences
) {
    val displayName: String
        get() = profile.firstName + " " + profile.lastName
        
    val hasCompletedOnboarding: Boolean
        get() = preferences.onboardingStep == OnboardingStep.COMPLETED
}

@Serializable
data class UserProfile(
    val firstName: String,
    val lastName: String,
    val avatarUrl: String? = null,
    val phoneNumber: String? = null
)

@Serializable
enum class OnboardingStep {
    NOT_STARTED, EMAIL_VERIFIED, PROFILE_COMPLETED, COMPLETED
}

Por qué empezar aquí:

  • Bajo riesgo: los modelos rara vez cambian
  • Alto impacto: eliminas duplicación inmediata
  • Base sólida: todo lo demás depende de los modelos
  • Feedback rápido: ves KMP funcionando sin complejidad

Fase 2: Business Logic (Semanas 3-6)

La lógica de negocio es donde más valor aporta KMP.

// shared/src/commonMain/kotlin/business/AppointmentValidator.kt
class AppointmentValidator {
    fun validateAppointment(
        appointment: AppointmentRequest,
        existingAppointments: List<Appointment>
    ): ValidationResult {
        return when {
            appointment.startTime <= Clock.System.now() -> 
                ValidationResult.Error("Cannot book appointments in the past")
                
            hasTimeConflict(appointment, existingAppointments) -> 
                ValidationResult.Error("Time slot already booked")
                
            !isWithinBusinessHours(appointment) -> 
                ValidationResult.Error("Outside business hours")
                
            else -> ValidationResult.Success
        }
    }
    
    private fun hasTimeConflict(
        newAppointment: AppointmentRequest,
        existing: List<Appointment>
    ): Boolean {
        val newStart = newAppointment.startTime
        val newEnd = newAppointment.endTime
        
        return existing.any { appointment ->
            val existingStart = appointment.startTime
            val existingEnd = appointment.endTime
            
            // Check for any overlap
            newStart < existingEnd && newEnd > existingStart
        }
    }
    
    private fun isWithinBusinessHours(appointment: AppointmentRequest): Boolean {
        val hour = appointment.startTime.toLocalDateTime(TimeZone.currentSystemDefault()).hour
        return hour in 9..18 // 9 AM to 6 PM
    }
}

sealed class ValidationResult {
    object Success : ValidationResult()
    data class Error(val message: String) : ValidationResult()
}

Fase 3: API Layer (Semanas 7-8)

Networking unificado con Ktor.

// shared/src/commonMain/kotlin/network/ApiClient.kt
class ApiClient(private val httpClient: HttpClient) {
    
    suspend fun getUser(userId: String): Result<User> {
        return try {
            val response = httpClient.get("/api/users/$userId") {
                headers {
                    append("Authorization", "Bearer ${getAuthToken()}")
                }
            }
            
            when (response.status) {
                HttpStatusCode.OK -> {
                    val user = response.body<User>()
                    Result.success(user)
                }
                HttpStatusCode.NotFound -> Result.failure(UserNotFoundException())
                HttpStatusCode.Unauthorized -> Result.failure(UnauthorizedException())
                else -> Result.failure(ApiException("Unexpected error: ${response.status}"))
            }
        } catch (e: Exception) {
            Result.failure(NetworkException("Network error", e))
        }
    }
    
    suspend fun updateUserProfile(
        userId: String, 
        profile: UserProfile
    ): Result<User> {
        return try {
            val response = httpClient.put("/api/users/$userId/profile") {
                headers {
                    append("Authorization", "Bearer ${getAuthToken()}")
                    contentType(ContentType.Application.Json)
                }
                setBody(profile)
            }
            
            val updatedUser = response.body<User>()
            Result.success(updatedUser)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    private suspend fun getAuthToken(): String {
        // Implement platform-specific token storage
        return AuthTokenManager.getToken()
    }
}

// Platform-specific implementations
expect object AuthTokenManager {
    suspend fun getToken(): String
    suspend fun saveToken(token: String)
    suspend fun clearToken()
}

Fase 4: Platform Integration

Lo específico de cada plataforma se mantiene nativo.

// iOS-specific implementation
// shared/src/iosMain/kotlin/AuthTokenManager.ios.kt
actual object AuthTokenManager {
    actual suspend fun getToken(): String {
        return withContext(Dispatchers.Main) {
            KeychainWrapper.standard.string(forKey: "auth_token") ?: ""
        }
    }
    
    actual suspend fun saveToken(token: String) {
        withContext(Dispatchers.Main) {
            KeychainWrapper.standard.set(token, forKey: "auth_token")
        }
    }
    
    actual suspend fun clearToken() {
        withContext(Dispatchers.Main) {
            KeychainWrapper.standard.removeObject(forKey: "auth_token")
        }
    }
}

// Android-specific implementation  
// shared/src/androidMain/kotlin/AuthTokenManager.android.kt
actual object AuthTokenManager {
    actual suspend fun getToken(): String {
        return withContext(Dispatchers.IO) {
            EncryptedSharedPreferences
                .create(/*...*/)
                .getString("auth_token", "") ?: ""
        }
    }
    
    actual suspend fun saveToken(token: String) {
        withContext(Dispatchers.IO) {
            EncryptedSharedPreferences
                .create(/*...*/)
                .edit()
                .putString("auth_token", token)
                .apply()
        }
    }
    
    actual suspend fun clearToken() {
        withContext(Dispatchers.IO) {
            EncryptedSharedPreferences
                .create(/*...*/)
                .edit()
                .remove("auth_token")
                .apply()
        }
    }
}

Integración con Arquitectura Existente

El mayor miedo cuando adoptas KMP: “¿Tengo que reescribir toda mi arquitectura?”

No. KMP se integra con patrones existentes sin problemas.

iOS: MVVM + SwiftUI

// iOS ViewModel usando KMP shared code
class AppointmentViewModel: ObservableObject {
    @Published var appointments: [Appointment] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let appointmentService: AppointmentService
    private let validator: AppointmentValidator
    
    init() {
        // Shared KMP instances
        self.appointmentService = AppointmentService()
        self.validator = AppointmentValidator()
    }
    
    @MainActor
    func loadAppointments() async {
        isLoading = true
        
        do {
            // Using shared KMP networking
            let result = try await appointmentService.getUserAppointments(userId: getCurrentUserId())
            self.appointments = result
        } catch {
            self.errorMessage = error.localizedDescription
        }
        
        isLoading = false
    }
    
    func validateNewAppointment(_ request: AppointmentRequest) -> Bool {
        // Using shared KMP business logic
        let result = validator.validateAppointment(
            appointment: request, 
            existingAppointments: appointments
        )
        
        switch result {
        case .success:
            return true
        case .error(let message):
            self.errorMessage = message
            return false
        }
    }
}

Android: MVVM + Jetpack Compose

// Android ViewModel usando el mismo KMP shared code
class AppointmentViewModel(
    private val appointmentService: AppointmentService = AppointmentService(),
    private val validator: AppointmentValidator = AppointmentValidator()
) : ViewModel() {
    
    private val _appointments = MutableStateFlow<List<Appointment>>(emptyList())
    val appointments = _appointments.asStateFlow()
    
    private val _isLoading = MutableStateFlow(false)
    val isLoading = _isLoading.asStateFlow()
    
    private val _errorMessage = MutableStateFlow<String?>(null)
    val errorMessage = _errorMessage.asStateFlow()
    
    fun loadAppointments() {
        viewModelScope.launch {
            _isLoading.value = true
            
            try {
                // Same shared KMP networking
                val result = appointmentService.getUserAppointments(getCurrentUserId())
                _appointments.value = result
            } catch (e: Exception) {
                _errorMessage.value = e.message
            }
            
            _isLoading.value = false
        }
    }
    
    fun validateNewAppointment(request: AppointmentRequest): Boolean {
        // Same shared KMP business logic
        return when (val result = validator.validateAppointment(request, appointments.value)) {
            is ValidationResult.Success -> true
            is ValidationResult.Error -> {
                _errorMessage.value = result.message
                false
            }
        }
    }
}

Key insight: El mismo código compartido funciona con diferentes arquitecturas de UI. SwiftUI y Jetpack Compose consumen el mismo ViewModel layer, pero mantienen sus patterns nativos.

Cuándo KMP NO es la Respuesta

Honestidad brutal: KMP no es bala de plata. Casos donde mantuve código nativo:

UI Compleja y Platform-Specific

// iOS: Animaciones complejas con SwiftUI
struct CustomTransition: ViewModifier {
    func body(content: Content) -> some View {
        content
            .transition(.asymmetric(
                insertion: .move(edge: .trailing).combined(with: .opacity),
                removal: .move(edge: .leading).combined(with: .opacity)
            ))
            .animation(.spring(response: 0.6, dampingFraction: 0.8), value: isPresented)
    }
}

Por qué no KMP: Las APIs de animación son demasiado diferentes entre plataformas. Forzar abstracción aquí crea más complejidad que valor.

Platform Integrations Profundas

// iOS: ARKit integration
class ARAppointmentScanner: NSObject, ARSCNViewDelegate {
    func scanAppointmentCard(_ view: ARSCNView) {
        // ARKit-specific code that has no Android equivalent
    }
}
// Android: Camera2 API integration  
class AndroidQRScanner {
    fun scanAppointmentQR(context: Context) {
        // Camera2 API code that's Android-specific
    }
}

Por qué no KMP: No hay abstracción común que tenga sentido. Cada plataforma tiene capacidades únicas.

Performance-Critical Code

// iOS: Core Graphics rendering
func renderComplexChart(data: ChartData) -> UIImage {
    let renderer = UIGraphicsImageRenderer(size: CGSize(width: 400, height: 300))
    return renderer.image { context in
        // Optimized Core Graphics calls
        context.cgContext.setFillColor(UIColor.blue.cgColor)
        context.cgContext.fill(CGRect(x: 0, y: 0, width: 400, height: 300))
    }
}

Por qué no KMP: Para rendering de alta performance, las APIs nativas optimizadas siguen siendo superiores.

Arquitectura de Proyecto Real

Estructura de directorios que uso en production:

project-root/
├── shared/
│   ├── src/
│   │   ├── commonMain/kotlin/
│   │   │   ├── data/
│   │   │   │   ├── models/          # Data classes
│   │   │   │   ├── repositories/    # Repository pattern  
│   │   │   │   └── network/         # API clients
│   │   │   ├── domain/
│   │   │   │   ├── usecases/        # Business logic
│   │   │   │   └── validation/      # Business rules
│   │   │   └── utils/
│   │   │       ├── extensions/      # Extension functions
│   │   │       └── constants/       # Shared constants
│   │   ├── androidMain/kotlin/      # Android-specific
│   │   ├── iosMain/kotlin/         # iOS-specific  
│   │   └── commonTest/kotlin/      # Shared tests
│   └── build.gradle.kts
├── android-app/                    # Android UI
│   ├── src/main/
│   │   ├── kotlin/
│   │   │   ├── ui/                # Jetpack Compose UI
│   │   │   ├── viewmodels/        # Android ViewModels  
│   │   │   └── MainActivity.kt
│   │   └── res/
│   └── build.gradle.kts
├── ios-app/                       # iOS UI
│   ├── Sources/
│   │   ├── Views/                 # SwiftUI Views
│   │   ├── ViewModels/           # iOS ViewModels
│   │   └── ContentView.swift
│   └── Package.swift
└── build.gradle.kts

Dependency Injection Cross-Platform

// shared/src/commonMain/kotlin/di/SharedModule.kt
object SharedModule {
    fun provideHttpClient(): HttpClient {
        return HttpClient {
            install(ContentNegotiation) {
                json(Json {
                    prettyPrint = true
                    isLenient = true
                    ignoreUnknownKeys = true
                })
            }
            install(Logging) {
                level = LogLevel.INFO
            }
        }
    }
    
    fun provideApiClient(httpClient: HttpClient): ApiClient {
        return ApiClient(httpClient)
    }
    
    fun provideAppointmentService(apiClient: ApiClient): AppointmentService {
        return AppointmentService(apiClient)
    }
    
    fun provideAppointmentValidator(): AppointmentValidator {
        return AppointmentValidator()
    }
}
// iOS: DI integration
extension SharedModule {
    static func configureFor iOS() -> DIContainer {
        let container = DIContainer()
        
        container.register { SharedModule.provideHttpClient() }
        container.register { SharedModule.provideApiClient($0.resolve()) }
        container.register { SharedModule.provideAppointmentService($0.resolve()) }
        container.register { SharedModule.provideAppointmentValidator() }
        
        return container
    }
}

Testing Cross-Platform

Una de las ventajas menos mencionadas: testear la lógica de negocio una sola vez.

// shared/src/commonTest/kotlin/AppointmentValidatorTest.kt
class AppointmentValidatorTest {
    private val validator = AppointmentValidator()
    
    @Test
    fun `should reject appointments in the past`() {
        val pastAppointment = AppointmentRequest(
            startTime = Clock.System.now().minus(1.hours),
            endTime = Clock.System.now().plus(1.hours),
            serviceId = "service-1"
        )
        
        val result = validator.validateAppointment(pastAppointment, emptyList())
        
        assertTrue(result is ValidationResult.Error)
        assertEquals("Cannot book appointments in the past", result.message)
    }
    
    @Test
    fun `should detect time conflicts`() {
        val existingAppointment = Appointment(
            id = "existing-1",
            startTime = Clock.System.now().plus(2.hours),
            endTime = Clock.System.now().plus(3.hours),
            serviceId = "service-1"
        )
        
        val conflictingRequest = AppointmentRequest(
            startTime = Clock.System.now().plus(2.5.hours),
            endTime = Clock.System.now().plus(3.5.hours),
            serviceId = "service-2"
        )
        
        val result = validator.validateAppointment(
            conflictingRequest, 
            listOf(existingAppointment)
        )
        
        assertTrue(result is ValidationResult.Error)
        assertEquals("Time slot already booked", result.message)
    }
    
    @Test
    fun `should accept valid appointments`() {
        val validAppointment = AppointmentRequest(
            startTime = Clock.System.now().plus(2.hours),
            endTime = Clock.System.now().plus(3.hours),
            serviceId = "service-1"
        )
        
        val result = validator.validateAppointment(validAppointment, emptyList())
        
        assertTrue(result is ValidationResult.Success)
    }
}

Beneficio real: Escribes un test, corre en ambas plataformas. Cuando encuentras un bug en iOS, el test asegura que no se repite en Android.

Performance: Números Reales

Métricas de Leonard AI después de migrar a KMP (iOS app con 50K usuarios activos):

Startup Time

  • Antes (Swift nativo): 890ms average cold start
  • Después (KMP): 905ms average cold start (+1.7%)
  • Impacto: Imperceptible para usuarios

Memory Usage

  • Antes: 120MB average memory usage
  • Después: 125MB average memory usage (+4.2%)
  • Impacto: Negligible en dispositivos modernos

Development Velocity

  • Antes: 2 semanas promedio para feature completa (iOS + Android)
  • Después: 1.2 semanas promedio para feature completa
  • ROI: 40% improvement en development speed

Bug Count

  • Antes: 0.8 bugs por feature (logic errors duplicados entre plataformas)
  • Después: 0.3 bugs por feature (shared logic = single source of truth)
  • Quality: 62.5% reduction en logic bugs

Conclusiones Brutalmente Honestas

Después de un año con KMP en producción:

Lo que Funciona Perfectamente

  • Data models: 100% sharing, cero problemas
  • Business logic: 90% sharing, performance nativa
  • API layer: Ktor es sólido como roca
  • Testing: Una suite de tests, dos plataformas
  • Team velocity: Significantly faster development

Lo que Aún Duele

  • Build times: 20% más lentos que proyectos nativos puros
  • Debugging: Stack traces cross-platform siguen siendo confusos
  • iOS integration: Xcode projects con KMP son más frágiles
  • Learning curve: Team Android adapta rápido, team iOS más resistencia

El Verdict Final

KMP en 2026 es production-ready para lógica de negocio y data layer. No es la solution para todo, pero elimina 60-70% de duplicación sin sacrificar performance nativa.

Cuándo usarlo:

  • Apps con lógica de negocio compleja
  • Teams que mantienen iOS + Android
  • Proyectos donde consistency > optimal platform experience

Cuándo no usarlo:

  • Apps simple CRUD con poca lógica
  • Teams specialized en una sola plataforma
  • Apps que requieren platform-specific features extensivamente

El sweet spot: apps business-heavy donde la lógica importa más que las animaciones fancy. Exactamente el tipo de apps que desarrollo para CODX Digital.

En 2026, la pregunta no es “¿funciona KMP?” sino “¿encaja KMP con tu app y tu team?” Para nosotros, después de migrar 200K+ LOC, la respuesta es un rotundo sí.