Skip to content

Functions and Result Handling

  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.

When a service must attempt an operation up to N times, express the loop as a tail-recursive private suspend function — not as a mutable variable threaded through a while loop.

Anti-pattern — mutable state in a loop:

// WRONG — mutable state, multiple exit points, hard to audit
var result: SendOutcome? = null
var attempts = 0
while (attempts < maxAttempts) {
result = trySend(request)
attempts++
if (result is SendOutcome.Sent || result is SendOutcome.Rejected) break
}
return result ?: SendOutcome.Exhausted(maxAttempts)

The loop’s terminal condition requires reading the loop body, the if guard, and the post-loop fallback together. Refactoring the terminal cases is error-prone.

Canonical replacement — tail-recursive function:

// CORRECT — single exit per branch, max depth bounded by maxAttempts
// (canonical shape from EmailSender.attemptSend in
// cards.arda.operations.shopaccess.email.service)
private suspend fun attemptSend(
request: SendRequest,
attempt: Int = 1,
): SendOutcome = when {
attempt > maxAttempts -> SendOutcome.Exhausted(maxAttempts)
else -> when (val outcome = trySend(request)) {
is SendOutcome.Sent, is SendOutcome.Rejected -> outcome // terminal
is SendOutcome.TransientFailure -> attemptSend(request, attempt + 1)
}
}

Each branch either returns a terminal result or recurses — the depth is bounded by maxAttempts (default 3). The JVM stack is not at risk for small bounds.

Note: The recursion cannot be annotated tailrec because Kotlin’s tailrec does not compose with suspend. This is a known language limitation; the depth bound remains the safety guarantee.

Keep source files small and cohesive. The size limits below are a signal, not a hard gate: a file a few lines over is not a defect, but a file well past the limit almost always hides more than one responsibility.

  • Production files: ≤ 300 lines.
  • Test files: ≤ 500 lines.

When a file grows past its limit, split it along its internal seams — extract collaborators, factory or extension functions, or sub-services that each own a cohesive slice of the behavior and the state/dependencies it needs. Split by concern, not by arbitrary line count: two halves that share the same collaborators and reading order belong together; the goal is cohesion, not merely a smaller number. The orders module is a worked example of decomposing a large service into cohesive collaborators wired at the composition root.

When a single class legitimately exceeds the limit and cannot be cleanly split, record the reason in review and seek sign-off rather than silently shipping 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.

A single .normalizeFailure() at the tail of a flatMap chain is sufficient — it converts any generic exception surfacing anywhere in the chain into an AppError. Do not sprinkle .normalizeFailure() after each step. Add a second one only when the body genuinely branches on error type and a later branch must normalize independently of the tail.

// CORRECT — one normalizeFailure() covers every failure in the chain
businessAffiliateService.findByNameAndRole(name = supplierName, role = VENDOR, asOf = asOf)
.flatMap { existingBa ->
when (existingBa) {
null -> businessAffiliateService.add(/* ... */).flatMap { /* createBusinessRole */ }
else -> businessAffiliateService.businessRolesFor(existingBa.payload.eId, asOf).flatMap { /* link */ }
}
}.normalizeFailure()

Guard, don’t checkNotNull, inside a Result chain

Section titled “Guard, don’t checkNotNull, inside a Result chain”

When an invariant guarantees two nullable fields are co-present (e.g., a smart constructor that makes a non-null eId imply a non-null affiliateEId), funnel them to non-null locals with a single when guard that returns a Result.failure for the impossible-but-typed case. The smart-cast keeps the rest of the body in the Result channel. Never reach for checkNotNull, !!, or getOrThrow to discharge such an invariant in production resolution code — those throw, escaping the Result channel.

val roleEId = supplierRef.eId
val affiliateEId = supplierRef.affiliateEId
return when {
roleEId == null || affiliateEId == null -> Result.failure(
AppError.IncompatibleState(
"resolveWithExistingRef requires a linked SupplierReference (eId and affiliateEId)"
)
)
// roleEId and affiliateEId are smart-cast non-null below; the body stays in Result.
else -> businessAffiliateService.detailsFor(affiliateEId, asOf).flatMap { details -> /* ... */ }
}

To convert a Result<T> whose value you no longer need into a Result<Unit>, use .unitify() (cards.arda.common.lib.lang) rather than .map { }. It states the intent — discard the value, keep the success/failure channel — without an empty lambda.

// WRONG
businessAffiliateService.updateName(affiliateEId, supplierName, asOf.effective).map { }
// CORRECT
businessAffiliateService.updateName(affiliateEId, supplierName, asOf.effective).unitify()

A backend session writing reference-resolution or cross-entity persistence code should also read these pattern pages:

  • Cross-Child Queries — tenant-scoped queries that resolve child entities across many parents in a single pass (no N+1).
  • Parent-Child Persistence — entities scoped to a parent (lines on a header, roles on a company).
  • Data Authority Module Pattern — the four-layer module structure that owns these services and universes. Documented exception: fillPayload bridging. The bitemporal framework’s fillPayload(row): EntityPayload is declared to throw on corrupt rows (it predates the Result convention). Smart-constructors inside Component.build() return Result; bridge them with .getOrThrow() only inside fillPayload. This is the only place in module code where getOrThrow() is acceptable, and only because the framework contract demands it. See Persistent Components § “The fillPayload boundary”.

The ResultExt.kt file in common-module provides combinators that eliminate boilerplate when/if chains. Use them before reaching for manual unwrapping.

resultNotNull — collapse nullable success to typed failure

Section titled “resultNotNull — collapse nullable success to typed failure”

Use when a Result<T?> null payload means “not found, and the caller requires a value.” It converts a Result.success(null) into a Result.failure(err) while leaving non-null successes and failures unchanged.

cards.arda.operations.shopaccess.email.service.EmailConfigurationServiceImpl
// Before — manual null check inside flatMap
fun getServerToken(configEId: EntityId): Result<PostmarkAccountToken> =
configRepo.findByEId(configEId)
.flatMap { cfg ->
if (cfg == null) Result.failure(AppError.NotFound("emailConfiguration"))
else Result.success(cfg.token)
}
// After — resultNotNull collapses the nullable step
fun getServerToken(configEId: EntityId): Result<PostmarkAccountToken> =
configRepo.findByEId(configEId)
.resultNotNull(AppError.NotFound("emailConfiguration"))
.map { it.token }

flatMap(r1, r2, transform) — combine two independent Results

Section titled “flatMap(r1, r2, transform) — combine two independent Results”

Use when two separate Result computations are both needed before a downstream step. If either fails the combinator short-circuits; if both succeed, transform receives both values.

flatMap(resolveConfig(tenantId), resolveSender(senderId)) { cfg, sender ->
EmailJob(cfg, sender, payload)
}

collectAll — fail-fast aggregation over a collection

Section titled “collectAll — fail-fast aggregation over a collection”

Use when mapping a collection of inputs to Result<T> and needing Result<List<T>>. It stops at the first failure and returns it; all items must succeed for the list to be returned.

cards.arda.operations.shopaccess.email.service.MaterialRegistryRefresher
// Before — manual fold with early exit
fun loadAll(paths: List<Path>): Result<List<Template>> {
val results = mutableListOf<Template>()
for (p in paths) {
val r = loadTemplate(p)
if (r.isFailure) return r.map { emptyList() } // awkward cast
results += r.getOrThrow()
}
return Result.success(results)
}
// After — collectAll is a single expression
fun loadAll(paths: List<Path>): Result<List<Template>> =
paths.map { loadTemplate(it) }.collectAll()

Choosing collectAll vs a collect-all-errors approach. collectAll is fail-fast: it stops and returns the first failure. When the goal is to surface all validation errors at once (so the user can fix everything in one pass), wrap each item’s failure in AppError.Composite instead — accumulate all AppError values, then return AppError.Composite(message, causes) when the list is non-empty. Reserve collectAll for pipeline steps where the first failure is the only actionable signal (e.g., loading required files on startup).