DEV Community

Kotools
Kotools

Posted on • Originally published at github.com

🧹 Eliminating mapper boilerplate in Kotlin

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)