Skip to content

Kotlin Coding Standards

The Kotlin coding standards for Arda backend services. These are binding, not advisory: they apply when writing or reviewing Kotlin in any Arda repository, and a design document that conflicts with them needs amending rather than implementing.

This page is the entry point. Read the eleven rules below first — they cover most of what a review will raise. Follow a link when you need the reasoning or the edge cases; each topic page is self-contained and short enough to read in one sitting.

If you remember nothing else, remember these.

RuleDetailEnforced by
1Anything that can fail returns Result<T>. Not an exception, not a null, not a sentinel.Functions and Result Handling—
2One return per function. Use when, flatMap chains, or local vals to funnel every path to a single exit. Why.Functions and Result HandlingReturnCount
3Never getOrThrow, getOrNull, or !! to pull a value out of a Result or a nullable. Put the logic inside map/flatMap.Functions and Result Handling · Errors and ExceptionsResultUnwrapping · NoNonNullAssertion · SingleBoundaryUnwrap
4Every error is an AppError, using the most specific subclass. Collect all validation failures into AppError.Composite rather than failing on the first.Errors and ExceptionsNonAppErrorFailure · MissingErrorCause
5A bare return type is a claim that the call cannot fail. Earn it structurally — usually with a private constructor — or return Result<T>.Nullability and Return TypesThrowsOutsideResult
6Nullable means “legitimately absent” and nothing else. Deserializing wire input and acting on a value both return Result<T>.Nullability and Return Types—
7If a type has an invariant, make it unconstructable without it — private constructor plus a companion factory returning Result<T>.Construction and Typed ValuesValidatedTypeConstructor
8Never a naked String or UUID for a domain value. Value class when it has rules, type alias when it does not.Construction and Typed ValuesNakedUuidDeclaration
9An entity with a lifecycle goes in a bitemporal Universe, not a hand-rolled table. Ask before deviating.Persistence and Transactions—
10A deferred DB action is a DBIO<T>, returned un-invoked, so the caller controls the transaction. Never collapse it to a value.Persistence and TransactionsCollapsedDBIO
11An impossible state gets no design of its own. A state the service’s own writers cannot produce goes to Sentry as an error. It fails the request (500) when the failure stays inside that request. When failing would reach beyond the request, it is logged at ERROR and read as the nearest safe value.Errors and Exceptions—

Seven of the eleven are checked mechanically rather than by review — see Automated checks below.

Roughly two hours, in this order. Each page assumes the ones before it.

  1. Functions and Result Handling — how every method in the codebase is shaped, and the combinators for composing them. Read this one properly; it is the grammar everything else is written in.
  2. Nullability and Return Types — decide what a method returns before you write its body.
  3. Errors and Exceptions — what travels in the failure channel, and the one place throwing is still correct.
  4. Construction and Typed Values — how invariants are made structural rather than documented.
  5. Persistence and Transactions — the storage decision and the transaction contract.
  6. Service-Layer Architecture — how the layers of a module divide responsibility.
  7. Resources and Dependency Injection — read when you first hold something closeable.

Then read outward. These conventions govern the code; several architecture pages govern the shape it goes into, and you need those before your first module lands — see Related architecture patterns. If this is your first Arda backend change, start from Backend Onboarding instead, which sequences both.

Load a topic page when its case is in the code in front of you; a review loads the pages whose cases are in the diff. A case list names the common cues, not every one: when the code does something a page’s “Covers” column names, load that page too.

PageCoversLoad it when the code has
Functions and Result HandlingSingle exit, Result<T> for fallible operations, when over if and direct conditions, KDoc @param and @return, fail-fast ordering, tail-recursive retry loops, file size and cohesion, package cohesion, one normalizeFailure() per chain, guard-don’t-checkNotNull, .unitify(), the ResultExt combinatorsa function that can fail; a Result chain (map, flatMap, normalizeFailure()); getOrThrow or getOrNull; more than one return; an if or when; a KDoc comment; a new or moved data class; a retry loop; a file or a package that keeps growing (a package over 25 main files)
Nullability and Return TypesBare vs. Result<T>; nullable vs. non-null; retrieval vs. deserialization vs. acting on a value; Result<T?> and when it is warranteda choice of return type (bare, Result<T>, nullable, Result<T?>); code that reads wire input
Errors and ExceptionsThe AppError hierarchy and choosing a subclass, collecting validations, preserving cause, impossible states, bootstrap-time validators, non-null assertionsa try, catch or throw; an AppError; a failure built or converted (Result.failure(...), an exception mapped to an error); a failure recovered to a value (getOrElse, recover); a state the service’s own writers cannot produce; a !!; a validation; a cause; a validator run at startup
Construction and Typed ValuesSmart constructors, invariants on data classes, URI over URL, domain-typed values, values on a scale (categorical, ordinal, interval), the type-migration checklista constructor, data class or value class with an invariant; a domain value held in a base type (String, UUID, Int, Long or another); a value with an order or a distance (a sequence number, a rank, a time); a URL; a type migration
Persistence and TransactionsDatabase mappings, Universe vs. plain Exposed table, DBIO and the transaction requirement, every database access a boundary or a participant, work that must not join the caller’s transactionan Exposed table or mapping; a Universe; a DBIO; a transaction; a method that reads or writes the database (is it a boundary or a participant?)
Service-Layer ArchitectureThe Universe boundary and every read of a Universe’s rows a member of that Universe, service composition (interfaces as contracts, one class per concern), sagas and orchestrators, wire surfaces carrying EntityRecord, shared vocabularya service, saga or orchestrator; an interface with method bodies; a read of a Universe’s rows outside it (exec, a Filter on column names); a wire or read surface (a DTO, a response, an EntityRecord); a reference to another entity on the wire; a shared constant; a call across a Universe boundary
Resources and Dependency Injectionuse and what to do when it does not fit, the surrogate-close anti-pattern, constructor injectionsomething closeable; use; a dependency, injected or built in place (a service, an SDK client)
Automated ChecksAdopting the standards plugin, declaring what the repository is, reading a report, choosing a scope, and writing rules of your ownthe standards plugin’s setup or version; an ardaStandards { ... } scope (ratchet, advisory); a standards-plugin or detekt finding; a @Suppress; a repository’s standards declaration; a new rule

Short enough to live here.

Enforced by LogProviderEnclosingClass (tree).

  • 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.

Enforced by WildcardImport and UnusedImport (both tree).

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

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, and to Backend Testing.

Most of what is written here is enforced by the Kotlin Standards plugin rather than left to review — twenty-five rules across two engines, each reporting against the section of this document that makes its claim. The Enforced by column above names the rule for each headline claim; the topic pages carry the same marker beside the individual sections.

Four of the eleven headline rules are not mechanized, and that is a statement about them rather than a gap in the tooling. Rules 6 and 9 turn on intent a checker cannot read — whether an absence is legitimate, whether an entity has a lifecycle. Rule 1 is the general principle whose checkable shadow is rule 5. Rule 11 turns on what every writer of a value can produce, which a checker cannot see from the read site; the gate catches only its dropped form (ResultUnwrapping, FoldToNull).

Automated Checks is the page for adopting the gate in a repository, reading its report, changing the scope a rule is held at, and adding rules of your own. Two things worth knowing before you meet a finding:

  • Every rule appears in every report, in exactly one of three states — findings, CLEAN (ran, found nothing), or SKIPPED (did not run, and why). A rule that ran and found nothing must never look like a rule that never ran.
  • A departure is stated where it applies. @Suppress with the reason above it puts the decision in the diff, where a reviewer meets it — and a waiver that is no longer holding anything back fails the build rather than becoming sediment.

These conventions are shaped by the platform, and occasionally the platform is what is wrong. If a rule makes a design impossible rather than merely inconvenient, say so — in the design document’s departures list, or in review. Rules have been changed for good arguments before. A departure that is named is a decision; one that is silent is a defect.

The conventions on this page govern how code is written. These govern what it is written into:


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