Skip to content

Kotlin Symbol Processor that auto-generates DTO, Domain and UI models along with mapper functions for your Clean Architecture application

License

Notifications You must be signed in to change notification settings

Timbermir/clean-wizard

Repository files navigation

logo

A Kotlin Symbol Processor that generates classes for Clean Architecture layers

Clean Wizard is a KSP Processor that processes annotations and generates classes for Clean Architecture layers using Kotlinpoet.

Basic Usage

  1. Define your DTOSchema that you want to generate classes from and annotate it with @DTO
@DTO
data class ComputerDTOSchema(
    @SerialName("motherboard")
    val motherboard: MotherboardDTOSchema,
    @SerialName("cpu")
    val cpu: CpuDTOSchema,
    @SerialName("isWorking")
    val isWorking: Boolean
)

@DTO
data class MotherboardDTOSchema(
    @SerialName("name")
    val name: String,
)

@DTO
data class CpuDTOSchema(
    @SerialName("name")
    val name: String,
)
  1. See the result
public data class ComputerDTO(
    @SerialName("motherboard")
    public val motherboard: MotherboardDTO,
    @SerialName("cpu")
    public val cpu: CpuDTO,
    @SerialName("isWorking")
    public val isWorking: Boolean,
)

public fun ComputerDTO.toDomain(): ComputerModel = ComputerModel(
    motherboard.toDomain(),
    cpu.toDomain(), isWorking
)

public data class ComputerModel(
    public val motherboard: MotherboardModel,
    public val cpu: CpuModel,
    public val isWorking: Boolean,
)

public data class ComputerUI(
    public val motherboard: MotherboardUI,
    public val cpu: CpuUI,
    public val isWorking: Boolean,
)

public fun ComputerModel.toUI(): ComputerUI = ComputerUI(
    motherboard.toUI(), cpu.toUI(),
    isWorking
)

Tip

In case your @SerialName annotation value is the same as field name you can just skip adding @SerialName, processor will do it for you, so

@DTO
data class ComputerDTOSchema(
    val motherboard: MotherboardDTOSchema,
    val cpu: CpuDTOSchema,
    val isWorking: Boolean
)

@DTO
data class MotherboardDTOSchema(
    val name: String,
)

@DTO
data class CpuDTOSchema(
    val name: String,
)

will produce the same:

public data class ComputerDTO(
    @SerialName("motherboard")
    public val motherboard: MotherboardDTO,
    @SerialName("cpu")
    public val cpu: CpuDTO,
    @SerialName("isWorking")
    public val isWorking: Boolean,
)

public fun ComputerDTO.toDomain(): ComputerModel = ComputerModel(
    motherboard.toDomain(),
    cpuDTO.toDomain(), isWorking
)

public data class ComputerModel(
    public val motherboard: MotherboardModel,
    public val cpu: CpuModel,
    public val isWorking: Boolean,
)

public data class ComputerUI(
    public val motherboard: MotherboardUI,
    public val cpu: CpuUI,
    public val isWorking: Boolean,
)

public fun ComputerModel.toUI(): ComputerUI = ComputerUI(
    motherboard.toUI(), cpu.toUI(),
    isWorking
)

Generated classes can be found under build package:

build/
  └── generated/
      └── ksp/
          └── main/
              └── corp/
                  └── tbm/
                      └── cleanwizard/
                          ├── computer/
                          │   ├── dto/
                          │   │   └── ComputerDTO.kt
                          │   ├── model/
                          │   │   └── ComputerModel.kt
                          │   └── ui/
                          │       └── ComputerUI.kt
                          ├── motherboard/
                          │   ├── dto/
                          │   │   └── MotherboardDTO.kt
                          │   ├── model/
                          │   │   └── MotherboardModel.kt
                          │   └── ui/
                          │       └── MotherboardUI.kt
                          └── cpu/
                              ├── dto/
                              │   └── CpuDTO.kt
                              ├── model/
                              │   └── CpuModel.kt
                              └── ui/
                                  └── CpuUI.kt

Don't worry, top-level extension functions to map are imported!

import corp.tbm.cleanwizard.computer.model.ComputerModel
import corp.tbm.cleanwizard.cpu.ui.CpuUI
import corp.tbm.cleanwizard.cpu.ui.toUI
import corp.tbm.cleanwizard.motherboard.ui.MotherboardUI
import corp.tbm.cleanwizard.motherboard.ui.toUI
import kotlin.Boolean

public data class ComputerUI(
    public val motherboardUI: MotherboardUI,
    public val cpuUI: CpuUI,
    public val isWorking: Boolean,
)

public fun ComputerModel.toUI(): ComputerUI = ComputerUI(
    motherboardModel.toUI(), cpuModel.toUI(),
    isWorking
)
  1. If you would like to map to domain using some kind of interface, I got you:
@DTO(toDomainAsTopLevel = false)
data class ComputerDTOSchema(
    val motherboard: MotherboardDTOSchema,
    val cpu: CpuDTOSchema,
    val isWorking: Boolean
)

It will produce the following output:

public data class ComputerDTO(
    @SerialName("motherboard")
    public val motherboard: MotherboardDTO,
    @SerialName("cpu")
    public val cpu: CpuDTO,
    @SerialName("isWorking")
    public val isWorking: Boolean,
) : DTOMapper<ComputerModel> {
    override fun toDomain(): ComputerModel = ComputerModel(
        motherboard.toDomain(),
        cpu.toDomain(), isWorking
    )
}

2.1 If your schema has lists, don't worry everything will be mapped

@DTO
data class ComputerDTOSchema(
  @SerialName("motherboard")
  val motherboard: MotherboardDTOSchema,
  @SerialName("cpu")
  val cpu: CpuDTOSchema,
  @SerialName("ram")
  val ram: List<RamDTOSchema>,
  @SerialName("isWorking")
  val isWorking: Boolean
)

@DTO
data class MotherboardDTOSchema(
  @SerialName("name")
  val name: String,
)

@DTO
data class CpuDTOSchema(
  @SerialName("name")
  val name: String,
)

@DTO
data class RamDTOSchema(
  @SerialName("name")
  val name: String,
  val capacity: Int
)
public data class ComputerDTO(
  @SerialName("motherboard")
  public val motherboard: MotherboardDTO,
  @SerialName("cpu")
  public val cpu: CpuDTO,
  @SerialName("ram")
  public val ram: List<RamDTO>,
  @SerialName("isWorking")
  public val isWorking: Boolean,
)

public fun ComputerDTO.toDomain(): ComputerModel = ComputerModel(
  motherboard.toDomain(),
  cpu.toDomain(),
  ram.map { ramDTO -> ramDTO.toDomain() },
  isWorking
)

...

Advanced Usage

  1. You are able to generate enums, however, with only one parameter due to Kotlin annotations limitations. You are not able to use your custom predefined enum, see this issue for details.
@DTO
data class ComputerDTOSchema(
    @SerialName("motherboard")
    val motherboard: MotherboardDTOSchema,
    @SerialName("cpu")
    val cpu: CpuDTOSchema,
    @SerialName("ram")
    val ram: List<RamDTOSchema>,
    @SerialName("isWorking")
    @IntEnum(
        enumName = "ComputerStatus",
        parameterName = "status",
        enumEntries = ["NO_POWER", "DISPLAY_NOT_WORKING", "WORKING", "CPU_PROBLEMS"],
        enumEntryValues = [1, 2, 3, 4]
    )
    val isWorking: Int
)

build/generated/org.orgname/projectname/computer/model/enums/ComputerStatus.kt

public enum class ComputerStatus(
    public val status: Int,
) {
    NO_POWER(status = 1),
    DISPLAY_NOT_WORKING(status = 2),
    WORKING(status = 3),
    CPU_PROBLEMS(status = 4),
    ;
}
public data class ComputerDTO(
    @SerialName("motherboard")
    public val motherboard: MotherboardDTO,
    @SerialName("cpu")
    public val cpu: CpuDTO,
    @SerialName("ram")
    public val ram: List<RamDTO>,
    @SerialName("isWorking")
    public val isWorking: ComputerStatus,
)
...

public data class ComputerModel(
    @SerialName("motherboard")
    public val motherboard: MotherboardModel,
    @SerialName("cpu")
    public val cpu: CpuModel,
    @SerialName("ram")
    public val ram: List<RamModel>,
    @SerialName("isWorking")
    public val isWorking: ComputerStatus,
)

public data class ComputerUI(
    @SerialName("motherboard")
    public val motherboard: MotherboardUI,
    @SerialName("cpu")
    public val cpu: CpuUI,
    @SerialName("ram")
    public val ram: List<RamUI>,
    @SerialName("isWorking")
    public val isWorking: ComputerStatus,
)

1.1 enumName and parameterName properties can be omitted. Property name will be used instead

ComputerDTOSchema.kt

@DTO
data class ComputerDTOSchema(
    @SerialName("motherboard")
    val motherboard: MotherboardDTOSchema,
    @SerialName("cpu")
    val cpu: CpuDTOSchema,
    @SerialName("ram")
    val ram: List<RamDTOSchema>,
    @SerialName("isWorking")
    @IntEnum(
        enumEntries = ["NO_POWER", "DISPLAY_NOT_WORKING", "WORKING", "CPU_PROBLEMS"],
        enumEntryValues = [1, 2, 3, 4]
    )
    val isComputerWorking: Int
)

build/generated/org.orgname/projectname/computer/model/enums/ComputerStatus

public enum class IsComputerWorking(
    public val isComputerWorking: Int,
) {
    NO_POWER(isComputerWorking = 1),
    DISPLAY_NOT_WORKING(isComputerWorking = 2),
    WORKING(isComputerWorking = 3),
    CPU_PROBLEMS(isComputerWorking = 4),
    ;
}

You can see all the available enums available for generation here

2.0 Let's imagine that you want to change the suffix of the DTO classes from DTO to Dto. Using ksp extension's arg("KEY", "value") is not type-safe and map-based, so making mistake in a key is not uncommon.

For this case, clean-wizard introduces the custom extension for passing processor options.

You need to apply clean-wizard plugin to your root build.gradle.kts

Gradle (Groovy) - build.gradle(:project-name)
plugins {
    id 'io.github.timbermir.clean-wizard' version '1.0.0'
}
Gradle (Kotlin) - build.gradle.kts(:project-name)
plugins {
    id("io.github.timbermir.clean-wizard") version "1.0.0"
}

2.1 Use `clean-wizard` extension in your root build.gradle.kts and change the suffix

`clean-wizard` {
    data {
        classSuffix = "DTO"
    }
}

2.2 See the result

build/generated/org.orgname/projectname/computer/dto/ComputerDto.kt

public data class ComputerDto(
  @SerialName("motherboard")
  public val motherboard: MotherboardDto,
  @SerialName("cpu")
  public val cpu: CpuDto,
  @SerialName("ram")
  public val ram: List<RamDto>,
  @SerialName("isComputerWorking")
  public val isComputerWorking: IsComputerWorking,
)

public fun ComputerDto.toModel(): ComputerDomain = ComputerDomain(
    motherboard.toModel(), 
    cpu.toModel(), 
    ram.map { ramDto -> ramDto.toModel() }, 
    isComputerWorking
)

...

Ready-to-use block with all the fields needed

`clean-wizard` {

    jsonSerializer {
        kotlinXSerialization {
            json {
                encodeDefaults = true
                prettyPrint = true
                explicitNulls = false
                @OptIn(ExperimentalSerializationApi::class)
                namingStrategy = JsonNamingStrategy.KebabCase
            }
        }
    }

    dataClassGenerationPattern = CleanWizardDataClassGenerationPattern.LAYER

    dependencyInjection {
        kodein {
            useSimpleFunctions = true
            binding = CleanWizardDependencyInjectionFramework.Kodein.KodeinBinding.Multiton()
        }
    }

    data {
        classSuffix = "DTO"
        packageName = "dtos"
        toDomainMapFunctionName = "toModel"
        interfaceMapper {
            className = "DTOMapper"
            pathToModuleToGenerateInterfaceMapper = projects.workloads.core.dependencyProject.name
        }
    }

    domain {
        classSuffix = "Domain"
        packageName = "models"
        toDTOMapFunctionName = "fromDomain"
        toUIMapFunctionName = "toUI"
        useCase {
            packageName = "useCase"
            useCaseFunctionType = CleanWizardUseCaseFunctionType.CustomFunctionName("execute")
            classSuffix = "UseCase"
        }
    }

    presentation {
        moduleName = "ui"
        classSuffix = "Ui"
        packageName = "uis"
        shouldGenerate = true
        toDomainMapFunctionName = "fromUI"
    }
}

You can see the list of available options here

Setup 🧩

Clean Wizard is available via Maven Central

  1. Add the KSP Plugin

Note: The KSP version you choose directly depends on the Kotlin version your project utilize
You can check https://github.com/google/ksp/releases for the list of KSP versions, then select the latest release that is compatible with your Kotlin version. Example: If you're using 1.9.22 Kotlin version, then the latest KSP version is 1.9.22-1.0.17.

Gradle (Groovy) - build.gradle(:module-name)
plugins {
    id 'com.google.devtools.ksp' version '1.9.22-1.0.17'
}
Gradle (Kotlin) - build.gradle.kts(:module-name)
plugins {
    id("com.google.devtools.ksp") version "1.9.22-1.0.17"
}
  1. Add dependencies
Gradle (Groovy) - build.gradle(:module-name)
dependencies {
    implementation 'io.github.timbermir.clean-wizard:clean-wizard:1.0.0'
    ksp 'io.github.timbermir.clean-wizard:data-class-compiler:1.0.0'
}
Gradle (Kotlin) - build.gradle.kts(:module-name)
dependencies {
    implementation("io.github.timbermir.clean-wizard:clean-wizard:1.0.0")
    ksp("io.github.timbermir.clean-wizard:data-class-compiler:1.0.0")
}
  1. (Optional) Apply clean-wizard plugin for custom processor options
Gradle (Groovy) - build.gradle(:project-name)
plugins {
    id 'io.github.timbermir.clean-wizard' version '1.0.0'
}
Gradle (Kotlin) - build.gradle.kts(:project-name)
plugins {
    id("io.github.timbermir.clean-wizard") version "1.0.0"
}

Current Processor limitations 🚧

  • SUPPORTS data class generation only in a single module, in other words you can't generate DTOs for data module, or Models for domain module, they are generated in module where DTOSchema is located
  • SUPPORTS only kotlinx-serialization-json
  • DOES NOT support enums, collections or any custom type but the source ones
  • DOES NOT support inheriting other annotations
  • DOES NOT support inheriting @SerialName value if present, generated @SerialName value is derived from field's name
  • DOES NOT support backwards mapping, i.e., from model to DTO
  • DOES NOT support custom processor options, i.e., change DTO classes suffix to Dto
  • DOES NOT support multiplatform
  • DOES NOT support Room entity generation, therefore no TypeConverters generation
  • DOES NOT utilize Incremental processing
  • DOES NOT utilize Multiple round processing

Building

It is recommended to use the latest released version of IntelliJ IDEA (Community or Ultimate Edition). You can download IntelliJ IDEA here.

The project relies on Gradle as its main build tool. Currently used version is 8.9. IntelliJ will try to find it among the installed Gradle Versions or download it automatically if it couldn't be found.

The project requires JDK 19 to build classes and to run tests. Gradle will try to find it among the installed JDKs or provision it automatically if it couldn't be found.

For local builds, you can use an earlier or later version of JDK if you don't have that version installed. Specify the version of this JDK with the jdk property in project-config.versions.toml.

After that, Gradle will download all dependencies the project depends on. Run the processor via Main.kt

On Windows, you might need to add long paths setting to the repository:

git config core.longpaths true

The errors related to inline properties usage in build.gradle.kts files can occur when IntelliJ IDEA cannot resolve Target JVM Version for Kotlin Compiler, causing it to fall back to the default 1.8. To resolve the errors, follow these steps

  1. Navigate to Settings -> Build, Execution, Deployment -> Compiler -> Kotlin Compiler
  2. Set the Target JVM Version to match the jdk property specified in project-config.versions.toml.

License

clean-wizard is distributed under the terms of the Apache License (Version 2.0). See the license for more information.