Every backend eventually hits the same wall: a domain concept needs a different shape at every layer it touches. One model becomes three, and someone has to write — and maintain — the code that translates between them.
😩 The problem
In a typical Kotlin backend, a single concept like User needs a parallel data structure to be persisted:
data class User(val id: UUID, val email: String)
data class UserEntity(val id: String, val email: String)
fun User.toUserEntity(): UserEntity = UserEntity(id.toString(), email)
fun UserEntity.toUser(): User = User(UUID.fromString(id), email)
Two data classes for one concept, plus two functions whose only job is to shuttle values between them. Multiply that by every layer a domain model crosses — HTTP request/response, persistence entity, another service's contract — and the mapper functions start to outnumber the domain logic they surround.
This pattern leads to:
- Boilerplate code — mappers, extension functions, and redundant data classes.
- Scattered logic — business rules spread across layers instead of living on the domain.
- Maintenance burden — a single field change ripples through every layer.
🔍 Why this happens
The root cause isn't carelessness — it's that today's tooling gives no other option. A data class can only have one shape. If persistence needs id as a String and the domain needs it as a UUID, something outside that class has to reconcile the difference. That something is a mapper function, written by hand, reviewed by hand, and updated by hand every time the domain model changes.
The mapper isn't just glue code — it's a place where business rules quietly accumulate, disconnected from the model they describe.
✨ A domain-first alternative
Kotools Facet is exploring a different starting point: keep the domain model as the only model, and let it declare — on itself — how each layer should see it.
@Faceted
data class User(val id: UUID, val email: String) {
companion object : FacetHost<User> {
val entity: BidirectionalFacet<User> by bidirectionalFacet {
map(
property = User::id,
transformOutput = { it.toString() },
transformInput = { UUID.fromString(it) }
)
}
}
}
// Generates:
// - UserEntity data class
// - User.toUserEntity() and UserEntity.toUser() extension functions
No UserEntity written by hand, no mapper functions to keep in sync — the projection is declared once, on User itself, and the glue code is generated at compile time. The rule that id is a UUID on the domain and a String in persistence lives in exactly one place: the model.
This is still early-stage design, not a shipped feature — Kotools Facet is in active development. But the direction is set: eliminate the mapper, not by hiding it better, but by never needing to hand-write it.
🚀 What's next
Kotools Facet is commercial software currently in early development. To get notified when it ships and discuss licensing, join the waiting list by sending an email to contact@kotools.org with the subject "Kotools Facet – Waiting List".
Follow the project's progress on the kotools/facet repository.
Top comments (0)