Ejemplos avanzados — modo embedded y modo contract¶
Dos ejemplos grandes que combinan las funcionalidades, uno por modo. No son
pseudocódigo: el harness los compila tal cual en los tests
los ejemplos avanzados del modo embedded compilan y ... del modo contract compilan
(AdvancedExamplesGuideTest.kt) — si esta guía se desactualiza, el build lo dice.
- Modo embedded (ex modo A, estilo JPA/Jackson): la config vive embebida en el modelo.
- Modo contract (ex modo B, estilo MapStruct/DDD): el mapeo es un contrato — una interfaz en la capa de infraestructura; el dominio queda sin una sola anotación kmapx.
Modo embedded — catálogo de e-commerce¶
Combina: @MapTo repetible con name, política de mapeo onNull = TYPE_DEFAULT (colecciones
y escalares), lista ignore de mapeo + ignore per-field, @MapField con sus
tres aspectos (ruta anidada, converter calificado, LITERAL), TARGET_DEFAULT,
value classes, @Converter global vs calificado (paso 0 del orden de resolución), @MapFactory,
sealed con @MapSubtype, enums con @MapEntry, @BiMapTo, colecciones y
Map en una pasada.
import dev.kmapx.annotations.*
import dev.kmapx.annotations.contract.*
import dev.kmapx.annotations.embedded.*
import dev.kmapx.runtime.Converts
// ── Value classes — se (des)envuelven solas ──
@JvmInline value class ProductId(val value: String)
// ── Converter GLOBAL — cualquier Long→String sin calificar pasa por aquí ──
@Converter
fun centsToDisplay(cents: Long): String =
"$" + (cents / 100) + "." + (cents % 100).toString().padStart(2, '0')
// ── Converter CALIFICADO — elegido POR CAMPO; gana sobre el global (paso 0) ──
object PriceTag : Converts<Long, String> {
override fun convert(value: Long): String = "PRICE:" + value
}
// ── Enums paralelos BIDIRECCIONALES — la vuelta invierte los @MapEntry sola ──
enum class CategoryDto { ELECTRONICS, BOOKS, HOME_GARDEN }
@BiMapTo(CategoryDto::class) // ida y vuelta: HOME_GARDEN → HOME_AND_GARDEN se deriva
enum class Category {
ELECTRONICS,
BOOKS,
@MapEntry(target = "HOME_GARDEN") HOME_AND_GARDEN,
}
// ── Factory privada Y bidireccional ──
data class WarehouseDto(val code: String, val capacity: Int)
@BiMapTo(WarehouseDto::class) // Ida y vuelta; ambas construyen vía of(...)
class Warehouse private constructor(val code: String, val capacity: Int) {
companion object {
@MapFactory
fun of(code: String, capacity: Int) = Warehouse(code.uppercase(), capacity)
}
}
// ── Sealed paralelas — when exhaustivo SIN else; @MapSubtype redirige nombres distintos ──
sealed interface StockEventDto {
data class Restocked(val units: Int) : StockEventDto
data object OutOfStock : StockEventDto
}
@MapTo(StockEventDto::class)
sealed interface StockEvent {
@MapSubtype(StockEventDto.Restocked::class)
data class Replenished(val units: Int) : StockEvent
data object OutOfStock : StockEvent
}
// ── Anidados: el par declarado resuelve dentro de colecciones ──
data class AddressDto(val city: String)
@MapTo(AddressDto::class)
data class Address(val city: String)
data class Supplier(val name: String, val address: Address)
// ── El DTO: @MapField concentra los tres aspectos de cada campo ──
data class ProductDto(
val id: String, // ProductId desenvuelta
val name: String,
// Rename + converter calificado en UNA anotación
@MapField(from = "priceCents", converter = PriceTag::class) val price: String,
// Este Long usa el @Converter GLOBAL (no está calificado)
@MapField(from = "priceCents") val priceDisplay: String,
// Ruta anidada — se navega y valida en compile-time
@MapField(from = "supplier.address.city") val supplierCity: String,
// onNull per-field: LITERAL con parseo tipado
@MapField(onNull = OnNull.LITERAL, default = "N/A") val notes: String,
// Si la fuente es null se OMITE el argumento → aplica el default
@MapField(onNull = OnNull.TARGET_DEFAULT) val discount: Int = 0,
// Excluido deliberadamente — exige default; silencia KMX002/KMX021
@MapField(ignore = true) val internalNotes: String? = null,
// Estos dos NO declaran nada: los cierra la política de MAPEO de abajo (TYPE_DEFAULT)
val tags: List<String>, // ?: emptyList()
val rating: Double, // ?: 0.0
val category: CategoryDto, // enum dispatch
val restock: StockEventDto, // sealed dispatch
val stops: List<AddressDto>, // elemento a elemento
val stockByCode: Map<String, Int>, // Map passthrough
val internalCode: String? = null, // lo excluye la LISTA ignore del @MapTo
)
data class ProductSummary(
@MapField(from = "supplier.address.city") val supplierCity: String, // cada mapeo declara su ruta
val name: String,
)
// ── La entidad: DOS mapeos desde una clase (repeatable + name), con política e ignore de MAPEO ──
@MapTo(ProductDto::class, onNull = OnNull.TYPE_DEFAULT, ignore = ["internalCode"])
@MapTo(ProductSummary::class, name = "asSummary")
data class Product(
val id: ProductId,
val name: String,
val priceCents: Long,
val supplier: Supplier,
val notes: String?,
val discount: Int?,
val tags: List<String>?, // → emptyList() por la política del mapeo
val rating: Double?, // → 0.0 (escalares en la lista cerrada)
val category: Category,
val restock: StockEvent,
val stops: List<Address>,
val stockByCode: Map<String, Int>,
)
Genera Product.toProductDto() y Product.asSummary() en ProductMappings.kt, when
exhaustivos para sealed/enum, y Warehouse.toWarehouseDto() más WarehouseDto.toWarehouse()
por el @BiMapTo — ambas direcciones construyendo vía la factory, con invertibilidad validada
(KMX028 si no cerrara). Los overrides @MapEntry también participan de @BiMapTo: la vuelta
los invierte sola (CategoryDto.HOME_GARDEN → Category.HOME_AND_GARDEN); el fallback de CLASE
es fan-in — varios entries hacia uno — y ahí @BiMapTo sigue siendo KMX028.
La cascada en acción (campo > mapeo > global): notes/discount declaran su
salida per-field y GANAN; tags/rating no declaran nada y caen a la política del mapeo
(TYPE_DEFAULT); si tampoco existiera, caerían al kmapx.onNull global del bloque kmapx { };
agotada la cascada, T? → T vuelve a ser el error KMX003. Un
@MapField(onNull = OnNull.STRICT) explícito CORTA la cascada para ese campo.
Modo contract — banca con dominio 100% limpio¶
Combina: @MapperConfig (profile: componentModel, política y ignore corporativos),
@Mapper(config, inheritFrom) (herencia de config por método), interfaz MIXTA con patch
por FORMA y Patch<T> tri-estado, @InverseOf con auto-detección,
converter INYECTADO como bean, parámetros suplementarios, post-funciones after<Método>
y @MapField en sede de método.
import dev.kmapx.annotations.*
import dev.kmapx.annotations.contract.*
import dev.kmapx.annotations.embedded.*
import dev.kmapx.runtime.Converts
import dev.kmapx.runtime.Patch
// ── DOMINIO: ni una anotación kmapx (el punto del modo contract) ──
data class Customer(
val id: String,
val name: String,
val nickname: String?,
val email: String,
val riskScore: Int,
)
// ── DTOs / requests: también limpios ──
data class CustomerDto(
val id: String,
val displayName: String,
val nickname: String,
val email: String?,
val riskScore: Int,
)
data class CustomerView(val id: String, val riskLabel: String)
data class AccountSummary(val name: String, val audit: String? = null)
data class CreateCustomerRequest(val name: String, val nickname: String?, val riskScore: Int, val email: String)
data class CustomerPatch(
val name: String?, // null = no tocar
val email: Patch<String>, // tri-estado: Keep / Set(v) / Set(null)
)
// ── Converter inyectado — una CLASS con dependencias, no un object ──
@org.springframework.stereotype.Component
class RiskLabeler : Converts<Int, String> {
override fun convert(value: Int): String = if (value > 70) "HIGH" else "LOW"
}
// ── El profile corporativo — settings en UN lugar, no genera código ──
@MapperConfig(
componentModel = ComponentModel.SPRING, // todos los mappers del profile son @Component
onNull = OnNull.THROW, // nivel PROFILE de la cascada
ignore = ["audit"], // Campos de auditoría fuera, en toda la empresa
)
interface CompanyMapperConfig
// ── Mapper base — su config por método se hereda por NOMBRE ──
@Mapper(onNull = OnNull.THROW) // el base es válido POR SÍ MISMO (genera su propio impl)
interface BaseCustomerMapper {
@MapField(target = "displayName", from = "name")
@MapField(target = "nickname", onNull = OnNull.LITERAL, default = "-")
fun toDto(c: Customer): CustomerDto
}
// ── El mapper: interfaz MIXTA — mapping + patch + inverso conviven ──
@Mapper(config = CompanyMapperConfig::class, inheritFrom = BaseCustomerMapper::class)
interface CustomerMapper {
// MAPPING — hereda el rename displayName del base; su @MapField de nickname
// PISA al heredado ("N/A" gana sobre "-": la config propia gana por campo destino).
@MapField(target = "nickname", onNull = OnNull.LITERAL, default = "N/A")
fun toDto(c: Customer): CustomerDto
// MAPPING con converter INYECTADO: la impl SPRING lo recibe por constructor.
@MapField(target = "riskLabel", from = "riskScore", converter = RiskLabeler::class)
fun toView(c: Customer): CustomerView
// MAPPING con targets heterogéneos: aquí SÍ existe "audit" → el ignore del profile aplica
// (en los targets sin ese campo, simplemente no hace nada).
fun toSummary(c: Customer): AccountSummary
// MAPPING con parámetros SUPLEMENTARIOS: id lo genera el servicio, se empareja por nombre.
fun create(request: CreateCustomerRequest, id: String): Customer
// PATCH por FORMA — retorno == tipo del PRIMER parámetro (@PatchMapper no existe):
// name String? = no tocar; email Patch<String> = tri-estado con when exhaustivo.
fun applyPatch(target: Customer, patch: CustomerPatch): Customer
// El INVERSO de toDto, auto-detectado (única firma inversa). Renombres se invierten
// (name = displayName); el widening email String? → String lo cierra el THROW del profile.
@InverseOf
fun fromDto(dto: CustomerDto): Customer
// Post-funciones — (source, result) para mapping, (target, patch, result) para patch.
fun afterToDto(source: Customer, result: CustomerDto): CustomerDto =
result.copy(displayName = result.displayName.trim())
fun afterApplyPatch(target: Customer, patch: CustomerPatch, result: Customer): Customer = result
}
Genera class CustomerMapperImpl(riskLabeler: RiskLabeler) : CustomerMapper anotada
@Component (el profile lo decidió), con el copy() del patch, el when del tri-estado, la
vuelta invertida de fromDto y las post-funciones aplicadas sobre cada resultado.
Qué valida el compilador aquí: config sin @MapperConfig = KMX044; dos @InverseOf
mutuos o firma no-inversa = KMX045; ignore inexistente en TODOS los targets = KMX011 con
did-you-mean; el inverso de un campo con converter sin inverso registrado = KMX028; LITERAL
sin default = KMX038; patch sobre un target que no es data class = KMX012.
Los dos modos conviven¶
Si el par exacto ya tiene extension embedded declarada, el método contract delega en ella
(la extension es la fuente de verdad): declarar @MapTo(CustomerDto::class) sobre
Customer haría que CustomerMapperImpl.toDto emita return c.toCustomerDto() en vez de
materializar el plan inline. Un solo motor, dos superficies.
| Decisión | embedded | contract |
|---|---|---|
| ¿Dónde vive la config? | En el modelo (@MapTo, @MapField en el campo) |
En la interfaz (@MapField(target=) en el método) |
| ¿Cuándo elegirlo? | DTOs propios, ergonomía primero | DDD/hexagonal, modelos de terceros, dominio limpio |
| Analogía | JPA / Jackson / kotlinx.serialization | MapStruct (@Mapper, @MapperConfig, inversos) |
| Bidireccional | @BiMapTo |
@InverseOf |
| Reutilización | política e ignore por @MapTo |
@MapperConfig (profiles) + inheritFrom |