Errors and Exceptions
Exception and Error Handling
Section titled “Exception and Error Handling”- All exceptions must be a subclass of
cards.arda.common.lib.lang.errors.AppError, using the most specific subclass available. - 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. UseThrowable.normalizeToAppError()to convert generic exceptions toAppError.
- Handle errors by returning a
kotlin.Resultwherever possible. - When integrating external libraries or calling methods that cannot return
kotlin.Result(e.g., constructors,toString,equals):- Run them inside a
runCatchingblock. - Convert the exception to an
AppErrorusingmapError, either manually or via the provided extension functionsThrowable.normalizeToAppError()orResult<T>.normalizeFailure().
- Run them inside a
- Collect all errors in validation functions. When a function validates
multiple conditions, collect all failures into a list and return
AppError.Compositewhen there are multiple errors, a singleAppErrorwhen there is one, orResult.successwhen 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. - Preserve the original
Throwableascause. When constructing a wrappingAppErrorfrom a caught exception, passcause = erron variants that accept it (IncompatibleState,Infrastructure,InternalService).AppError.ExternalServicedoes not acceptcausein the current common-module version (tracked in PDEV-767); omitcausefor 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
Resultfor “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
Resultthrough 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 throwAppError; they are not required to returnResult<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.
Non-Null Assertions (!!)
Section titled “Non-Null Assertions (!!)”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 nullval name = user.name!!
// CORRECT — when expression with smart castval 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 castval name = user.name ?: return Result.failure(AppError.Infrastructure("name is required"))
// CORRECT — require() for preconditionsval 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.
Related
Section titled “Related”- Nullability and Return Types — when an error belongs in the return type at all.
- Functions and Result Handling — composing failures.
- API Design — the HTTP boundary that turns a thrown
AppErrorinto a response. - Exception Handling — the
AppErrorhierarchy in architectural terms. - Kotlin Coding Standards — index.
Copyright: © Arda Systems 2025-2026, All rights reserved