Patrones Idiomáticos de Kotlin

LeadFlow SaaS B2B — Módulo de gestión de leads refactorizado · CULTIVA IA

13 patrones aplicados
Resumen de la refactorización
5
Value classes
para IDs y tipos de dominio
0
Usos de !! (force-unwrap)
eliminados del módulo
13
Patrones idiomáticos
aplicados en el módulo
4
Scope functions distintas
usadas (let, apply, also, run)
Antes vs. Después — Null Safety
❌ Antes (Java-style Kotlin)
// NPE en producción fun getLeadEmail(id: String): String { val lead = repository.findById(id) return lead!!.email!! // 💥 NullPointerException } // Clase mutable — sin seguridad class Lead { var id: String? = null var email: String = "" var status: String = "NEW" // magic string } // GlobalScope — sin control de ciclo de vida GlobalScope.launch { fetchLeads() }
✅ Después (Kotlin idiomático)
// Safe-call + Elvis — sin NPE fun getLeadDisplayName(lead: Lead?): String = lead?.name?.takeIf { it.isNotBlank() } ?: lead?.email?.value?.substringBefore('@') ?: "Lead desconocido" // Data class inmutable — val por defecto data class Lead(val id: LeadId, val email: Email, // value class validada val status: LeadStatus) // enum tipado // Structured concurrency — supervisorScope suspend fun buildDashboard(...) = supervisorScope { val user = async { fetchUser() } val leads = async { fetchLeads() } }
Patrones aplicados al módulo LeadFlow
🔑

Value Classes

IDs tipados sin coste en runtime

@JvmInline value class LeadId(val value: String) { init { require(value.isNotBlank()) } } @JvmInline value class Email(val value: String) { init { require('@' in value) } } // Sin riesgo de confundir IDs entre sí fun findLead(id: LeadId): Lead?
🔒

Sealed Interface

Errores de dominio exhaustivos

sealed interface LeadError { val message: String data class NotFound(val id: LeadId) : LeadError data class DuplicateEmail(val email: Email): LeadError data class InvalidTransition(...): LeadError } // when exhaustivo — el compilador avisa fun LeadError.toHttpStatus() = when (this) { is LeadError.NotFound -> 404 is LeadError.DuplicateEmail -> 409 is LeadError.Unauthorized -> 401 }
🛡️

runCatching / Result<T>

Errores como valores, no excepciones

suspend fun createLead( name: String, email: String ): Result<Lead> = runCatching { require(name.isNotBlank()) val validEmail = Email(email) repository.save(Lead(name, validEmail)) } // Chain idiomático val name = createLead(...) .map { it.name } .getOrElse { "Desconocido" }

supervisorScope

Concurrencia estructurada tolerante a fallos

suspend fun buildDashboard( id: LeadId ): LeadDashboard = supervisorScope { // Paralelo — fallo no cancela otros val related = async { runCatching { repo.findRelated(id) } .getOrDefault(emptyList()) } val campaigns = async { ... } LeadDashboard(related.await(), ...) }
🏗️

DSL con @DslMarker

Builders type-safe y autocomplete

@DslMarker annotation class CampaignDsl fun emailCampaign( init: EmailCampaignBuilder.() -> Unit ): EmailCampaign = EmailCampaignBuilder().apply(init).build() // Uso expresivo — sin magic strings val campaign = emailCampaign { name = "Bienvenida LeadFlow" subject = "{{name}}, bienvenido 🚀" targetStatus(LeadStatus.NEW) maxDailyEmails = 500 }
🔌

Extension Functions

Comportamiento sin herencia

// Lógica de dominio en la clase sin modificarla fun Lead.isHot(): Boolean = score.value >= 75 && status in setOf(QUALIFIED, CONTACTED) fun Lead.qualify(s: QualificationScore): Lead = copy(score = s, status = QUALIFIED) // Extensiones sobre colecciones fun List<Lead>.hotLeads() = filter { it.isHot() } .sortedByDescending { it.score.value }
Colecciones idiomáticas — pipeline de leads
leads.asSequence()
.filter { !stale }
.filter { email valid }
.sortedByDescending { score }
.groupBy { status }
.mapValues { toShortString() }
Map<LeadStatus, List<String>>
Scope functions y Flow reactivo
Scope functions — cuándo usar cada una
// let: transformar nullable → resultado val hotEmail = leads.hotLeads().firstOrNull()?.let { "[HOT] ${it.email}" } // apply: configurar y devolver el mismo objeto val report = StringBuilder().apply { appendLine("=== Reporte LeadFlow ===") leads.forEach { appendLine(it.toShortString()) } } // also: side effect sin romper el flujo val qualified = leads.filter { it.status == QUALIFIED } .also { log("Procesando ${it.size} leads") } // run: bloque con receptor → resultado val summary = leads.run { "Hot: ${hotLeads().size} / Total: $size" }
Flow reactivo — búsqueda con debounce
// Cold flow con operadores idiomáticos fun searchLeads( query: Flow<String> ): Flow<List<Lead>> = query .debounce(300) // esperar pausa al escribir .distinctUntilChanged() // solo si cambió .filter { it.length >= 2 } // min 2 caracteres .mapLatest { q -> repo.findAll() .filter { q in it.name } } .catch { emit(emptyList()) }
Tabla de referencia rápida
Patrón Uso en LeadFlow Beneficio
value class LeadId, Email, QualificationScore Type-safety sin overhead de runtime
data class (val) Lead, EmailCampaign, LeadDashboard Inmutabilidad — sin bugs de estado compartido
copy() qualify(), contact(), addTag() Actualizaciones seguras sin mutar el objeto original
sealed interface LeadError + toHttpStatus() when exhaustivo — el compilador detecta casos olvidados
?. ?: getLeadDisplayName(), isHot() Null safety garantizada por el compilador — 0 NullPointerException
runCatching createLead(), qualifyLead() Errores como valores — Result<T> chaineable
supervisorScope buildDashboard() Paralelo tolerante a fallos — secundarios no cancelan el principal
@DslMarker emailCampaign { ... } API expresiva y type-safe — no acepta lambdas anidadas ilegales
extension fun isHot(), hotLeads(), toShortString() Comportamiento de dominio sin herencia ni modificar data class
asSequence() processLeadsPipeline() Evaluación lazy — sin listas intermedias en memoria
let/apply/also/run scopeFunctionExamples() Código expresivo sin variables temporales innecesarias
by lazy allLeadsSummary Inicialización diferida — solo se calcula cuando se necesita
Flow + debounce searchLeads(), observeHotLeads() Streams reactivos cold — sin RxJava ni callbacks anidados