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.

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.

  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.

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