Skip to content
My Guidelines
Guides
In this guide (16 items)

Kotlin Code Style

Kotlin Code Style

MANDATORY FOR ALL AI AGENTS: When generating Kotlin code, you MUST follow every convention below. Pay special attention to: fun interface usage for single-method ports, extension functions for mappers, nullable vs lateinit rules, and data class vs class selection criteria. Domain and application layers MUST remain framework-free.

This document defines concrete conventions for this repository. Where not specified, follow the official Kotlin Coding Conventions and Spring Boot recommendations.

For Java equivalents of these patterns, see Java Code Style.

Targets

  • Readability first: prefer clarity over brevity.
  • Domain purity: domain and use cases are free from framework dependencies.
  • Deterministic formatting: enforced by tooling. Spotless (ktlint) for formatting auto-fix; Detekt for static analysis (code smells, complexity, naming). See gradle.md §4.1 / §4.1.1 for plugin config and CI integration.

Tooling split

  • Spotless (spotlessApply) — formatting only. Runs automatically after every Kotlin file edit via PostToolUse hook (~/.claude/hooks/claude-spotless-apply.sh).
  • Detekt (detekt) — static analysis: complexity thresholds (LongMethod ≤10, LongParameterList ≤3, CyclomaticComplexMethod ≤8), ReturnCount ≤2, MaxLineLength 120, MagicNumber, WildcardImport, detekt-formatting (ktlint rules as findings). Apply ONLY to Kotlin subprojects. Wire into check task so CI fails on violations. Use baseline.xml for legacy code intake.
  • ArchUnit — architectural boundaries (layer leak detection). Required separately; Detekt does not cover hexagonal rules.

Formatting

  • Max line length: 120. Break method chains sensibly.
  • Indentation: 4 spaces. No tabs.
  • Braces: always use braces for control flow.
  • Imports: no wildcard imports. Keep sorted.
  • Annotations: one per line when parameters present; otherwise inline is fine.

Naming

  • Packages: all lowercase, no underscores (base package: com.example.platform).
  • Classes/Objects: PascalCase (InvoiceUseCase, InvoiceEntity).
  • Functions/Properties: lowerCamelCase (createInvoice).
  • Constants: UPPER_SNAKE_CASE in companion object or top-level const val.
  • DTOs vs Entities suffixes: Request/Response for controllers/GraphQL; Entity for persistence; Mapper for mapping classes; Adapter for adapters; Port for ports.

Null-safety & types

  • Avoid platform types. Prefer non-null types; use nullable only when necessary.
  • Use data class ONLY for DTOs, request/response payloads, and pure value objects. NEVER for an aggregate root or entitycopy(...) lets callers bypass the entity’s invariants and transition guards. For aggregate roots / entities, use a regular class with private constructor + companion object factory + intent-revealing transition methods. See “data class vs regular class” rules below and ddd.md §11.1.
  • Prefer sealed classes/enums for closed hierarchies (InvoiceStatus, events).
  • Prefer Result/Either-like patterns or domain-specific exceptions for failures at boundaries; avoid returning null for error signaling.

When to use data class vs regular class:

Anti-pattern: anemic domain. A domain entity that is only fields + getters/setters is a data bag, not an entity. Per DDD and clean-code, entities OWN their invariants and state transitions. If you find yourself writing data class XEntity(val a, val b, ...) with no methods and the logic lives in a service or use case, you have an anemic domain. Either (a) it is a DTO — rename it XRequest / XResponse / XPayload and move it out of domain/, or (b) it is an entity — convert to a regular class, mark the constructor private, expose a companion object factory, and put the behavior on it.

  • Use data class for DTOs, request/response payloads, and pure value objects (with init { require(...) } for validation). NEVER for an aggregate root.

    // ✅ DTO — lives in application or infrastructure, NOT domain
    data class UserRequest(
    val email: String,
    val firstName: String,
    val lastName: String,
    )
    // ✅ Value object — single concept, validated, immutable
    data class Email(val value: String) {
    init { require(value.matches(EMAIL_REGEX)) { "invalid email: $value" } }
    private companion object { val EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex() }
    }

    Benefits: Auto-generated equals(), hashCode(), toString(), copy(). Use when the type is structurally compared and carries no domain behavior.

  • Use regular class for rich domain entities, aggregate roots, or any model that owns behavior:

    // ✅ Aggregate root — invariants enforced, behavior on the entity, private ctor + factory
    class User private constructor(
    val id: UserId,
    val email: Email,
    val firstName: String,
    val lastName: String,
    val status: UserStatus,
    val version: Long,
    ) {
    init {
    require(firstName.isNotBlank()) { "firstName required" }
    require(lastName.isNotBlank()) { "lastName required" }
    }
    fun rename(firstName: String, lastName: String): User =
    User(id, email, firstName, lastName, status, version + 1)
    fun deactivate(): User {
    require(status == UserStatus.ACTIVE) { "only ACTIVE can deactivate" }
    return User(id, email, firstName, lastName, UserStatus.INACTIVE, version + 1)
    }
    fun isActive(): Boolean = status == UserStatus.ACTIVE
    companion object {
    fun register(id: UserId, email: Email, firstName: String, lastName: String): User =
    User(id, email, firstName, lastName, UserStatus.PENDING, version = 0)
    }
    }

    Benefits: Explicit factory boundary, no accidental copy() that skips invariants, behavior co-located with state — the entity is the only place that knows how it can change.

lateinit vs nullable properties:

  • Avoid lateinit var when possible. It can throw UninitializedPropertyAccessException at runtime.

  • Prefer constructor injection or nullable properties with explicit null checks:

    // ❌ BAD - lateinit can throw at runtime
    class OrganizationCacheAdapter : OrganizationRepositoryPort, ChainablePort<OrganizationRepositoryPort> {
    private lateinit var delegate: OrganizationRepositoryPort // What if never initialized?
    override fun findById(id: UUID): Organization? {
    return delegate.findById(id) // Can throw UninitializedPropertyAccessException
    }
    }
    // ✅ BETTER - nullable with explicit check
    class OrganizationCacheAdapter : OrganizationRepositoryPort, ChainablePort<OrganizationRepositoryPort> {
    private var delegate: OrganizationRepositoryPort? = null
    override fun setDelegate(delegate: OrganizationRepositoryPort) {
    this.delegate = delegate
    }
    override fun findById(id: UUID): Organization? {
    return delegate?.findById(id)
    ?: throw IllegalStateException("Delegate repository not initialized")
    }
    }
    // ✅ BEST - constructor injection (eliminates the problem)
    @Component
    class OrganizationCacheAdapter(
    private val redisTemplate: RedisTemplate<String, Any>,
    @Qualifier("organizationJdbcRepository") private val delegate: OrganizationRepositoryPort
    ) : OrganizationRepositoryPort {
    override fun findById(id: UUID): Organization? {
    val cached = redisTemplate.opsForValue().get("org:$id")
    return cached as? Organization ?: delegate.findById(id)
    }
    }
  • Only use lateinit when:

    1. The property will definitely be initialized before use (e.g., Spring @Autowired in tests)
    2. You cannot use constructor injection (rare in modern Kotlin/Spring)
    3. The property is a var primitive type that doesn’t support null

fun interface (SAM - Single Abstract Method):

  • Use fun interface for single-method contracts (functional interfaces):

    // ✅ GOOD - fun interface for single method
    fun interface TransactionPort<R> {
    fun run(callback: TransactionCallbackPort<R>): R
    }
    fun interface TransactionCallbackPort<R> {
    fun call(): R
    }
    // Usage - can use lambda syntax
    val result = transaction.run { repository.save(entity) }
  • Use regular interface when multiple methods are needed:

    // ✅ GOOD - regular interface for multiple methods
    interface OrganizationRepositoryPort {
    fun save(organization: Organization): Organization
    fun update(organization: Organization): Organization
    fun findById(id: UUID): Organization?
    }
  • Benefits of fun interface:

    • Can be implemented with lambda expressions
    • More concise and functional
    • Perfect for callbacks and strategy patterns

Extension Functions for Mappers:

  • Use extension functions for mapping between layers. Never bypass the aggregate’s invariants — mappers call factories (create for new aggregates, rehydrate for persistence reads), never the private constructor.

    // In CommandMapper.kt (application layer) — creates a NEW aggregate via factory
    fun CreateOrganizationCommand.toOrganization(clock: Clock): Organization =
    Organization.create(
    id = UUID.randomUUID(),
    name = OrganizationName(this.name), // value object validates
    contactEmail = Email(this.contactEmail),
    clock = clock,
    )
    // In EntityJdbcMapper.kt (infrastructure layer) — REHYDRATES from persistence.
    // The aggregate exposes a separate `rehydrate(...)` factory that trusts the row
    // (skips factory-only invariants like "version starts at 0") but still validates
    // structural ones. This is the ONLY caller allowed to rebuild an arbitrary state.
    fun OrganizationEntity.toDomain(): Organization =
    Organization.rehydrate(
    id = this.id!!,
    name = OrganizationName(this.name),
    contactEmail = Email(this.email),
    status = OrganizationStatus.valueOf(this.status),
    version = this.version,
    // ... timestamps, audit fields
    )
    fun Organization.toEntityForInsert(): OrganizationEntity = OrganizationEntity().apply {
    this.id = this@toEntityForInsert.id
    this.name = this@toEntityForInsert.name.value // value-object → primitive
    this.isNew = true // For Spring Data JDBC Persistable
    // ...
    }
    // Usage
    val organization = command.toOrganization(clock)
    val domain = entity.toDomain()
    val entity = domain.toEntityForInsert()

    Why a separate rehydrate(...) factory. create(...) enforces “this is a brand-new aggregate” (version=0, status=initial, createdAt=now). When loading from the DB, none of those hold — but the entity still must refuse to be reconstructed in a structurally invalid state. rehydrate(...) is the named, intentional escape hatch used ONLY by JDBC/JPA mappers; it lives in the same companion object as create(...) and is the ONLY way an arbitrary state combination is built without going through a transition.

  • Benefits:

    • Natural, fluent API
    • Keeps mappers in dedicated files
    • Easy to discover and use
    • No static utility classes needed

Kotlin-specific anti-patterns to avoid:

  1. ❌ Using !! (force unwrap) - can throw NPE

    val id = organization.id!! // NPE if null

    ✅ Use null-safe operators instead:

    val id = organization.id ?: throw IllegalStateException("Organization ID must not be null")
  2. ❌ Overusing var - prefer immutability

    var status = OrganizationStatus.ACTIVE // Mutable

    ✅ Use val by default:

    val status = OrganizationStatus.ACTIVE // Immutable
  3. ❌ Platform types from Java interop

    val user = javaService.getUser() // Type is User! (platform type)

    ✅ Explicitly declare nullability:

    val user: User? = javaService.getUser()
  4. ❌ Not using scope functions appropriately

    val entity = OrganizationEntity()
    entity.id = organization.id
    entity.name = organization.name
    entity.isNew = true

    ✅ Use apply for object configuration:

    val entity = OrganizationEntity().apply {
    id = organization.id
    name = organization.name
    isNew = true
    }
  5. ❌ Nullable collections instead of empty collections

    val items: List<String>? = null // Can be null OR empty

    ✅ Use non-null empty collections:

    val items: List<String> = emptyList() // Never null, can be empty

Error handling

  • Domain: throw domain-specific exceptions or return Result types. Do not use Spring exceptions in domain/application layers.
  • Entry points: translate exceptions to HTTP/GraphQL error responses in handlers.
  • Outbound adapters: map DB/messaging errors to infrastructure exceptions and rethrow as application/domain-relevant exceptions.

Logging

  • Use SLF4J logger per class: private val log = LoggerFactory.getLogger(Class::class.java).
  • Log at boundaries. Do not log sensitive data. Use structured messages and include correlation IDs when available.
  • Levels: DEBUG for detailed flow; INFO for lifecycle events; WARN for recoverable issues; ERROR for failures that bubble up.

Validation

  • Entry points perform request validation (e.g., Bean Validation). Map to domain types only after validation passes.
  • Domain invariants are validated inside constructors/factory methods of domain models and use cases.

Collections & immutability

  • Prefer immutable collections at boundaries. Use val by default; var only when necessary.

Coroutines & blocking

  • Current project is blocking (JDBC, Kafka). If introducing coroutines, ensure adapters are non-blocking; avoid mixing unless isolated.

Mapping

  • Keep mappers pure and in mappers package. No business logic.
  • Naming: XxxMapper with functions toDomain, toEntity, toResponse, etc.

Tests

  • Unit tests suffix: Test. Structure: given-when-then.
  • Use factories/builders for fixtures to reduce duplication.
  • Test public API of use cases and controllers. Mock outbound ports in use case tests.
  • For mappers, use simple deterministic tests.

Gradle & modules

  • No cross-module leakage of frameworks into domain/application.
  • Keep dependencies minimal and explicit in each module build.gradle.kts.

SQL & Migrations (Liquibase)

  • Use sequenced changelog files with clear descriptions under db/changelog/changes/ (e.g. db/changelog/changes/0001-what-changed.sql), referenced from the master changelog (db/changelog/db.changelog-master.yaml).
  • Avoid destructive changes without safe migrations; prefer additive migrations.

MyBatis

  • Keep SQL in mapper XML or annotation styles consistently; prefer XML if queries grow. Map entity fields explicitly; avoid * selects.
  • Entity naming mirrors table names in singular (InvoiceEntity -> invoice table).

API (REST/GraphQL)

  • DTOs are transport-only. Do not leak domain internals. Keep error schema consistent.

JSON serialization

  • Always use JsonMapper.builder() (Jackson 3.x / tools.jackson) — immutable and thread-safe. Never use ObjectMapper (deprecated in Jackson 3.x).
  • Register modules at build time (JavaTimeModule); avoid runtime registerModule() calls.

Commit messages

  • Use Conventional Commits: feat:, fix:, docs:, refactor:, test:, chore:.