Skip to content

Kotlin Coding Standards

This page documents the Kotlin coding standards for Arda backend services. Apply these rules when writing or reviewing Kotlin code in any Arda repository.

  • When a function has multiple parameters of the same type, use named arguments at the call site to prevent argument-order mistakes.
  • When implementing LogEnabled by LogProvider(...), always pass the enclosing class as the argument: LogProvider(MyUniverse::class). Never copy a LogProvider(...) delegation from a neighboring file without updating the class reference.
  1. Do not reformat existing code unless you have been explicitly asked to do so. Leave formatting to the project’s automated tools.
  2. For new code, follow the style defined in the .editorconfig file at the repository root.
  1. Any function or method that may fail must return Result<T> instead of throwing an exception.

  2. Single exit point. Functions and methods must have a single return statement (or a single expression body). Do not scatter multiple return or return@label statements throughout a function body. Instead, use when expressions, Result.flatMap chains, or local val bindings to funnel all paths to one exit. Multiple early returns make control flow hard to follow and easy to break during refactoring.

    // WRONG — multiple return points
    suspend fun validate(url: String): Result<URL> {
    val parsed = try { URL(url) } catch (e: Exception) {
    return Result.failure(AppError.ArgumentValidation("url", e.message))
    }
    if (parsed.host != expectedHost) {
    return Result.failure(AppError.ArgumentValidation("url", "wrong host"))
    }
    return Result.success(parsed)
    }
    // CORRECT — single expression using flatMap chain
    suspend fun validate(url: String): Result<URL> =
    runCatching { URL(url) }
    .mapError { AppError.ArgumentValidation("url", it.message) }
    .flatMap { parsed ->
    when {
    parsed.host != expectedHost -> Result.failure(
    AppError.ArgumentValidation("url", "wrong host")
    )
    else -> Result.success(parsed)
    }
    }
  3. Prefer when expressions over if statements wherever possible.

  4. Use Result.map, Result.flatMap, and similar operators to chain operations that may fail. Model the logic around Success and Failure channels.

  5. Do not use getOrThrow or getOrNull to extract a value from a Result. Use map or flatMap and place the logic inside the lambda.

  6. Result<T> for all fallible operations. Any operation that can fail — including URL construction, parsing, or other seemingly simple operations — must return Result<T>. Consistency matters: if there is any code path that throws, wrap it in runCatching and return Result.

  7. Fail-fast ordering in chains. When chaining multiple operations, order them cheapest-first. Validate inputs (pure logic, no I/O) before accessing ApplicationContext (coroutine context) or making service calls (network).

  8. flatInApplicationContext for coroutine context access. Use ApplicationContext.Key.flatInApplicationContext { ctx -> ... } to access ApplicationContext from a coroutine. Do not use ApplicationContext.current().flatMap { ... }flatInApplicationContext is the idiomatic common-module pattern.

  9. Data classes before their producers. Define data classes (result types, value objects) above the class that produces or consumes them. The reader encounters the type before the code that uses it.

When a service must attempt an operation up to N times, express the loop as a tail-recursive private suspend function — not as a mutable variable threaded through a while loop.

Anti-pattern — mutable state in a loop:

// WRONG — mutable state, multiple exit points, hard to audit
var result: SendOutcome? = null
var attempts = 0
while (attempts < maxAttempts) {
result = trySend(request)
attempts++
if (result is SendOutcome.Sent || result is SendOutcome.Rejected) break
}
return result ?: SendOutcome.Exhausted(maxAttempts)

The loop’s terminal condition requires reading the loop body, the if guard, and the post-loop fallback together. Refactoring the terminal cases is error-prone.

Canonical replacement — tail-recursive function:

// CORRECT — single exit per branch, max depth bounded by maxAttempts
// (canonical shape from EmailSender.attemptSend in
// cards.arda.operations.shopaccess.email.service)
private suspend fun attemptSend(
request: SendRequest,
attempt: Int = 1,
): SendOutcome = when {
attempt > maxAttempts -> SendOutcome.Exhausted(maxAttempts)
else -> when (val outcome = trySend(request)) {
is SendOutcome.Sent, is SendOutcome.Rejected -> outcome // terminal
is SendOutcome.TransientFailure -> attemptSend(request, attempt + 1)
}
}

Each branch either returns a terminal result or recurses — the depth is bounded by maxAttempts (default 3). The JVM stack is not at risk for small bounds.

Note: The recursion cannot be annotated tailrec because Kotlin’s tailrec does not compose with suspend. This is a known language limitation; the depth bound remains the safety guarantee.

Keep source files small and cohesive. The size limits below are a signal, not a hard gate: a file a few lines over is not a defect, but a file well past the limit almost always hides more than one responsibility.

  • Production files: ≤ 300 lines.
  • Test files: ≤ 500 lines.

When a file grows past its limit, split it along its internal seams — extract collaborators, factory or extension functions, or sub-services that each own a cohesive slice of the behavior and the state/dependencies it needs. Split by concern, not by arbitrary line count: two halves that share the same collaborators and reading order belong together; the goal is cohesion, not merely a smaller number. The orders module is a worked example of decomposing a large service into cohesive collaborators wired at the composition root.

When a single class legitimately exceeds the limit and cannot be cleanly split, record the reason in review and seek sign-off rather than silently shipping it.

In production code, never unwrap a Result with getOrThrow or getOrNull. Always use map or flatMap to work with the value while keeping it in the Result context. If this cannot be done reasonably for a given case, escalate to the team for guidance.

A single .normalizeFailure() at the tail of a flatMap chain is sufficient — it converts any generic exception surfacing anywhere in the chain into an AppError. Do not sprinkle .normalizeFailure() after each step. Add a second one only when the body genuinely branches on error type and a later branch must normalize independently of the tail.

// CORRECT — one normalizeFailure() covers every failure in the chain
businessAffiliateService.findByNameAndRole(name = supplierName, role = VENDOR, asOf = asOf)
.flatMap { existingBa ->
when (existingBa) {
null -> businessAffiliateService.add(/* ... */).flatMap { /* createBusinessRole */ }
else -> businessAffiliateService.businessRolesFor(existingBa.payload.eId, asOf).flatMap { /* link */ }
}
}.normalizeFailure()

Guard, don’t checkNotNull, inside a Result chain

Section titled “Guard, don’t checkNotNull, inside a Result chain”

When an invariant guarantees two nullable fields are co-present (e.g., a smart constructor that makes a non-null eId imply a non-null affiliateEId), funnel them to non-null locals with a single when guard that returns a Result.failure for the impossible-but-typed case. The smart-cast keeps the rest of the body in the Result channel. Never reach for checkNotNull, !!, or getOrThrow to discharge such an invariant in production resolution code — those throw, escaping the Result channel.

val roleEId = supplierRef.eId
val affiliateEId = supplierRef.affiliateEId
return when {
roleEId == null || affiliateEId == null -> Result.failure(
AppError.IncompatibleState(
"resolveWithExistingRef requires a linked SupplierReference (eId and affiliateEId)"
)
)
// roleEId and affiliateEId are smart-cast non-null below; the body stays in Result.
else -> businessAffiliateService.detailsFor(affiliateEId, asOf).flatMap { details -> /* ... */ }
}

To convert a Result<T> whose value you no longer need into a Result<Unit>, use .unitify() (cards.arda.common.lib.lang) rather than .map { }. It states the intent — discard the value, keep the success/failure channel — without an empty lambda.

// WRONG
businessAffiliateService.updateName(affiliateEId, supplierName, asOf.effective).map { }
// CORRECT
businessAffiliateService.updateName(affiliateEId, supplierName, asOf.effective).unitify()

A backend session writing reference-resolution or cross-entity persistence code should also read these pattern pages:

  • Cross-Child Queries — tenant-scoped queries that resolve child entities across many parents in a single pass (no N+1).
  • Parent-Child Persistence — entities scoped to a parent (lines on a header, roles on a company).
  • Data Authority Module Pattern — the four-layer module structure that owns these services and universes. Documented exception: fillPayload bridging. The bitemporal framework’s fillPayload(row): EntityPayload is declared to throw on corrupt rows (it predates the Result convention). Smart-constructors inside Component.build() return Result; bridge them with .getOrThrow() only inside fillPayload. This is the only place in module code where getOrThrow() is acceptable, and only because the framework contract demands it. See Persistent Components § “The fillPayload boundary”.

The ResultExt.kt file in common-module provides combinators that eliminate boilerplate when/if chains. Use them before reaching for manual unwrapping.

resultNotNull — collapse nullable success to typed failure

Section titled “resultNotNull — collapse nullable success to typed failure”

Use when a Result<T?> null payload means “not found, and the caller requires a value.” It converts a Result.success(null) into a Result.failure(err) while leaving non-null successes and failures unchanged.

cards.arda.operations.shopaccess.email.service.EmailConfigurationServiceImpl
// Before — manual null check inside flatMap
fun getServerToken(configEId: EntityId): Result<PostmarkAccountToken> =
configRepo.findByEId(configEId)
.flatMap { cfg ->
if (cfg == null) Result.failure(AppError.NotFound("emailConfiguration"))
else Result.success(cfg.token)
}
// After — resultNotNull collapses the nullable step
fun getServerToken(configEId: EntityId): Result<PostmarkAccountToken> =
configRepo.findByEId(configEId)
.resultNotNull(AppError.NotFound("emailConfiguration"))
.map { it.token }

flatMap(r1, r2, transform) — combine two independent Results

Section titled “flatMap(r1, r2, transform) — combine two independent Results”

Use when two separate Result computations are both needed before a downstream step. If either fails the combinator short-circuits; if both succeed, transform receives both values.

flatMap(resolveConfig(tenantId), resolveSender(senderId)) { cfg, sender ->
EmailJob(cfg, sender, payload)
}

collectAll — fail-fast aggregation over a collection

Section titled “collectAll — fail-fast aggregation over a collection”

Use when mapping a collection of inputs to Result<T> and needing Result<List<T>>. It stops at the first failure and returns it; all items must succeed for the list to be returned.

cards.arda.operations.shopaccess.email.service.MaterialRegistryRefresher
// Before — manual fold with early exit
fun loadAll(paths: List<Path>): Result<List<Template>> {
val results = mutableListOf<Template>()
for (p in paths) {
val r = loadTemplate(p)
if (r.isFailure) return r.map { emptyList() } // awkward cast
results += r.getOrThrow()
}
return Result.success(results)
}
// After — collectAll is a single expression
fun loadAll(paths: List<Path>): Result<List<Template>> =
paths.map { loadTemplate(it) }.collectAll()

Choosing collectAll vs a collect-all-errors approach. collectAll is fail-fast: it stops and returns the first failure. When the goal is to surface all validation errors at once (so the user can fix everything in one pass), wrap each item’s failure in AppError.Composite instead — accumulate all AppError values, then return AppError.Composite(message, causes) when the list is non-empty. Reserve collectAll for pipeline steps where the first failure is the only actionable signal (e.g., loading required files on startup).

Value classes that validate their input must use the private constructor + companion operator fun invoke pattern, not init { require(...) }. The factory returns Result<T> so callers see validation failures as values, not exceptions.

// WRONG — throws on invalid input; callers must runCatching every construction
@JvmInline
value class LocalPart(val value: String) {
init { require(value.matches(localPartRegex)) { "Invalid local part: $value" } }
}
// CORRECT — private constructor, companion operator invoke returning Result
@JvmInline
value class LocalPart private constructor(val value: String) {
companion object {
operator fun invoke(value: String): Result<LocalPart> = when {
value.matches(localPartRegex) -> Result.success(LocalPart(value))
else -> Result.failure(
AppError.ArgumentValidation("localPart", "Invalid local part: $value")
)
}
}
}
// Call sites
LocalPart("noreply")
.flatMap { local -> EmailAddress(local, domain) }
.flatMap { address -> sendTo(address) }

Conventions:

  • Factory name: operator fun invoke, never create. Call sites read LocalPart("noreply"), identical to the deprecated constructor call.

  • Companion-only construction. The private constructor and the companion share the value class; kotlinx.serialization still generates a serializer via the companion-adjacent declaration, so @Serializable value classes work without changes.

  • Constant baselines for known-valid defaults. Expose a companion object constant (RecentHealth.OK) rather than calling the factory and .getOrThrow() at every reference. The constant is built once at class load with verified inputs.

    @JvmInline
    value class RecentHealth private constructor(val consecutiveFailureCounter: Int) {
    companion object {
    val OK = RecentHealth(consecutiveFailureCounter = 0)
    operator fun invoke(consecutiveFailureCounter: Int): Result<RecentHealth> = when {
    consecutiveFailureCounter < 0 -> Result.failure(
    AppError.ArgumentValidation("consecutiveFailureCounter", "must be >= 0")
    )
    else -> Result.success(RecentHealth(consecutiveFailureCounter))
    }
    }
    }
  • Test fixtures. When constructing a value class with a literal valid input inside a test, the trailing .getOrThrow() is acceptable because the input is known-valid at write time. Wrap result-returning factories with .flatMap in production paths.

  1. All exceptions must be a subclass of cards.arda.common.lib.lang.errors.AppError, using the most specific subclass available.
  2. Choose the exception subclass by origin:
    • AppError.Invocation — errors attributable to inputs to the method.
      • AppError.ArgumentValidation(argumentName, validationMessage) — invalid input parameter.
      • AppError.ContextValidation(msg) — invalid request context (e.g., missing tenant scope).
    • AppError.Internal — errors arising from internal logic or implementation-resource issues.
      • AppError.ExternalService(msg, code, description) — failure in an external service call (e.g., AWS SDK error).
      • AppError.IncompatibleState(message) — internal state inconsistency.
      • AppError.Infrastructure(message) — infrastructure/configuration error.
      • AppError.NotFound(resourceName) — requested resource does not exist.
    • AppError.Composite(message, causes) — wraps multiple errors together, as in input validation, collection operations, or batch operations. Use Throwable.normalizeToAppError() to convert generic exceptions to AppError.
  3. Handle errors by returning a kotlin.Result wherever possible.
  4. When integrating external libraries or calling methods that cannot return kotlin.Result (e.g., constructors, toString, equals):
    • Run them inside a runCatching block.
    • Convert the exception to an AppError using mapError, either manually or via the provided extension functions Throwable.normalizeToAppError() or Result<T>.normalizeFailure().
  5. Collect all errors in validation functions. When a function validates multiple conditions, collect all failures into a list and return AppError.Composite when there are multiple errors, a single AppError when there is one, or Result.success when there are none. Do not fail-fast on the first error — the caller (and ultimately the user) needs the complete picture to fix all issues at once.
  6. Preserve the original Throwable as cause. When constructing a wrapping AppError from a caught exception, pass cause = err on variants that accept it (IncompatibleState, Infrastructure, InternalService). AppError.ExternalService does not accept cause in the current common-module version (tracked in PDEV-767); omit cause for that variant and add a // TODO(PDEV-767) comment at the construction site as audit trail so it is easy to find and update once PDEV-767 ships.

Prefer Kotlin’s Closeable.use { ... } (and the AutoCloseable variant) over an explicit try { ... } finally { resource.close() } for any object whose lifetime ends at the bottom of the current scope.

use is strictly safer than the equivalent try/finally:

  • If both the body and close() throw, use propagates the body’s exception and attaches the close-time exception as Throwable.addSuppressed(...). The naïve try/finally form throws whichever exception fires last, silently dropping the body’s exception — usually the one the caller actually needs.
  • It is shorter, removes the need for a separate variable, and makes the resource’s lifetime obvious at a glance.
// WRONG — body exception is lost if close() also throws
val migrationDs = HikariDataSource(hc)
try {
DbMigration(fwCfg, migrationDs).migrate().onFailure { throw it }
} finally {
migrationDs.close()
}
// CORRECT
HikariDataSource(hc).use { migrationDs ->
DbMigration(fwCfg, migrationDs).migrate().onFailure { throw it }
}

use is for resources whose lifetime ends with the current block. It does not fit when the resource’s lifetime is conditional — for example, a pool that must outlive the function on the happy path (because it backs a value you return to the caller) but must be closed on the failure path. In that case keep an explicit try { ... } catch { resource.close(); throw }:

val runtimePool = HikariDataSource(hc).also { allCreatedPools.add(it) }
val db = Database.connect(runtimePool)
try {
HikariDataSource(hc).use { migrationDs ->
DbMigration(fwCfg, migrationDs).migrate().onFailure { throw it }
}
} catch (t: Throwable) {
// runtimePool's lifetime is conditional — close it only on the failure
// path, since `db` (and therefore the pool) is returned to the caller on
// success.
allCreatedPools.remove(runtimePool)
runtimePool.close()
throw t
}
return db

Anti-pattern: surrogate close on the wrong type

Section titled “Anti-pattern: surrogate close on the wrong type”

Calling dataSource.connection.close() on a javax.sql.DataSource returns one connection to the pool — it does not close the pool. If the intent is to release the pool, hold a typed handle to the pool implementation (e.g., HikariDataSource) and wrap it in use { ... }.

Never use !! (the non-null assertion operator). It bypasses the compiler’s null-safety guarantees and throws KotlinNullPointerException at runtime — exactly the class of error that Kotlin’s type system is designed to prevent.

Instead, use one of the following patterns that give the compiler enough information to smart-cast the value:

// WRONG — runtime crash if null
val name = user.name!!
// CORRECT — when expression with smart cast
val name = when (val n = user.name) {
null -> return Result.failure(AppError.Infrastructure("name is required"))
else -> n // compiler knows n is non-null here
}
// CORRECT — if/else with smart cast
val name = user.name
?: return Result.failure(AppError.Infrastructure("name is required"))
// CORRECT — require() for preconditions
val name = requireNotNull(user.name) { "name must not be null" }

The when or if pattern is preferred because it keeps failure handling in the Result channel. Use requireNotNull only for true programming errors where a null value indicates a bug, not a user or configuration error.

Do not use nullable types to represent missing capabilities. If a class parameter is only needed by some callers, do not make it nullable to “disable” a feature. Instead, construct the class with full capabilities and let callers use only the methods they need. For example, a service that supports both PUT and POST should always be constructed with both capabilities — a caller that only needs PUT simply does not call the POST method.

  1. Inject dependencies, do not construct them internally. Classes should receive their dependencies (services, clients, configuration) as constructor parameters. The wiring point (Module.kt) creates all dependencies and passes them in. Classes should not create their own service instances, SDK clients, or other infrastructure objects.
  2. When a class needs access to an internal component of a dependency (e.g., an S3AsyncClient owned by an S3AssetService), the dependency should expose it as a public property rather than having the consumer construct a separate instance.
  1. Null should only be returned from methods that explicitly search or retrieve information. In these cases the method returns Result<T?>:

    • A null payload means the value was not found.
    • A failure means a different condition occurred (e.g., a required parent record was itself missing).

    Example: retrieving a child record from a parent-child relationship — if the parent is null, return failure. If the parent is found but the child is not, return success with a null payload.

  2. In all other cases (e.g., mutating system state), if the target of the operation is null or not found, return Result.failure(...).

Arda uses Exposed for database access.

  1. Column names must be specified in lowercase snake_case.

  2. Use column types defined under EntityTable. When those types are insufficient, use native Exposed column types.

  3. When using Filter.Eq and similar, use <TBL>.<COLUMN>.name for the locator parameter instead of hardcoded strings, to eliminate the risk of mismatched column names.

  4. JSON columns with custom serializers: The reified json<T>(name, format) overload calls serializer<T>() at runtime, which ignores @Serializable(with=...) and @Contextual annotations on type arguments. When T contains a non-@Serializable class (e.g., java.net.URI), use the three-argument overload with an explicit KSerializer:

    // WRONG — fails at runtime: serializer<Map<String, URI>>() cannot find URI serializer
    val sites = tbl.json<Map<String, URI>>(name, JsonConfig.standardJson)
    // CORRECT — explicit serializer
    val sites = tbl.json<Map<String, URI>>(name, JsonConfig.standardJson, MapSerializer(String.serializer(), URISerializer))

    EntityTable also provides standardJson<T>(name), which resolves contextual serializers from JsonConfig.standardJson.serializersModule. Prefer it over json<T>(name, JsonConfig.standardJson) when contextual serializers are sufficient.

  5. Unchecked casts to ChildTable: When casting EntityTable to ChildTable (required due to invariant generics on ExposedLocatorTranslator), always guard with check() before the cast:

    override val translator by lazy {
    check(persistence.bt is ChildTable) {
    "MyUniverse requires a ChildTable, got ${persistence.bt::class}"
    }
    queryConfig.bindToTable(persistence.bt as ChildTable)
    }
  6. QueryCompiler reuse: Never construct QueryCompiler(table) inline inside service methods. Define a module-level lazy val in the persistence package, or expose an internal accessor on the universe:

    // Module-level lazy val (preferred for child universes)
    internal val myQCompiler by lazy {
    QueryCompiler(MY_TABLE, myQueryConfig.bindToTable(MY_TABLE))
    }
    // Internal accessor on parent universe
    internal val queryCompiler get() = qCompiler

Persistence shape: Universe vs. plain table

Section titled “Persistence shape: Universe vs. plain table”

Choose how to persist an entity by whether it has a lifecycle:

  • No lifecycle — standalone, immutable records that are never updated (an audit / event log). A plain Exposed table is fine; there are no “versions of one entity” to interpret, so the Universe machinery buys nothing. Deduplication, if needed, is a unique-index concern.
  • Has a lifecycle — created, mutated, soft-deleted, or otherwise evolving over time (multiple rows are versions of one logical entity). Default to a bitemporal ScopedUniverse. Do not hand-roll soft-delete, version history, or tenant scoping on a plain table — the AbstractScopedUniverse / ScopedTable / ScopedRecord / Persistence stack already provides them, tested and composable: retired-flag soft-delete, a row per change (full audit history), scoped metadata (tenant_id, author, created / updated timestamps), structural tenant isolation via ScopedUniversalCondition, and create / read / findOne(Filter, asOf) / list(Query, asOf) / update / delete operations that honor the “latest non-retired version” semantics.
  • Special cases (e.g. a Draft Store, or another non-standard store) — consult the user / operator before deviating from the Universe default; do not pick a bespoke persistence shape unilaterally.

A business-key uniqueness constraint on a bitemporal table (e.g. “at most one active row per (tenant, key)”) is enforced in the application layer — check for a live entry before inserting — not by a DB partial-unique index: a row-version write inserts the new version before retiring the old one and would otherwise trip a non-deferrable unique constraint.

Naming pitfall: a ScopedTable column property named source (or any name that collides with an Exposed ColumnSet member) fails to compile — “‘source’ hides member of supertype ‘ColumnSet’ and needs an ‘override’ modifier.” Name the Kotlin property non-collidingly and keep the DB column name via the string argument, e.g. enumerated<…>("source").

  • Prefer java.net.URI over java.net.URL for URL-typed fields. URL.equals() and URL.hashCode() trigger DNS resolution, making URL unsafe as a map key, in collections, or inside data classes.
  • Use URI(...).toURL() instead of URL(String). The URL(String) constructor is deprecated in modern Java. Construct via URI first, then convert: URI("https://example.com/path").toURL().
  • Both URISerializer and URLSerializer are registered as contextual serializers in JsonConfig.standardJson. Use URISerializer explicitly when needed (see Database Mappings rule 4 above).
  • The wire format for URI is a plain JSON string. Changing a field from String to URI requires no database migration.

When changing a field type in a domain value object (e.g., String to URI), update all three layers:

  1. Domain: sealed interface property and Value data class property, including any default values.
  2. Persistence: component column definition, fill(), getComponentValue(), and setComponentValue().
  3. Tests: update test data construction and assertions.

Verify that the wire format (JSON serialization) is unchanged if no database migration is planned.

settings.gradle.kts files containing includeBuild("../common-module") or similar local composite build overrides must never be committed or pushed. Always exclude or stash settings.gradle.kts before staging commits.

Refer to the unit-tests skill in the workspace for detailed unit testing guidance.

  1. Prefer explicit imports over wildcard imports.
  2. Remove all unused imports before finishing a task.

Copyright: (c) Arda Systems 2025-2026, All rights reserved