Skip to content

Code Rules

Tiana_ edited this page May 30, 2026 · 1 revision

Code Rules - Kotlin Best Practices for FinCore

The single source of truth for how Kotlin/Spring code looks in FinCore Engine. Required reading for every contributor and every AI agent. Linked from CLAUDE.md, CONTRIBUTING.md, all agent definitions.


1. Architecture layers (Hexagonal-light)

src/main/kotlin/com/fincore/<service>/
├── api/              # REST controllers (THIN)
│   ├── dto/
│   │   ├── request/  # *CreateRequest, *UpdateRequest
│   │   └── response/ # *Response, *Summary
│   └── *Controller.kt
├── application/      # use-case services (interface + impl)
│   ├── *Service.kt
│   └── *ServiceImpl.kt
├── domain/           # pure Kotlin, no Spring imports, no JPA
│   ├── *.kt          # aggregates, value objects
│   └── enum/
│       └── *.kt
├── infrastructure/   # adapters
│   ├── persistence/
│   │   ├── *Entity.kt        # @Entity (class, not data class)
│   │   ├── *Repository.kt
│   │   └── *Mapper.kt        # MapStruct
│   ├── messaging/
│   │   ├── *EventPublisher.kt
│   │   └── *EventConsumer.kt
│   └── external/
│       └── *Adapter.kt
├── config/
│   ├── SecurityConfig.kt
│   ├── OpenApiConfig.kt
│   └── ObservabilityConfig.kt
└── exception/
    ├── DomainException.kt
    ├── *Exception.kt
    └── GlobalExceptionHandler.kt

Rules:

  • domain/ imports nothing from Spring, JPA, or infrastructure/
  • application/ imports domain/ only
  • api/ imports application/ only (no JPA, no domain entities)
  • infrastructure/ imports domain/ and application/ interfaces

ArchUnit tests in CI enforce these.


2. Controllers - thin, no logic

@RestController
@RequestMapping("/v1/accounts")
class AccountController(
    private val accountService: AccountService,
    private val accountMapper: AccountMapper,
) {

    @PostMapping
    @PreAuthorize("hasRole('LEDGER_WRITER') and hasAuthority('SCOPE_ledger:write')")
    fun create(
        @Valid @RequestBody req: CreateAccountRequest,
        @RequestHeader("Idempotency-Key") idempotencyKey: String,
    ): ResponseEntity<AccountResponse> {
        val account = accountService.create(accountMapper.toCommand(req, idempotencyKey))
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(accountMapper.toResponse(account))
    }

    @GetMapping("/{id}")
    @PreAuthorize("hasRole('LEDGER_READER')")
    fun get(@PathVariable id: UUID): AccountResponse {
        val account = accountService.getById(AccountId(id))
        return accountMapper.toResponse(account)
    }
}

Hard rules:

  • Method < 15 lines
  • Class < 200 lines
  • No @Transactional (it's at service layer)
  • No repository injection (only services)
  • Returns DTO, never entity
  • One controller per resource

3. Services - interface + impl ALWAYS

// In application/AccountService.kt
interface AccountService {
    fun create(cmd: CreateAccountCommand): Account
    fun getById(id: AccountId): Account
    fun update(id: AccountId, cmd: UpdateAccountCommand): Account
}

// In application/AccountServiceImpl.kt
@Service
class AccountServiceImpl(
    private val repo: AccountRepository,
    private val mapper: AccountEntityMapper,
    private val outbox: OutboxEventPublisher,
    private val idempotencyService: IdempotencyService,
) : AccountService {

    @Transactional
    override fun create(cmd: CreateAccountCommand): Account {
        idempotencyService.checkAndStore(cmd.idempotencyKey, cmd.requestHash) {
            val account = Account.create(cmd)
            val entity = mapper.toEntity(account)
            repo.save(entity)
            outbox.publish(AccountCreatedEvent.from(account))
            account
        }
    }

    @Transactional(readOnly = true)
    override fun getById(id: AccountId): Account =
        repo.findById(id.value)?.let { mapper.toDomain(it) }
            ?: throw AccountNotFoundException(id)
}

Rules:

  • interface XxxService in application/, class XxxServiceImpl : XxxService next to it
  • @Service and @Transactional only in impl
  • Constructor injection only - never @Autowired field
  • Method < 30 lines, class < 300 lines
  • readOnly = true for read methods
  • Critical financial operations: isolation = REPEATABLE_READ

4. Mappers - MapStruct only

@Mapper(
    componentModel = "spring",
    unmappedTargetPolicy = ReportingPolicy.ERROR,
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
)
interface AccountMapper {
    fun toCommand(req: CreateAccountRequest, idempotencyKey: String): CreateAccountCommand
    fun toResponse(account: Account): AccountResponse
}

@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR)
interface AccountEntityMapper {
    fun toEntity(domain: Account): AccountEntity
    fun toDomain(entity: AccountEntity): Account
}

Build setup uses KSP (not kapt - kapt is deprecated in Kotlin 2.x):

// build.gradle.kts
plugins {
    id("com.google.devtools.ksp") version "2.0.21-1.0.27"
}

dependencies {
    implementation("org.mapstruct:mapstruct:1.6.3")
    ksp("org.mapstruct:mapstruct-processor:1.6.3")
    ksp("org.projectlombok:lombok-mapstruct-binding:0.2.0")
}

Rules:

  • One mapper per pair (Entity ↔ DTO, Entity ↔ Domain, DTO ↔ Domain)
  • unmappedTargetPolicy = ReportingPolicy.ERROR - fail compile on unmapped fields
  • No manual fun X.toY() = Y(...) extension functions for mapping
  • No Util classes for mapping
  • Custom logic via @Named mapping methods

5. Entities (JPA)

@Entity
@Table(name = "accounts", schema = "ledger")
class AccountEntity(
    @Id
    val id: UUID,                          // generated in app via UUID.randomUUID()

    @Column(nullable = false, length = 255)
    var name: String,

    @Enumerated(EnumType.STRING)           // NEVER ORDINAL
    @Column(nullable = false, length = 32)
    val type: AccountType,

    @Column(nullable = false, length = 3)
    val currency: String,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 16)
    var status: AccountStatus,

    @Column(columnDefinition = "jsonb")
    @Type(JsonBinaryType::class)
    var metadata: Map<String, String> = emptyMap(),

    @Version
    var version: Long = 0,                  // optimistic locking - MANDATORY

    @CreationTimestamp
    @Column(nullable = false, updatable = false)
    val createdAt: Instant = Instant.now(),

    @UpdateTimestamp
    @Column(nullable = false)
    var updatedAt: Instant = Instant.now(),
) {

    override fun equals(other: Any?): Boolean = this === other ||
        (other is AccountEntity && id == other.id)

    override fun hashCode(): Int = id.hashCode()
}

Rules:

  • class (NOT data class) - data class breaks Hibernate proxies and lazy loading
  • @Id of type UUID, generated in code (not @GeneratedValue)
  • @Version MANDATORY for optimistic locking
  • Equality based on id, override explicitly
  • Enums: @Enumerated(EnumType.STRING) - NEVER ORDINAL
  • nullable = false by default; nullable = true requires explicit reason
  • No CascadeType.ALL - prefer explicit
  • No @OneToMany(fetch = EAGER) - always lazy

6. DTOs (data classes)

data class CreateAccountRequest(
    @field:NotBlank
    @field:Size(min = 1, max = 255)
    val name: String,

    @field:NotNull
    val type: AccountType,

    @field:NotBlank
    @field:Pattern(regexp = "^[A-Z]{3}$", message = "must be ISO 4217 code")
    val currency: String,

    @field:Size(max = 16)
    val metadata: Map<String, String>? = null,
)

data class AccountResponse(
    val id: UUID,
    val name: String,
    val type: AccountType,
    val currency: String,
    val status: AccountStatus,
    val metadata: Map<String, String>,
    val version: Long,
    val createdAt: Instant,
    val updatedAt: Instant,
)

Rules:

  • data class, all fields val (immutable)
  • In api/dto/request/ and api/dto/response/
  • Jakarta Bean Validation: @field:NotNull, @field:Size, @field:Pattern, @field:Positive, @field:DecimalMin
  • No business logic in DTOs

7. Enums - extracted, used everywhere

// In domain/enum/AccountType.kt
enum class AccountType {
    ASSET,
    LIABILITY,
    EQUITY,
    REVENUE,
    EXPENSE,
    USER_WALLET,
    FEE,
    RESERVE,
    SUSPENSE,
}

// In domain/enum/AccountStatus.kt
enum class AccountStatus { ACTIVE, FROZEN, CLOSED }

Rules:

  • One enum per file (or grouped by aggregate in domain/<aggregate>/enum/)
  • Persisted as VARCHAR via @Enumerated(EnumType.STRING) - NEVER ORDINAL (renaming/reordering breaks DB)
  • For localization: separate mapping layer (not enum.label or enum.displayName)
  • No business logic in enum bodies - pure values

8. Repositories (Spring Data JPA)

@Repository
interface AccountRepository : JpaRepository<AccountEntity, UUID> {

    fun findByCurrency(currency: String): List<AccountEntity>

    @Query("""
        SELECT a FROM AccountEntity a
        WHERE a.status = :status AND a.currency = :currency
    """)
    fun findActiveByCurrency(
        @Param("status") status: AccountStatus,
        @Param("currency") currency: String,
    ): List<AccountEntity>

    @EntityGraph(attributePaths = ["metadata"])
    fun findWithMetadataById(id: UUID): AccountEntity?
}

Rules:

  • In infrastructure/persistence/ only
  • Method names follow Spring Data convention OR @Query with explicit JPQL
  • @EntityGraph or explicit JOIN FETCH to prevent N+1
  • Native queries (nativeQuery = true) only for performance-critical paths
  • No findAll() without pagination in production code

9. Transactions

Where What
Domain layer None
Application impl methods @Transactional (default propagation = REQUIRED, readOnly = true for reads)
Critical financial ops @Transactional(isolation = Isolation.REPEATABLE_READ)
Repository methods None (use parent transaction)
Controllers None

Rules:

  • @Transactional only in *ServiceImpl
  • No external API calls inside @Transactional - wrap in CompletableFuture or move outside
  • Use outbox pattern for cross-system effects, not direct Kafka send in transaction
  • Optimistic locking via @Version always

10. Money handling - critical

// In libs/fincore-core/Money.kt
data class Money(val amount: BigDecimal, val currency: Currency) {
    init {
        require(amount.scale() <= 18) { "scale max 18 decimals" }
    }

    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "currency mismatch" }
        return Money(amount.add(other.amount), currency)
    }

    operator fun minus(other: Money): Money {
        require(currency == other.currency) { "currency mismatch" }
        return Money(amount.subtract(other.amount), currency)
    }

    companion object {
        val MATH_CONTEXT = MathContext(38, RoundingMode.HALF_EVEN)  // banker's rounding
    }
}

// In domain code
val balance = Money(BigDecimal("100.00"), Currency.getInstance("EUR"))
val fee = Money(BigDecimal("2.50"), Currency.getInstance("EUR"))
val net = balance - fee

Hard rules:

  • NEVER Float, Double, Number for monetary values
  • BigDecimal with MathContext.DECIMAL128 = MathContext(34, HALF_EVEN), but we use MathContext(38, HALF_EVEN) to match DB scale
  • RoundingMode.HALF_EVEN (banker's rounding) - IEEE 754 standard for financial
  • DB column: NUMERIC(38, 18)
  • Comparison via compareTo, NEVER equals (BigDecimal scale gotcha: 1.0 != 1.00 per equals but 1.0.compareTo(1.00) == 0)
  • Money type is mandatory in any external interface that crosses the bounded context

11. Idempotency

Every POST/PUT/PATCH/DELETE accepts Idempotency-Key header. Implementation in 3 layers:

  1. HTTP filter caches at request boundary (Redis L2)
  2. Application service writes idempotency_keys row in same DB transaction as business state
  3. Database has unique constraint on the key
@Service
class IdempotencyService(
    private val repo: IdempotencyKeyRepository,
) {
    @Transactional(propagation = REQUIRES_NEW)
    fun <T> checkAndStore(key: String, requestHash: String, action: () -> T): T {
        repo.findByKey(key)?.let { existing ->
            require(existing.requestHash == requestHash) { throw IdempotencyConflictException(key) }
            @Suppress("UNCHECKED_CAST")
            return existing.deserializeResponse() as T
        }

        val result = action()
        repo.save(IdempotencyRecord(
            key = key,
            requestHash = requestHash,
            responseStatus = 201,
            responseBody = serialize(result),
            expiresAt = Instant.now().plus(24, ChronoUnit.HOURS),
        ))
        return result
    }
}

See Architecture-Resilience for the full idempotency pattern.


12. Exceptions

// domain/AccountNotFoundException.kt
class AccountNotFoundException(val id: AccountId) :
    DomainException("Account ${id.value} not found")

// exception/DomainException.kt
sealed class DomainException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
class NotFoundException(message: String) : DomainException(message)
class ValidationException(message: String, val fieldErrors: List<FieldError>? = null) : DomainException(message)
class ConflictException(message: String) : DomainException(message)
class InvariantViolationException(message: String) : DomainException(message)

// exception/GlobalExceptionHandler.kt
@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(AccountNotFoundException::class)
    fun handleNotFound(ex: AccountNotFoundException): ResponseEntity<ProblemDetail> {
        val problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.message ?: "Not found").apply {
            type = URI.create("https://docs.fincore.dev/errors/account-not-found")
            title = "Account not found"
            setProperty("correlationId", MDC.get("correlationId"))
        }
        return ResponseEntity.status(404).body(problem)
    }

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ProblemDetail> { ... }
}

Rules:

  • Domain exceptions: typed (e.g., AccountNotFoundException), inherit from DomainException
  • @RestControllerAdvice produces RFC 7807 Problem Details
  • Never catch generic Exception (let it bubble to handler)
  • Never log + rethrow at multiple layers (log once at handler)
  • Stack traces never returned in API responses

13. Logging

class AccountServiceImpl(...) : AccountService {

    private val log = LoggerFactory.getLogger(this::class.java)

    @Transactional
    override fun create(cmd: CreateAccountCommand): Account {
        log.info("Creating account currency={} type={}", cmd.currency, cmd.type)
        val account = ...
        log.info("Account created id={} currency={} type={}", account.id, account.currency, account.type)
        return account
    }
}

Rules:

  • private val log = LoggerFactory.getLogger(this::class.java)
  • Structured (logstash-logback-encoder)
  • MDC: correlationId, requestId, userId (auto-populated by filter)
  • PII NEVER logged: full names, IBAN, PAN, SSN, government IDs, emails - only IDs and hashes (****-1234)
  • Levels: ERROR (system failure), WARN (degraded), INFO (state change), DEBUG (flow), TRACE (data dump)
  • Production default: INFO

14. Tests (TDD mandatory)

// Naming: should {behavior} when {condition}()
class AccountServiceImplTest {

    @Test
    fun `should create account when valid command provided`() {
        val cmd = CreateAccountCommand(name = "test", type = USER_WALLET, currency = "EUR")
        val service = AccountServiceImpl(...)

        val result = service.create(cmd)

        result.name shouldBe "test"
        result.status shouldBe AccountStatus.ACTIVE
    }

    @Test
    fun `should reject account when currency is invalid`() {
        val cmd = CreateAccountCommand(name = "test", type = USER_WALLET, currency = "XYZ")

        shouldThrow<ValidationException> {
            service.create(cmd)
        }
    }
}

Rules:

  • TDD: failing test first → green → refactor
  • Naming: should {behavior} when {condition}() - backticks for spaces
  • No // Given // When // Then comments - naming is enough
  • Unit: MockK + JUnit 5 + Kotest assertions
  • Integration: @SpringBootTest + Testcontainers (real Postgres + Redpanda)
  • Property-based: Kotest property testing for invariants
  • Coverage: > 70% overall, > 90% on critical paths (ledger, idempotency, decision engine)
  • Race condition tests for concurrent payment posting

15. Concurrency

@Service
class TransactionServiceImpl(
    private val accountRepo: AccountRepository,
    ...
) : TransactionService {

    @Transactional(isolation = Isolation.REPEATABLE_READ)
    override fun post(cmd: PostTransactionCommand): Transaction {
        // Lock affected accounts to prevent concurrent updates
        val accounts = accountRepo.findAllByIdForUpdate(cmd.entries.map { it.accountId })

        // Verify accounts active
        accounts.forEach { it.requireActive() }

        // Build & save (deferred trigger validates SUM=0 at COMMIT)
        val tx = Transaction.create(cmd)
        transactionRepo.save(tx)
        entryRepo.saveAll(tx.entries)

        // Outbox row (in same tx)
        outbox.publish(TransactionPostedEvent.from(tx))

        return tx
    }
}

Rules:

  • Virtual threads enabled: spring.threads.virtual.enabled=true
  • Coroutines on edge (gateway, async tasks)
  • Optimistic locking (@Version) by default
  • Pessimistic (SELECT FOR UPDATE) for critical ledger ops on shared rows
  • Retry on OptimisticLockException up to 3 times, then 503

16. SPDX header (mandatory on every file)

// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.domain
...

Pre-commit hook adds it if missing.


17. Code style

Tool Config
ktlint default ruleset, version 1.x
detekt fail on complexity > 15, magic numbers, long methods, dead code
Spotless pre-commit

Manual rules:

  • 4 spaces indent
  • Max line 140
  • No import * (explicit imports)
  • Trailing commas in multi-line constructs (Kotlin convention)
  • Conventional Commits: feat:, fix:, refactor:, test:, docs:, chore:, breaking!:

18. Forbidden patterns

Forbidden Reason
println Use logger
!! (non-null assertion) Use ?: or proper null handling
Magic numbers Constant or config
Dead code Delete
Manual mappers (extension functions) Use MapStruct
Hardcoded URLs to production Use config
EnumType.ORDINAL Use STRING
Float/Double for money Use BigDecimal
Business logic in @RestController Move to service
Business logic in JpaRepository Move to service
Business logic in JPA entity setter Move to domain method
External calls in @Transactional Move outside or use outbox
data class for JPA entity Use class
JPA entity in API response Use DTO
@Autowired field injection Constructor injection
Catch-all catch (e: Exception) Catch specific or let bubble
Cross-context entity import Use API interface

19. Pre-merge checklist (for every PR)

  • All new public methods have KDoc
  • All new endpoints have OpenAPI annotations
  • All new endpoints have integration test asserting auth + happy path + edge cases
  • All new entities have unit tests for invariants
  • All new mappers verified via integration test (no field drops)
  • No new dependencies without ADR or maintainer approval
  • No PII added to logs (search-test in CI)
  • No commented-out code
  • No new TODO without linked issue
  • SPDX header on all new files
  • Conventional commit message
  • Coverage above threshold

20. AI agent rules (specific to Claude/Copilot/etc.)

When AI generates code:

  1. Read this document first - no shortcuts
  2. Read the related Wiki page for the area you're modifying (Architecture-*, Domain-Model)
  3. Use existing patterns - search for similar code, copy structure
  4. Don't invent APIs - check OpenAPI spec, follow conventions
  5. Don't generate large abstractions unless requested
  6. Prefer interface + impl for services even for simple cases (consistency)
  7. Add tests alongside code, not as afterthought
  8. Always write progress - log every file changed (per CLAUDE.md)
  9. Never commit - that's the user's responsibility
  10. Match style - ktlint will reformat anyway, but writing it right saves a re-read cycle

Related

Clone this wiki locally