Skip to content

Errors and Exceptions

Enforced by NonAppErrorFailure and MissingErrorCause (both tree).

  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.

An impossible state is one the service’s own writers cannot produce: a stored value that only a change made outside the service could have written. A hand-edited row, a future migration that writes the field, a regression in the writer, or storage corruption can all produce one. Each means something else is already broken.

The rule, as MP adopted it (2026-10-21, COORDINATOR-Q595), verbatim:

An impossible state (one the service’s own writers cannot produce) gets no design of its own. It is reported to Sentry as an error, and it fails the request (500) when the failure stays local to that request; when failing would reach beyond the request, it is logged and read as the nearest safe value. It comes to MP only if neither fits.

Why. A state the service never writes is a bug to be found, not a case to be designed for. Typed refusals, per-act rules and probe handling built for it are code that exists only to handle a defect. What it needs is to be seen, where errors are watched, without letting one corrupted row do more damage than it already has.

The rule has two outcomes. Decide which one applies by tracing what a failure would reach:

  1. The failure stays inside the request. Let it travel the failure channel as an AppError (usually AppError.IncompatibleState, with the bad value in the message). The request fails with a 500, and the error reaches Sentry.
  2. The failure would reach beyond the request. Examples: other entities’ operations, other callers, or background probes. Recover locally: log the failure at ERROR, naming the entity, the row and the bad value, and continue with the nearest safe value. The ERROR log is the Sentry report. In operations, the Logback SentryAppender forwards every ERROR event to Sentry, so no extra call is needed.

If neither fits, the case goes to MP as a question.

Worked example: a malformed stored CardCycle binding (operations#340). Each attach row stores the CardCycle it binds. Only one function writes that value, always from an EntityId, so a stored value that does not parse as one cannot come from the service.

The value is read while building the attachments of all of a demand’s members at once. Failing that read would refuse every act on the demand. It would also refuse other demands’ attaches of any card the demand lists, and would stop both background probes from healing its members.

That reach is beyond the request, so outcome 2 applies: the read returns a typed failure, and its one caller recovers it locally:

// The read: a value that does not parse is a typed failure (an IncompatibleState carrying the value);
// an absent key is a legitimate null.
internal fun DemandItemPayload.boundCycle(): Result<EntityId?> = /* ... */
// CORRECT — its one caller recovers explicitly, locally, and logs at ERROR (forwarded to Sentry)
private fun BitemporalEntity<DemandItemPayload, DemandItemMetadata>.storedBindingOrNone(): EntityId? =
payload.boundCycle().getOrElse { error ->
AttachmentLog.log.error(
"A demand's attach row stores a boundCycle that is not a CardCycle id, read as binding none: demand={} row={} reason={}",
eId,
rId,
error.message,
error
)
null
}
// WRONG — the failure is dropped and nothing records it
payload.boundCycle().getOrNull()
payload.boundCycle().fold(onSuccess = { it }, onFailure = { null })

The contrast case: had the binding been read for one request’s own entity only, outcome 1 would apply. The IncompatibleState failure would propagate, the request would return 500, and the error would reach Sentry from the boundary.

Why getOrElse with a log, and not getOrNull(). Both end in null, but only one leaves a record. getOrNull() and a fold whose failure branch is just null merge “legitimately absent” and “failed” into one value that no caller downstream can tell apart. Nothing records the failure. That is the swallowed error that rules 3 and 4 forbid.

The logged getOrElse is the explicit, local recovery the Result Handling rules allow. The failure is handled at one named site, it is recorded where errors are watched, and the safe value is chosen on purpose.

Not mechanized. Whether a state is impossible depends on every writer of the value, which a checker cannot read. The gate catches only the dropped form: ResultUnwrapping flags getOrNull(), and FoldToNull flags the fold to null.

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.

Enforced by NoNonNullAssertion (tree).

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.