Introduction
This guide walks through KReplica's API, codegen output, and common patterns. KReplica is a DTO generator, which lets you define multiple DTO variants from one interface, among other things.
Preface
If you're new to KReplica, I implore you to check the Playground as well. Since this is a code generation tool, I believe it’s very useful to see a side-by-side view of the input and codegen output.
Setup
To start, simply add the KSP and KReplica plugins to your module's build.gradle.kts
file.
plugins {
// Use a KSP version compatible with your Kotlin version
id("com.google.devtools.ksp") version "..."
id("io.availe.kreplica") version "5.0.0"
}
API Stability
- Overall: Beta
- API Annotations: Stable*
- Codegen Output: Beta**
*It’s under consideration whether auto nominal typing should be removed. Please let me know if you found it useful.
**The core codegen output itself (sealed interface hierarchy to create DATA, PATCH, and CREATE variants) is stable. The 'local and global' variants concept, however, is considered beta. I'm currently satisfied with them, but they can be subject to change if need be.
In retrospect, KReplica should've stayed longer as a pre-1.0.0 library — and in many ways it should be considered as one. That said, the core design has stabilized by now, even if the peripheries change.
KReplica follows SemVer strictly. As such, a v6.0.0 is already planned, even though it's only the removal of an undocumented public API (it's not listed in the docs).
If fully expect KReplica to stabilize with v6.0.0 or v7.0.0, with the latter being the potential removal of the nominal typing feature. But I will hold up on marking the library as stable until it's actually tested in additional projects.
API Annotations
This section covers the 5 annotations present in KReplica. Note the first 3 annotations are the important ones, with the latter two being more niche, if not entirely optional, in nature.
@Replicate.Model
This is the primary annotation that marks an interface for DTO generation. It defines the default behavior for all properties within the interface.
-
variants: Array<DtoVariant>
(Required)An array specifying which DTO variants to generate. Possible values areDtoVariant.DATA
,DtoVariant.CREATE
, andDtoVariant.PATCH
. -
nominalTyping: NominalTyping
(Default:NominalTyping.DISABLED
)WhenENABLED
, wraps primitive properties in type-safe value classes. -
autoContextual: AutoContextual
(Default:AutoContextual.ENABLED
)WhenENABLED
, automatically adds@Contextual
to non-primitive properties if thekotlinx.serialization
plugin is detected.
package io.availe.demo.playground
import io.availe.Replicate
import io.availe.models.DtoVariant
@Replicate.Model(
variants = [DtoVariant.DATA, DtoVariant.CREATE, DtoVariant.PATCH]
)
private interface UserProfile {
val id: Int
val username: String
}
// Generated by KReplica. Do not edit.
package io.availe.demo.playground
import io.availe.models.KReplicaCreateVariant
import io.availe.models.KReplicaDataVariant
import io.availe.models.KReplicaPatchVariant
import io.availe.models.Patchable
import kotlin.Int
import kotlin.String
/**
* A sealed hierarchy representing all variants of the UserProfile data model.
*/
public sealed interface UserProfileSchema {
public data class Data(
public val id: Int,
public val username: String,
) : UserProfileSchema,
KReplicaDataVariant<UserProfileSchema>
public data class CreateRequest(
public val id: Int,
public val username: String,
) : UserProfileSchema,
KReplicaCreateVariant<UserProfileSchema>
public data class PatchRequest(
public val id: Patchable<Int> = Patchable.Unchanged,
public val username: Patchable<String> = Patchable.Unchanged,
) : UserProfileSchema,
KReplicaPatchVariant<UserProfileSchema>
}
@Replicate.Property
Provides fine-grained control over a single property, allowing you to override the settings defined in the
model-level @Replicate.Model
annotation.
-
include: Array<DtoVariant>
(Default: empty array)Explicitly includes the property only in the specified variants. If used, the model-levelvariants
are ignored for this property. -
exclude: Array<DtoVariant>
(Default: empty array)Excludes the property from the specified variants. -
nominalTyping: NominalTyping
(Default:NominalTyping.INHERIT
)Overrides the model-levelnominalTyping
setting for this specific property. -
autoContextual: AutoContextual
(Default:AutoContextual.INHERIT
)Overrides the model-levelautoContextual
setting for this specific property.
package io.availe.demo.playground
import io.availe.Replicate
import io.availe.models.DtoVariant
import java.util.UUID
@Replicate.Model(variants = [DtoVariant.DATA, DtoVariant.CREATE])
private interface UserAccount {
@Replicate.Property(exclude = [DtoVariant.CREATE])
val id: UUID
val email: String
}
// Generated by KReplica. Do not edit.
package io.availe.demo.playground
import io.availe.models.KReplicaCreateVariant
import io.availe.models.KReplicaDataVariant
import java.util.UUID
import kotlin.String
/**
* A sealed hierarchy representing all variants of the UserAccount data model.
*/
public sealed interface UserAccountSchema {
public data class Data(
public val id: UUID,
public val email: String,
) : UserAccountSchema,
KReplicaDataVariant<UserAccountSchema>
public data class CreateRequest(
public val email: String,
) : UserAccountSchema,
KReplicaCreateVariant<UserAccountSchema>
}
@Replicate.Apply
Applies other annotations (like @Serializable
) to the generated DTO classes. This is useful for
annotations that cannot be applied to interfaces or for applying an annotation to only a subset of generated
variants.
-
annotations: Array<KClass<out Annotation>>
(Required)Specifies the annotation classes to apply to the generated DTOs. -
include: Array<DtoVariant>
(Default: empty array)If provided, the annotations will only be applied to the DTO variants listed in this array. -
exclude: Array<DtoVariant>
(Default: empty array)The annotations will be applied to all generated variants except for those listed in this array.
package io.availe.demo.playground
import io.availe.Replicate
import io.availe.models.DtoVariant
import kotlinx.serialization.Serializable
@Replicate.Model(variants = [DtoVariant.DATA])
@Replicate.Apply([Serializable::class])
private interface Product {
val id: Int
val name: String
}
// Generated by KReplica. Do not edit.
package io.availe.demo.playground
import io.availe.models.KReplicaDataVariant
import kotlin.Int
import kotlin.String
import kotlinx.serialization.Serializable
/**
* A sealed hierarchy representing all variants of the Product data model.
*/
@Serializable
public sealed interface ProductSchema {
@Serializable
public data class Data(
public val id: Int,
public val name: String,
) : ProductSchema,
KReplicaDataVariant<ProductSchema>
}
@Replicate.SchemaVersion
Manually specifies a version number for a DTO schema. This is only needed if you choose not to follow the V<number>
naming convention for versioned interfaces.
-
number: Int
(Required)The integer to assign as the version number for the schema.
package io.availe.demo.playground
import io.availe.Replicate
import io.availe.models.DtoVariant
private interface Account {
@Replicate.Model(variants = [DtoVariant.DATA])
private interface V1 : Account {
val id: Int
}
@Replicate.Model(variants = [DtoVariant.DATA])
@Replicate.SchemaVersion(2)
private interface CurrentAccount : Account {
val id: Int
val email: String
}
}
// Generated by KReplica. Do not edit.
package io.availe.demo.playground
import io.availe.models.KReplicaDataVariant
import kotlin.Int
import kotlin.String
/**
* A sealed interface hierarchy representing all versions of the Account data model.
*/
public sealed interface AccountSchema {
public sealed interface DataVariant : AccountSchema
/**
* --------------------------
* | Version 1 (V1) |
* --------------------------
*/
public sealed interface V1 : AccountSchema {
public data class Data(
public val id: Int,
public val schemaVersion: Int = 1,
) : V1,
DataVariant,
KReplicaDataVariant<V1>
}
/**
* --------------------------
* | Version 2 (CurrentAccount) |
* --------------------------
*/
public sealed interface CurrentAccount : AccountSchema {
public data class Data(
public val id: Int,
public val email: String,
public val schemaVersion: Int = 2,
) : CurrentAccount,
DataVariant,
KReplicaDataVariant<CurrentAccount>
}
}
@Replicate.Hide
Disables code generation for the annotated model. This can be useful for temporarily excluding a model from the build process without deleting the source code. This annotation has no parameters.
package io.availe.demo.playground
import io.availe.Replicate
import io.availe.models.DtoVariant
@Replicate.Model(variants = [DtoVariant.DATA])
@Replicate.Hide
private interface InternalFeature {
val featureId: Int
}
@Replicate.Model(variants = [DtoVariant.DATA])
private interface PublicFeature {
val id: Int
}
// Generated by KReplica. Do not edit.
package io.availe.demo.playground
import io.availe.models.KReplicaDataVariant
import kotlin.Int
/**
* A sealed hierarchy representing all variants of the PublicFeature data model.
*/
public sealed interface PublicFeatureSchema {
public data class Data(
public val id: Int,
) : PublicFeatureSchema,
KReplicaDataVariant<PublicFeatureSchema>
}
API Concepts
This section covers the same topics as “API Annotations,” but instead of focusing on syntax and parameters, it explores the underlying concepts and rationale.
DTO Versioning
KReplica makes API evolution safer and more explicit through its versioning system. By nesting interfaces that
follow a simple V<number>
naming convention (e.g., private interface V1 : MySchema
),
KReplica automatically groups them into a single, version-aware sealed hierarchy.
This generated sealed interface ensures that when you handle different DTOs, your when
expressions can
be exhaustive. If you introduce a new version (e.g., V2
), the Kotlin compiler will produce an error
until you explicitly handle the new version's variants, preventing runtime errors from forgotten migration paths.
If you prefer not to use the naming convention, you can achieve the same result by manually assigning a version
number with the @Replicate.SchemaVersion(number = ...)
annotation.
package io.availe.demo.playground
import io.availe.Replicate
import io.availe.models.DtoVariant
private interface UserAccount {
@Replicate.Model(variants = [DtoVariant.DATA])
private interface V1 : UserAccount {
val id: Int
}
@Replicate.Model(variants = [DtoVariant.DATA, DtoVariant.PATCH])
private interface V2 : UserAccount {
val id: Int
val email: String
}
// manually assign version number
@Replicate.Model(variants = [DtoVariant.DATA, DtoVariant.PATCH])
@Replicate.SchemaVersion(3)
private interface NewModel : UserAccount {
val id: Int
val username: String
val email: String
}
}
// Generated by KReplica. Do not edit.
package io.availe.demo.playground
import io.availe.models.KReplicaDataVariant
import io.availe.models.KReplicaPatchVariant
import io.availe.models.Patchable
import kotlin.Int
import kotlin.String
/**
* A sealed interface hierarchy representing all versions of the UserAccount data model.
*/
public sealed interface UserAccountSchema {
public sealed interface DataVariant : UserAccountSchema
public sealed interface PatchRequestVariant : UserAccountSchema
/**
* --------------------------
* | Version 1 (V1) |
* --------------------------
*/
public sealed interface V1 : UserAccountSchema {
public data class Data(
public val id: Int,
public val schemaVersion: Int = 1,
) : V1,
DataVariant,
KReplicaDataVariant<V1>
}
/**
* --------------------------
* | Version 2 (V2) |
* --------------------------
*/
public sealed interface V2 : UserAccountSchema {
public data class Data(
public val id: Int,
public val email: String,
public val schemaVersion: Int = 2,
) : V2,
DataVariant,
KReplicaDataVariant<V2>
public data class PatchRequest(
public val id: Patchable<Int> = Patchable.Unchanged,
public val email: Patchable<String> = Patchable.Unchanged,
public val schemaVersion: Patchable<Int> = Patchable.Unchanged,
) : V2,
PatchRequestVariant,
KReplicaPatchVariant<V2>
}
/**
* --------------------------
* | Version 3 (NewModel) |
* --------------------------
*/
public sealed interface NewModel : UserAccountSchema {
public data class Data(
public val id: Int,
public val username: String,
public val email: String,
public val schemaVersion: Int = 3,
) : NewModel,
DataVariant,
KReplicaDataVariant<NewModel>
public data class PatchRequest(
public val id: Patchable<Int> = Patchable.Unchanged,
public val username: Patchable<String> = Patchable.Unchanged,
public val email: Patchable<String> = Patchable.Unchanged,
public val schemaVersion: Patchable<Int> = Patchable.Unchanged,
) : NewModel,
PatchRequestVariant,
KReplicaPatchVariant<NewModel>
}
}
Nominal Typing
Proposed Deprecation
While automatically creating value classes for primitives seems impressive, I have come to view it as a potential antipattern. Since, I've come to believe that value types should be specified in the domain object-level, not the DTO-level. I'd instead recommend the Compile-Safe API Mapper pattern instead as a means of providing safety.
Perhaps this feature might find some use for those that prefer a "flat model" where DTOs and domain models are one and the same. If that's the case, it's nice to know that there are a number of edge cases KReplica's nominal typing can handle. For more information, check the corresponding playground example.
That said, this feature is being considered for removal. Feedback on this feature would be very much appreciated — especially if you should be fond of it.
This feature's name is actually a bit of a misnomer, a more accurate name would be "auto nominally typed primitives." What it does is that it lets you type in primitively typed (Int, Double, String, etc.) variables in your KReplica declarations, and in the codegen output, they're automatically converted to inline value classes in the codegen output.
The benefit of this is that it prevents the accidental misuse of primitive types. For example, a function signature
like
fun process(orderId: Long, customerId: Long)
offers no protection against swapping the two IDs, as both
are structurally just Long
values.
Nominal typing solves this by creating distinct types that are not interchangeable. When enabled, a property like
val customerId: Long
becomes val customerId: CustomerId
, where CustomerId
is
a
new @JvmInline value class CustomerId(val value: Long)
. This provides compile-time safety, ensuring a
CustomerId
can never be used where an OrderId
is expected.
package io.availe.demo.playground
import io.availe.Replicate
import io.availe.models.DtoVariant
import io.availe.models.NominalTyping
@Replicate.Model(variants = [DtoVariant.DATA], nominalTyping = NominalTyping.ENABLED)
private interface Order {
val id: Long
val customerId: Long
@Replicate.Property(nominalTyping = NominalTyping.DISABLED)
val totalAmount: Double
}
// Generated by KReplica. Do not edit.
package io.availe.demo.playground
import io.availe.models.KReplicaDataVariant
import kotlin.Double
import kotlin.Long
import kotlin.jvm.JvmInline
/**
* A sealed hierarchy representing all variants of the Order data model.
*/
public sealed interface OrderSchema {
public data class Data(
public val id: OrderId,
public val customerId: OrderCustomerId,
public val totalAmount: Double,
) : OrderSchema,
KReplicaDataVariant<OrderSchema>
}
/**
* VALUE CLASSES
*/
@JvmInline
public value class OrderCustomerId(
public val `value`: Long,
)
@JvmInline
public value class OrderId(
public val `value`: Long,
)
Applying Annotations
You can directly add annotations to KReplica declarations, either at the model-level or the property-level. However,
since KReplica model declarations are interfaces, in some cases you cannot directly apply certain annotations to it.
For example, the @Serializable
annotation from kotlinx.serialization
must be applied to a class, not an interface. Additionally, you
might want to apply an annotation like @Deprecated
to only the CREATE
variant of a DTO,
but not the DATA
variant.
The @Replicate.Apply
annotation solves these problems. It instructs KReplica to add specified
annotations directly to the generated data classes. You can apply annotations to all variants by default or target
specific variants using its include
and exclude
parameters.
Auto-Contextualization
The kotlinx.serialization library oftentimes requires the usage of the @Contextual annotation, which tells kotlinx.serialization that a custom serializer is available for said given type. This custom serializer is usually provided by the library's maintainer, meaning no action is needed besides inserting the @Contextual annotation.
Normally, the IDE will help you identify which variables require @Contextual. Unfortunately,
kotlinx.serialization's
@Serializable
annotation cannot be directly applied on interfaces. This thus is why @Replicate.Apply
exists in the first place.
Via @Replicate.Apply
, we can still mark our KReplica annotations as @Serializable
. However,
this mean we no longer get IDE help
to determine whether or not a property requires @Contextual.
To get around this, KReplica automatically applies the @Contextual annotations to properties if it declares that kotlinx serialization is applied to said KReplica declaration. This feature is enabled by default.
You can disable this behavior globally by setting autoContextual = AutoContextual.DISABLED
in the
@Replicate.Model
annotation, or control it on a per-property basis using
@Replicate.Property
.
package io.availe.demo.playground
import io.availe.Replicate
import io.availe.models.AutoContextual
import io.availe.models.DtoVariant
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import java.time.Instant
@Replicate.Model(
variants = [DtoVariant.DATA],
autoContextual = AutoContextual.ENABLED
)
@Replicate.Apply([Serializable::class])
private interface Event {
val id: Int
val timestamp: Instant
@Replicate.Property(autoContextual = AutoContextual.DISABLED)
@Contextual
val manualTimestamp: Instant
}
// Generated by KReplica. Do not edit.
package io.availe.demo.playground
import io.availe.models.KReplicaDataVariant
import java.time.Instant
import kotlin.Int
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
/**
* A sealed hierarchy representing all variants of the Event data model.
*/
@Serializable
public sealed interface EventSchema {
@Serializable
public data class Data(
public val id: Int,
public val timestamp: @Contextual Instant,
@Contextual
public val manualTimestamp: Instant,
) : EventSchema,
KReplicaDataVariant<EventSchema>
}
Understanding the Generated Code
This section covers the codegen output of KReplica, and how to understand it.
Schemas
In KReplica, an unversioned schema is a sealed interface that defines all possible DTO shapes (such as Data, Patch, and Create) for a given model.
Meanwhile, a version schema uses a two-layer nested model. The outer layer contains all the inner schemas, with each
inner schema being a separate version. By convention, each inner schema follows a a V<number> naming
convention, but you can assign custom names as long as a
version number is provided with @SchemaVersion
.
DATA
variant of a generated UserAccountSchema
:
- Unversioned:
UserAccountSchema.Data
- Versioned:
UserAccountSchema.V1.Data
public sealed interface <SchemaName>Schema {
public data class Data(...) : <SchemaName>Schema, ...
public data class CreateRequest(...) : <SchemaName>Schema, ...
public data class PatchRequest(...) : <SchemaName>Schema, ...
}
public sealed interface <SchemaName>Schema {
public sealed interface V1 : <SchemaName>Schema {
public data class Data(...) : V1, ...
public data class CreateRequest(...) : V1, ...
}
public sealed interface V2 : <SchemaName>Schema {
public data class Data(...) : V2, ...
public data class PatchRequest(...) : V2, ...
}
}
The Patchable Wrapper
When handling PATCH or update requests, a common challenge is distinguishing between fields that were omitted (and
should remain unchanged) and fields that were explicitly set to null
. For example, if your DTO includes
a property like description
, how can you tell whether the client wanted to update it, clear it, or
leave it as is? Making the property nullable introduces ambiguity: does null
mean “clear this field” or
“don’t change it”?
KReplica solves this with the Patchable<T>
sealed class. In KReplica PatchRequest
variants, all properties are wrapped in Patchable<T>
, making it
explicit whether a field should be updated, cleared, or left unchanged.
By default, each property in a PatchRequest
is set to Patchable.Unchanged
, so you only
need to specify the fields you want to update.
/**
* Used by generated PATCH request code to mark fields as updated or unchanged.
* Use Set(value) to update a field, or Unchanged to leave it as-is.
*/
sealed class Patchable<out T> {
data class Set<out T>(val value: T) : Patchable<T>()
data object Unchanged : Patchable<Nothing>()
}
/**
* Used by generated serializable PATCH request code to mark fields as updated or unchanged.
* Use Set(value) to update a field, or Unchanged to leave it as-is.
*/
@Serializable
sealed class SerializablePatchable<out T> {
@Serializable
data class Set<out T>(val value: T) : SerializablePatchable<T>()
@Serializable
data object Unchanged : SerializablePatchable<Nothing>()
}
This wrapper provides two explicit states:
-
Patchable.Set(value)
Use this to update a property with a new value. For example, if you haveval email: Patchable<String>
you can set it null withPatchable.Set(null)
. -
Patchable.Unchanged
Use this to leave a property unchanged. This is the default for all properties in aPatchRequest
and signifies the property was not sent and should be ignored during the update.
If a schema is marked as serializable, KReplica will automatically utilize the serializable version of this wrapper.
Local Variants
For each KReplica schema, KReplica generates “local variant” marker interfaces: DataVariant
, CreateRequestVariant
,
and PatchRequestVariant
. Local variants are only generated as needed. For example, if the schema does
not include a CREATE
variant, it will not generate the CreateRequestVariant
local variant.
public sealed interface <SchemaName>Schema {
// Local variant markers
public sealed interface DataVariant : <SchemaName>Schema
public sealed interface CreateRequestVariant : <SchemaName>Schema
public sealed interface PatchRequestVariant : <SchemaName>Schema
// Generated DTOs
public data class Data(...) : <SchemaName>Schema, DataVariant, ...
public data class CreateRequest(...) : <SchemaName>Schema, CreateRequestVariant, ...
public data class PatchRequest(...) : <SchemaName>Schema, PatchRequestVariant, ...
}
public sealed interface <SchemaName>Schema {
// Local variant markers for all versions
public sealed interface DataVariant : <SchemaName>Schema
public sealed interface CreateRequestVariant : <SchemaName>Schema
public sealed interface PatchRequestVariant : <SchemaName>Schema
public sealed interface V1 : <SchemaName>Schema {
// V1 DTOs implement local variants
public data class Data(...) : V1, DataVariant, ...
public data class CreateRequest(...) : V1, CreateRequestVariant, ...
}
public sealed interface V2 : <SchemaName>Schema {
// V2 DTOs also implement local variants
public data class Data(...) : V2, DataVariant, ...
public data class PatchRequest(...) : V2, PatchRequestVariant, ...
}
}
This allows for exhaustive when
expressions in your code.
Global Variants
Every schema generated by KReplica includes a “global” variant interface, such as
KReplicaDataVariant<V>
.
Unlike local variants, which are tied to a specific schema, global variants are shared across all KReplica schemas.
public sealed interface <SchemaName>Schema {
// Local variant: for matching all Data in this schema
public sealed interface DataVariant : <SchemaName>Schema
// Data DTO implements both the local variant and the global variant (KReplicaDataVariant)
public data class Data(...) : <SchemaName>Schema, DataVariant, KReplicaDataVariant<<SchemaName>Schema>
}
public sealed interface <SchemaName>Schema {
// Local variant: for matching all Data in this schema (across versions)
public sealed interface DataVariant : <SchemaName>Schema
public sealed interface V1 : <SchemaName>Schema {
// Data DTO for V1 implements the local variant and the global variant (KReplicaDataVariant)
public data class Data(...) : V1, DataVariant, KReplicaDataVariant<V1>
}
public sealed interface V2 : <SchemaName>Schema {
// Data DTO for V2 also implements the local variant and the global variant (KReplicaDataVariant)
public data class Data(...) : V2, DataVariant, KReplicaDataVariant<V2>
}
// ...more versions as needed
}
This makes it possible to write generic code that works with any KReplica-generated model.
Core Patterns & Use Cases
These are some recommended patterns. "Patterns" are not part of KReplica directly, but instead show how you can use Kotlin features with KReplica.
Exhaustive `when` Statements
A neat feature of Kotlin is its exhaustive when
expressions: when you use a sealed class or sealed
interface, the compiler forces you to handle every possible subtype.
KReplica takes advantage of this by generating DTOs like:
public sealed interface <SchemaName>Schema
This design is especially helpful when making use of versioned DTOs.
fun handleAllDataVariants(data: UserAccountSchema.DataVariant) {
when (data) {
is UserAccountSchema.V1.Data -> println("Handle V1 Data: ${data.id}")
is UserAccountSchema.V2.Data -> println("Handle V2 Data: ${data.email}")
}
}
fun handleV2Variants(user: UserAccountSchema.V2) {
when (user) {
is UserAccountSchema.V2.CreateRequest -> println("Handle V2 Create: ${user.email}")
is UserAccountSchema.V2.Data -> println("Handle V2 Data: ${user.id}")
is UserAccountSchema.V2.PatchRequest -> println("Handle V2 Patch")
}
}
fun handleAllUserTypes(user: UserAccountSchema) {
when (user) {
is UserAccountSchema.V1.CreateRequest -> println("Handle V1 Create")
is UserAccountSchema.V1.Data -> println("Handle V1 Data")
is UserAccountSchema.V1.PatchRequest -> println("Handle V1 Patch")
is UserAccountSchema.V2.CreateRequest -> println("Handle V2 Create")
is UserAccountSchema.V2.Data -> println("Handle V2 Data")
is UserAccountSchema.V2.PatchRequest -> println("Handle V2 Patch")
}
}
You can write when
statements that are:
- Version-specific – handle all variants for a single version (e.g., all V2 DTOs).
- Variant-specific – handle the same variant type across all versions (e.g., all Data types).
- Schema-wide – handle every possible DTO in the schema.
kotlinx.serialization Integration
Integrating KReplica with kotlinx.serialization
is straightforward, but it requires the @Replicate.Apply
annotation as @Serializable
cannot be applied directly to interfaces.
You can tell KReplica to serialize a DTO by simply typing: @Replicate.Apply([Serializable::class])
.
package io.availe.demo.patterns
import io.availe.Replicate
import io.availe.models.DtoVariant
import kotlinx.serialization.Serializable
@Replicate.Model(variants = [DtoVariant.DATA, DtoVariant.CREATE])
@Replicate.Apply([Serializable::class])
private interface Product {
@Replicate.Property(exclude = [DtoVariant.CREATE])
val id: Int
val name: String
val price: Double
}
// Generated by KReplica. Do not edit.
package io.availe.demo.patterns
import io.availe.models.KReplicaCreateVariant
import io.availe.models.KReplicaDataVariant
import kotlin.Double
import kotlin.Int
import kotlin.String
import kotlinx.serialization.Serializable
/**
* A sealed hierarchy representing all variants of the Product data model.
*/
@Serializable
public sealed interface ProductSchema {
@Serializable
public data class Data(
public val id: Int,
public val name: String,
public val price: Double,
) : ProductSchema,
KReplicaDataVariant<ProductSchema>
@Serializable
public data class CreateRequest(
public val name: String,
public val price: Double,
) : ProductSchema,
KReplicaCreateVariant<ProductSchema>
}
Note KReplica has auto-contextualization enabled by default. You can disable this model-wide or on a per-property
basis if
needed. Disabling it is likely the best practice if you intend to use @Serializable(with = ...)
instead
of
@Contextual
.
Compile-Safe DTO Mapper
KReplica only generates DTOs, so how you implement mapping is left up to you. However, here is a mapping pattern which I'm keen to. The point is to ensure compile-time guarantees in this specific scenario: You utilized Kreplica to generate the 3 DTO variants (data, create, patch) for a CRUD API. Furthermore, there is a specific `id` which can be used for mapping.
Now, the entire point of the generic interface below (check "The Reusable Example" in the code snippet below) is to create a systematized approach that implements three methods:
- toDataDto: Maps domain object -> the DATA variant
- toDomain: Maps the DATA variant -> Domain object
- applyPatch: Maps the PATCH variant -> Domain object
Note that in the example below, the V
type parameter prevents mixing DTOs from different schema
versions at compile time. For versioned
schemas, use something like UserAccountSchema.V1
or UserAccountSchema.V2
. For unversioned
schemas, use the schema type itself, such as UserAccountSchema
. This keeps variants aligned and blocks
from occurring.
For this pattern, I strongly discourage nullable properties. KReplica’s three variants are meant to reduce the need for nullables by including only the fields relevant to each operation. For example, say that you have an immutable `id` that's created by a database, but not directly by your program. Only the DATA variant should have access to the `id` field, not the CREATE or PATCH variants.
This is because in this example, the Kotlin compiler forces you to map required properties, but allows you to skip nullable properties. And forgetting to map a field can lead to inadvertent data loss. If you need to model a field which may or may not be present, I'd recommend using an option type instead of a nullable. Note Kotlin/KReplica does not have an option type built in — as, although I recommend this pattern, this is outside the immediate scope of the library.
import io.availe.models.KReplicaCreateVariant
import io.availe.models.KReplicaDataVariant
import io.availe.models.KReplicaPatchVariant
interface ApiSchemaMapper<
M,
ID,
V : Any,
D : KReplicaDataVariant<V>,
C : KReplicaCreateVariant<V>,
P : KReplicaPatchVariant<V>
> {
fun toDataDto(model: M): D
fun toDomain(id: ID, dto: C): M
fun applyPatch(model: M, patch: P): M
}
package io.availe.demo.domain
import java.util.UUID
data class UserModel(
val id: UUID,
val firstName: String,
val lastName: String,
val email: String,
) {
init {
require(firstName.isNotBlank()) { "firstName cannot be blank" }
require(lastName.isNotBlank()) { "lastName cannot be blank" }
}
}
package io.availe.demo.models
import io.availe.Replicate
import io.availe.models.DtoVariant
import java.util.UUID
private interface UserAccount {
@Replicate.Model(
variants = [DtoVariant.DATA, DtoVariant.PATCH, DtoVariant.CREATE],
)
private interface V1 : UserAccount {
@Replicate.Property(exclude = [DtoVariant.CREATE])
val id: UUID
val firstName: String
val lastName: String
val email: String
}
}
package io.availe.demo.mapping
import io.availe.demo.domain.UserModel
import io.availe.demo.models.UserAccountSchema
import io.availe.models.Patchable
import java.util.UUID
object UserV1Mapper : ApiSchemaMapper<
UserModel,
UUID,
UserAccountSchema.V1,
UserAccountSchema.V1.Data,
UserAccountSchema.V1.CreateRequest,
UserAccountSchema.V1.PatchRequest,
> {
override fun toDataDto(model: UserModel): UserAccountSchema.V1.Data =
UserAccountSchema.V1.Data(
id = model.id,
firstName = model.firstName,
lastName = model.lastName,
email = model.email,
)
override fun toDomain(id: UUID, dto: UserAccountSchema.V1.CreateRequest): UserModel =
UserModel(
id = id,
firstName = dto.firstName.trim(),
lastName = dto.lastName.trim(),
email = dto.email.trim(),
)
override fun applyPatch(model: UserModel, patch: UserAccountSchema.V1.PatchRequest): UserModel =
model.copy(
firstName = (patch.firstName as? Patchable.Set)?.value?.trim() ?: model.firstName,
lastName = (patch.lastName as? Patchable.Set)?.value?.trim() ?: model.lastName,
email = (patch.email as? Patchable.Set)?.value?.trim() ?: model.email,
)
}
If it helps, the 1st tab ("The Reusable Pattern") is the generic interface that is the actual pattern. Tabs 2 and 3 are provided for context, but are not that important (they show a mock domain model and mock KReplica declaration). The 4th tab ("The Implementation") shows an example of how you can actually apply the pattern shown in the 1st tab.
Frequently Asked Questions
Can a @Replicate.Property
have a broader replication than its @Replicate.Model
?
No. The restriction of all properties must be a subset of the parent model's variants. This ensures fail-fast feedback. If you restrict a parent's replication but forget to update a child property, you'll get an immediate build-time error instead of a silent failure.
If a @Replicate.Model
has another @Replicate.Model
as a field, does the order of
compilation matter?
No. KReplica uses a two-pass compilation strategy. It first generates stub files for all
@Replicate.Model
declarations to make their types known, then performs the main compilation. This ensures that nested
models resolve correctly regardless of file order.
Why do all the examples use the private
keyword (private interface
)?
The private
keyword is not required, but it's a recommended practice. The source interfaces
are only for KReplica's use; your application code will interact with the generated DTOs. Making them
private prevents them from polluting the global namespace. This is especially useful for versioned
schemas, as it allows you to nest versions (e.g., private interface V1 : UserAccount
)
inside a parent scope, avoiding naming collisions.