Adding a Custom Code Generator¶
Kraft's code generation phase is pluggable via a ServiceLoader-based SPI. Instead of (or alongside) the built-in extension function generator, you can provide your own MapperGenerator that consumes the same intermediate representation (MapperDescriptor) and emits any output you need — interface adapters, JSON schemas, documentation, test stubs, or anything else.
The SPI is experimental — opt in first¶
Every SPI type on this page (MapperGenerator, MapperGeneratorProvider, GeneratorEnvironment, GenerationConfig, EnumMapperGeneratorSpi, EnumMapperGeneratorProvider) is marked @ExperimentalKraftApi. The contract: these types are excluded from Kraft's semantic-versioning promise and may change in any release without a major version bump. Annotation users and generated code are never affected — only code that references SPI types in source.
Referencing them without opting in is a compile error. Opt in either per file:
@OptIn(ExperimentalKraftApi::class)
class JsonSchemaGeneratorProvider : MapperGeneratorProvider { /* ... */ }
or module-wide in your generator module's build script:
The samples below assume the module-wide flag.
Data Structure Diagrams¶
SPI Wiring¶
How your generator is discovered and invoked by the processor:
classDiagram
direction LR
class MapperGeneratorProvider {
<<fun interface>>
+create(env) MapperGenerator
}
class GeneratorEnvironment {
+logger: KSPLogger
+options: Map~String, String~
+config: GenerationConfig
+sideRegistry: SideRegistry
}
class GenerationConfig {
+functionNameTemplate: String
+functionNameFor(descriptor) String
}
class MapperGenerator {
<<fun interface>>
+generate(descriptor, codeGenerator)
}
class EnumMapperGeneratorProvider {
<<fun interface>>
+create(env) EnumMapperGeneratorSpi
}
class EnumMapperGeneratorSpi {
<<interface>>
+generate(descriptors, codeGenerator)
+generatedPackage(descriptor) String
+generatedFunctionName(descriptor) String
}
MapperGeneratorProvider --> GeneratorEnvironment : receives
MapperGeneratorProvider --> MapperGenerator : creates
GeneratorEnvironment --> GenerationConfig
MapperGenerator --> MapperDescriptor : consumes
EnumMapperGeneratorProvider --> GeneratorEnvironment : receives
EnumMapperGeneratorProvider --> EnumMapperGeneratorSpi : creates
EnumMapperGeneratorSpi --> EnumMappingDescriptor : consumes
MapperDescriptor — The Central IR¶
The MapperDescriptor is what your generate() method receives. It contains everything needed to emit mapper code for one source-target pair:
classDiagram
direction TB
class MapperDescriptor {
+id: MapperId
+sourceType: TypeInfo
+targetType: TypeInfo
+source: MappingSource
+propertyMappings: List
+nestedMappings: List
+enumMappings: List
+converters: List
+aliasEmitMode: AliasEmitMode
}
class MapperId {
+sourceQualifiedName: String
+targetQualifiedName: String
}
class TypeInfo {
+packageName: String
+simpleName: String
+isNullable: Boolean
+qualifiedName: String
}
class PropertyInfo {
+name: String
+type: TypeInfo
+hasDefault: Boolean
}
class MappingSource {
<<sealed>>
}
class ClassAnnotation {
+annotatedClass: KSClassDeclaration
+direction: MappingDirection
}
class ConfigObject {
+configObject: KSClassDeclaration
}
class NestedMappingDescriptor {
+nestedMapperId: MapperId
+sourceType: TypeInfo
+targetType: TypeInfo
}
class EnumMappingDescriptor {
+sourceType: TypeInfo
+targetType: TypeInfo
+entries: List~EnumEntryMapping~
}
class ConverterDescriptor {
+targetPropertyName: String
+sourcePropertyName: String?
+functionName: String
+isExtension: Boolean
+resolvedDirection: ConverterDirection
}
MapperDescriptor --> MapperId : id
MapperDescriptor --> TypeInfo : sourceType / targetType
MapperDescriptor --> MappingSource : source
MapperDescriptor --> NestedMappingDescriptor : nestedMappings *
MapperDescriptor --> EnumMappingDescriptor : enumMappings *
MapperDescriptor --> ConverterDescriptor : converters *
MappingSource <|-- ClassAnnotation
MappingSource <|-- ConfigObject
NestedMappingDescriptor --> MapperId
NestedMappingDescriptor --> TypeInfo
EnumMappingDescriptor --> TypeInfo
PropertyMappingStrategy Variants¶
Each target property is resolved to exactly one strategy. Your generator iterates descriptor.propertyMappings and handles each variant:
classDiagram
direction TB
class PropertyMappingStrategy {
<<sealed>>
+targetProperty: PropertyInfo
}
class Direct {
+sourceProperty: PropertyInfo
}
class Renamed {
+sourceProperty: PropertyInfo
}
class ConverterFunction {
+source: ConverterSource
+converter: ConverterDescriptor
}
class NestedMapper {
+sourceProperty: PropertyInfo
+nestedMappingDescriptor: NestedMappingDescriptor
}
class ValueClassWrap {
+sourceProperty: PropertyInfo
+valueClass: TypeInfo
+safeCall: Boolean
}
class ValueClassUnwrap {
+sourceProperty: PropertyInfo
+underlyingPropertyName: String
+safeCall: Boolean
}
class ContainerMapping {
+sourceProperty: PropertyInfo
+shape: ContainerShape
}
class Constant {
+expression: String
}
class Ignored
class ConverterSource {
<<sealed>>
}
class Property {
+info: PropertyInfo
}
class WholeObject {
+sourceType: TypeInfo
}
PropertyMappingStrategy <|-- Direct
PropertyMappingStrategy <|-- Renamed
PropertyMappingStrategy <|-- ConverterFunction
PropertyMappingStrategy <|-- NestedMapper
PropertyMappingStrategy <|-- ValueClassWrap
PropertyMappingStrategy <|-- ValueClassUnwrap
PropertyMappingStrategy <|-- ContainerMapping
PropertyMappingStrategy <|-- Constant
PropertyMappingStrategy <|-- Ignored
ConverterFunction --> ConverterSource
ConverterSource <|-- Property
ConverterSource <|-- WholeObject
Ignored really has no fields of its own — the empty box is accurate: it carries only the inherited targetProperty, and the generator simply emits no constructor argument for it.
ValueClassWrap / ValueClassUnwrap (added in 0.14) wrap a value into a value class (UserId(x)) or read its underlying property (x.value). safeCall means the source is nullable. NestedMapper, ValueClassWrap and ValueClassUnwrap always describe a single value.
ContainerMapping maps a container-typed property (List<ItemSource> → List<ItemDto>) level by level. Its shape is a ContainerShape tree that mirrors the property's type:
| Node | Fields | Meaning |
|---|---|---|
ContainerShape.Linear |
sourceKind, targetKind, element |
One single-element container level (List, Set, …), whose elements map by element |
ContainerShape.Keyed |
sourceKind, targetKind, key, value |
One map level; key is always a Leaf |
ContainerShape.Leaf |
op: ElementOp |
The operation applied to each element, key or value |
Every node also carries sourceNullable / targetNullable for its own level, so a generator can safe-call, fall back to an empty container, or drop null elements at each level. ElementOp is one of Identity, Nested(descriptor: NestedMappingDescriptor), Converter(converter: ConverterDescriptor), Wrap(valueClass: TypeInfo) or Unwrap(propertyName: String). The kinds are ContainerKind values. Use shape.nestedDescriptors() (or strategy.nestedDescriptors() on any strategy) to collect the nested mappers a property calls.
The strategy hierarchy is sealed and grows with new features. The generator SPI is @ExperimentalKraftApi for that reason, so expect an exhaustive when to need new branches on upgrade.
SPI change: element-wise mapping moved to ContainerMapping
Before ContainerMapping, element-wise mapping was carried by a nullable collectionKind field (a LIST / SET enum) on NestedMappingDescriptor, ValueClassWrap and ValueClassUnwrap. That field, its enum and NestedMappingDescriptor.isCollection are removed. A generator that read them must handle PropertyMappingStrategy.ContainerMapping instead, and an exhaustive when over strategies needs the new branch.
Step-by-Step Guide¶
1. Create a Gradle Module¶
Create a new JVM module (or standalone project) for your generator:
// my-kraft-generator/build.gradle.kts
plugins {
kotlin("jvm")
}
dependencies {
implementation("com.blu3berry.kraft:kraft-core:<version>")
// kraft-core brings in the KSP API transitively.
// Add KotlinPoet only if your generator needs it — it is NOT required.
}
Note
kraft-core does not depend on KotlinPoet. You are free to use any code-emission strategy — raw string templates, KotlinPoet, or your own writer.
2. Implement MapperGeneratorProvider¶
This is the ServiceLoader entry point. It receives a GeneratorEnvironment and returns your MapperGenerator:
class JsonSchemaGeneratorProvider : MapperGeneratorProvider {
override fun create(environment: GeneratorEnvironment): MapperGenerator {
return JsonSchemaGenerator(
logger = environment.logger,
config = environment.config,
options = environment.options
)
}
}
3. Implement MapperGenerator¶
Your generator receives one MapperDescriptor per source-target pair. Use its fields to emit whatever output you need:
class JsonSchemaGenerator(
private val logger: KSPLogger,
private val config: GenerationConfig,
private val options: Map<String, String>
) : MapperGenerator {
override fun generate(descriptor: MapperDescriptor, codeGenerator: CodeGenerator) {
val targetName = descriptor.targetType.simpleName
val fileName = "${targetName}_schema"
// Open an output file via the KSP CodeGenerator
val file = codeGenerator.createNewFile(
dependencies = Dependencies(aggregating = false),
packageName = "",
fileName = fileName,
extensionName = "json"
)
val schema = buildString {
appendLine("{")
appendLine(""" "type": "object",""")
appendLine(""" "title": "$targetName",""")
appendLine(""" "properties": {""")
descriptor.propertyMappings.forEachIndexed { index, strategy ->
val propName = strategy.targetProperty.name
val propType = strategy.targetProperty.type.simpleName
val comma = if (index < descriptor.propertyMappings.lastIndex) "," else ""
// Map Kotlin types to JSON Schema types
val jsonType = when (propType) {
"String" -> "string"
"Int", "Long" -> "integer"
"Double", "Float" -> "number"
"Boolean" -> "boolean"
else -> "object"
}
appendLine(""" "$propName": { "type": "$jsonType" }$comma""")
}
appendLine(" }")
appendLine("}")
}
file.write(schema.toByteArray())
file.close()
logger.info("Generated JSON schema: $fileName.json")
}
}
4. Handle PropertyMappingStrategy Variants¶
When iterating descriptor.propertyMappings, handle each variant according to your output format:
fun processStrategies(descriptor: MapperDescriptor) {
for (strategy in descriptor.propertyMappings) {
when (strategy) {
is PropertyMappingStrategy.Direct -> {
// Same name, same type: source.x -> target.x
val name = strategy.targetProperty.name
val type = strategy.targetProperty.type
}
is PropertyMappingStrategy.Renamed -> {
// Different name: source.oldName -> target.newName
val sourceName = strategy.sourceProperty.name
val targetName = strategy.targetProperty.name
}
is PropertyMappingStrategy.ConverterFunction -> {
// Custom converter applied
val converter = strategy.converter
val targetName = strategy.targetProperty.name
}
is PropertyMappingStrategy.NestedMapper -> {
// Delegates to a child mapper for a single object
val nested = strategy.nestedMappingDescriptor
}
is PropertyMappingStrategy.ValueClassWrap -> {
// UserId(source.id)
val valueClass = strategy.valueClass
}
is PropertyMappingStrategy.ValueClassUnwrap -> {
// source.id.value
val underlying = strategy.underlyingPropertyName
}
is PropertyMappingStrategy.ContainerMapping -> {
// Element-wise mapping of a List / Set (and later Map) property
val shape = strategy.shape
val nestedMappers = shape.nestedDescriptors()
}
is PropertyMappingStrategy.Constant -> {
// Literal expression. No built-in MappingRule produces this yet,
// but handle it for exhaustiveness — the built-in generator does.
val expr = strategy.expression
}
is PropertyMappingStrategy.Ignored -> {
// Property skipped — has a default value
}
}
}
}
5. (Optional) Implement Enum Generator¶
If you also want to customize enum mapping output, implement the enum SPI:
class MyEnumGeneratorProvider : EnumMapperGeneratorProvider {
override fun create(environment: GeneratorEnvironment): EnumMapperGeneratorSpi {
return MyEnumGenerator(environment.logger, environment.config)
}
}
class MyEnumGenerator(
private val logger: KSPLogger,
private val config: GenerationConfig
) : EnumMapperGeneratorSpi {
override fun generate(
descriptors: List<EnumMappingDescriptor>,
codeGenerator: CodeGenerator
) {
for (descriptor in descriptors) {
// descriptor.sourceType / descriptor.targetType — the enum types
// descriptor.entries — List<EnumEntryMapping> with source/target constant names
}
}
// Must match what generate() actually emits: the converter registry and the
// @KraftConverterDelegate trampolines use these to compute call coordinates.
override fun generatedPackage(descriptor: EnumMappingDescriptor): String =
"${descriptor.sourceType.packageName}.generated"
override fun generatedFunctionName(descriptor: EnumMappingDescriptor): String =
config.functionNameFor(descriptor.sourceType.simpleName, descriptor.targetType.simpleName)
}
6. Register via ServiceLoader¶
Create the service file so AutoMapperProcessor can discover your provider at compile time.
For class mappers, create:
com.example.JsonSchemaGeneratorProvider
For enum mappers (optional), create:
com.example.MyEnumGeneratorProvider
7. Wire into the Consumer Build¶
Add your generator module as a KSP dependency alongside kraft-ksp:
// app/build.gradle.kts
dependencies {
ksp("com.blu3berry.kraft:kraft-ksp:<version>")
ksp("com.example:my-kraft-generator:<version>")
}
Both your generator and the built-in one are on the KSP classpath, but only one MapperGeneratorProvider is used per compilation (see discovery rules below).
GeneratorEnvironment Reference¶
Your provider's create() method receives a GeneratorEnvironment with:
| Field | Type | Description |
|---|---|---|
logger |
KSPLogger |
KSP logger — use info(), warn(), error() for compile-time messages. |
options |
Map<String, String> |
All KSP processor options from build.gradle.kts. Read custom options here. |
config |
GenerationConfig |
Holds the functionNameTemplate (default "to${target}"). Use config.functionNameFor(descriptor) to get the resolved function name. |
sideRegistry |
SideRegistry |
Registered side-alias configs from kraft.side.* KSP args. Call sideRegistry.resolveSide(targetFqn) to find the matching side, or check sideRegistry.sides.isEmpty() to short-circuit when no sides are configured. See Side Aliases. |
Discovery and Fallback Behavior¶
AutoMapperProcessor uses java.util.ServiceLoader to find providers on the KSP classpath:
| Providers found | Behavior |
|---|---|
| 0 | Falls back to the built-in ExtensionMapperGenerator. |
| 1 | Uses that single provider. |
| 2+ | Uses the first provider found and logs a warning. |
The same logic applies independently to EnumMapperGeneratorProvider.
Warning
If you provide a custom MapperGeneratorProvider, it replaces the built-in extension function generator entirely. If you still want extension functions alongside your custom output, call through to ExtensionMapperGenerator from within your generator, or structure your project so they are separate KSP executions.
Tips¶
- Function naming: Use
config.functionNameFor(descriptor)to respect the user'skraft.functionNameFormatKSP option. This keeps your generator consistent with the naming convention users configure. - Incremental processing: Pass originating
KSFilereferences toDependencies(...)when creating output files. This allows KSP to skip regeneration when source files haven't changed. -
Custom KSP options: Read your own options from
environment.options. For example, configure them inbuild.gradle.kts:Then read in your generator:
-
Accessing nested info:
descriptor.nestedDependenciesgives you the set ofMapperIds this mapper depends on — useful if your output format needs to declare imports or references to child mappers. - Collection handling: Container properties are
PropertyMappingStrategy.ContainerMappings. Walkstrategy.shaperecursively:ContainerShape.Linear.sourceKind/targetKindtellListfromSet(and the otherContainerKinds), and eachContainerShape.Leaf.opsays how one element maps.