Skip to content

Errors and Exceptions

  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.

Bootstrap-time validators may throw {#bootstrap-time-validators-may-throw}

Section titled “Bootstrap-time validators may throw {#bootstrap-time-validators-may-throw}”

The rule that a fallible method returns Result<T> has one carve-out: validation that runs while a component is wiring itself may throw instead.

This covers route-tree construction, registry population, module configuration, and anything else that executes once during start-up and never on a request path. Three reasons it is a genuine exception rather than a loophole:

  • A failure there is a programming error that must abort start-up, not a condition a caller recovers from. There is no sensible Result for “this build of the software is wired wrongly.”
  • There is no chain to compose into. The DSL blocks that do this wiring are statement-shaped builders, not monadic pipelines; threading a Result through them buys nothing and costs a great deal of syntax.
  • Failing loudly at boot is strictly safer than failing on the first request that happens to reach the misconfigured route.

It must be an AppError, not require’s IllegalArgumentException. AppError.ArgumentValidation(argumentName, validationMessage) is the right variant for a bad wiring argument. common-module’s endpoint DSL is the reference implementation — Group.addChild throws AppError.ArgumentValidation on a route collision, and its KDoc states the convention:

The entire DSL — addChild, sub, forResource, forService, get/post/put/delete — runs at component-startup time when each module wires its route tree. Per the workspace Kotlin coding convention, init-time validators are allowed to throw AppError; they are not required to return Result<Unit>.

// CORRECT — construct-once registry, validated in init, throws AppError at bootstrap.
class SettingsRegistry(definitions: List<SettingsDefinition<*>>) {
init {
definitions.groupBy { it.key }.filterValues { it.size > 1 }.keys.firstOrNull()?.let { dup ->
throw AppError.ArgumentValidation("definitions", "duplicate settings key '$dup'")
}
}
private val byKey = definitions.associateByTo(LinkedHashMap()) { it.key }
}

Validate before building the derived structure. associateBy keeps the last occurrence, so validating afterwards silently resolves the duplicate instead of rejecting it — and a validator that mutates before it fails leaves partial state behind.

Where a caller wants to drive validation programmatically, expose a separate Result<Unit>-returning validateConfiguration() alongside the throwing path — the shape Group uses. Add it when a caller appears, not speculatively.

Scope this narrowly. “Called during initialization” means only during initialization, structurally — a method that is also reachable from a request path is not covered, however it is named. If in doubt, return Result.

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.