En esta guía (16 elementos)
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 interfaceusage for single-method ports, extension functions for mappers, nullable vs lateinit rules, anddata classvsclassselection 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 intochecktask so CI fails on violations. Usebaseline.xmlfor 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 classONLY for DTOs, request/response payloads, and pure value objects. NEVER for an aggregate root or entity —copy(...)lets callers bypass the entity’s invariants and transition guards. For aggregate roots / entities, use a regularclasswithprivateconstructor +companion objectfactory + 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 itXRequest/XResponse/XPayloadand move it out ofdomain/, or (b) it is an entity — convert to a regularclass, mark the constructorprivate, expose acompanion objectfactory, and put the behavior on it.
-
Use
data classfor DTOs, request/response payloads, and pure value objects (withinit { require(...) }for validation). NEVER for an aggregate root.// ✅ DTO — lives in application or infrastructure, NOT domaindata class UserRequest(val email: String,val firstName: String,val lastName: String,)// ✅ Value object — single concept, validated, immutabledata 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
classfor rich domain entities, aggregate roots, or any model that owns behavior:// ✅ Aggregate root — invariants enforced, behavior on the entity, private ctor + factoryclass 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.ACTIVEcompanion 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 varwhen possible. It can throw UninitializedPropertyAccessException at runtime. -
Prefer constructor injection or nullable properties with explicit null checks:
// ❌ BAD - lateinit can throw at runtimeclass 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 checkclass OrganizationCacheAdapter : OrganizationRepositoryPort, ChainablePort<OrganizationRepositoryPort> {private var delegate: OrganizationRepositoryPort? = nulloverride 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)@Componentclass 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
lateinitwhen:- The property will definitely be initialized before use (e.g., Spring @Autowired in tests)
- You cannot use constructor injection (rare in modern Kotlin/Spring)
- The property is a
varprimitive type that doesn’t support null
fun interface (SAM - Single Abstract Method):
-
Use
fun interfacefor single-method contracts (functional interfaces):// ✅ GOOD - fun interface for single methodfun interface TransactionPort<R> {fun run(callback: TransactionCallbackPort<R>): R}fun interface TransactionCallbackPort<R> {fun call(): R}// Usage - can use lambda syntaxval result = transaction.run { repository.save(entity) } -
Use regular
interfacewhen multiple methods are needed:// ✅ GOOD - regular interface for multiple methodsinterface OrganizationRepositoryPort {fun save(organization: Organization): Organizationfun update(organization: Organization): Organizationfun 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 (
createfor new aggregates,rehydratefor persistence reads), never the private constructor.// In CommandMapper.kt (application layer) — creates a NEW aggregate via factoryfun CreateOrganizationCommand.toOrganization(clock: Clock): Organization =Organization.create(id = UUID.randomUUID(),name = OrganizationName(this.name), // value object validatescontactEmail = 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.idthis.name = this@toEntityForInsert.name.value // value-object → primitivethis.isNew = true // For Spring Data JDBC Persistable// ...}// Usageval 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 samecompanion objectascreate(...)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:
-
❌ Using
!!(force unwrap) - can throw NPEval id = organization.id!! // NPE if null✅ Use null-safe operators instead:
val id = organization.id ?: throw IllegalStateException("Organization ID must not be null") -
❌ Overusing
var- prefer immutabilityvar status = OrganizationStatus.ACTIVE // Mutable✅ Use
valby default:val status = OrganizationStatus.ACTIVE // Immutable -
❌ Platform types from Java interop
val user = javaService.getUser() // Type is User! (platform type)✅ Explicitly declare nullability:
val user: User? = javaService.getUser() -
❌ Not using scope functions appropriately
val entity = OrganizationEntity()entity.id = organization.identity.name = organization.nameentity.isNew = true✅ Use
applyfor object configuration:val entity = OrganizationEntity().apply {id = organization.idname = organization.nameisNew = true} -
❌ 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
valby default;varonly 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:
XxxMapperwith functionstoDomain,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 useObjectMapper(deprecated in Jackson 3.x). - Register modules at build time (
JavaTimeModule); avoid runtimeregisterModule()calls.
Commit messages
- Use Conventional Commits:
feat:,fix:,docs:,refactor:,test:,chore:.