-
Notifications
You must be signed in to change notification settings - Fork 0
Code Rules
Tiana_ edited this page May 30, 2026
·
1 revision
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.
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, orinfrastructure/ -
application/importsdomain/only -
api/importsapplication/only (no JPA, no domain entities) -
infrastructure/importsdomain/andapplication/interfaces
ArchUnit tests in CI enforce these.
@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
// 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 XxxServiceinapplication/,class XxxServiceImpl : XxxServicenext to it -
@Serviceand@Transactionalonly in impl - Constructor injection only - never
@Autowired field - Method < 30 lines, class < 300 lines
-
readOnly = truefor read methods - Critical financial operations:
isolation = REPEATABLE_READ
@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
Utilclasses for mapping - Custom logic via
@Namedmapping methods
@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(NOTdata class) -data classbreaks Hibernate proxies and lazy loading -
@Idof typeUUID, generated in code (not@GeneratedValue) -
@VersionMANDATORY for optimistic locking - Equality based on
id, override explicitly - Enums:
@Enumerated(EnumType.STRING)- NEVERORDINAL -
nullable = falseby default;nullable = truerequires explicit reason - No
CascadeType.ALL- prefer explicit - No
@OneToMany(fetch = EAGER)- always lazy
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 fieldsval(immutable) - In
api/dto/request/andapi/dto/response/ - Jakarta Bean Validation:
@field:NotNull,@field:Size,@field:Pattern,@field:Positive,@field:DecimalMin - No business logic in DTOs
// 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
VARCHARvia@Enumerated(EnumType.STRING)- NEVERORDINAL(renaming/reordering breaks DB) - For localization: separate mapping layer (not
enum.labelorenum.displayName) - No business logic in enum bodies - pure values
@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
@Querywith explicit JPQL -
@EntityGraphor explicitJOIN FETCHto prevent N+1 - Native queries (
nativeQuery = true) only for performance-critical paths - No
findAll()without pagination in production code
| 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:
-
@Transactionalonly 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
@Versionalways
// 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 - feeHard rules:
-
NEVER
Float,Double,Numberfor monetary values -
BigDecimalwithMathContext.DECIMAL128=MathContext(34, HALF_EVEN), but we useMathContext(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, NEVERequals(BigDecimal scale gotcha:1.0 != 1.00per equals but1.0.compareTo(1.00) == 0) - Money type is mandatory in any external interface that crosses the bounded context
Every POST/PUT/PATCH/DELETE accepts Idempotency-Key header. Implementation in 3 layers:
- HTTP filter caches at request boundary (Redis L2)
-
Application service writes
idempotency_keysrow in same DB transaction as business state - 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.
// 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 fromDomainException -
@RestControllerAdviceproduces 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
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
// 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 // Thencomments - 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
@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
OptimisticLockExceptionup to 3 times, then 503
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors
package com.fincore.ledger.domain
...Pre-commit hook adds it if missing.
| 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!:
| 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 |
- 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
TODOwithout linked issue - SPDX header on all new files
- Conventional commit message
- Coverage above threshold
When AI generates code:
- Read this document first - no shortcuts
- Read the related Wiki page for the area you're modifying (Architecture-*, Domain-Model)
- Use existing patterns - search for similar code, copy structure
- Don't invent APIs - check OpenAPI spec, follow conventions
- Don't generate large abstractions unless requested
- Prefer interface + impl for services even for simple cases (consistency)
- Add tests alongside code, not as afterthought
- Always write progress - log every file changed (per CLAUDE.md)
- Never commit - that's the user's responsibility
- Match style - ktlint will reformat anyway, but writing it right saves a re-read cycle
- CLAUDE.md - agent rules, references this doc
- Coding-Standards - formatter setup details
- Testing-Strategy - test categorization and approach
- Architecture-Overview - where these rules sit in big picture
- Architecture-Resilience - concurrency patterns
- Overview
- Services
- Data Model
- Domain Model
- Event Flow
- Security
- Observability
- Resilience
- SLA / SLI / SLO