Skip to content

Functions and Result Handling

Enforced by ReturnCount (tree).

  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)
    }
    }

    Why this one is worth the friction. Single exit is a contested style rule in general, and it is not asserted here as a matter of taste. It follows from the two rules either side of it: everything fallible returns Result<T>, and failure is composed with map/flatMap/fold rather than branched around.

    An early return is what gets written instead of composing. Each one is a point where the failure left the Result channel and became control flow — and once failure travels as control flow, nothing checks that every path was handled. Inside the channel the compiler does that work: a when used as an expression must yield a value on every branch or it does not compile, while a missing early return is simply a path nobody wrote and nobody tested.

    So the rule is really compose, do not branch out early. The single return is the evidence that the body composed, not the goal in itself — which is also why it can be enforced by counting returns: the symptom is mechanically checkable where the cause is not.

    The usual argument for the opposite convention is that guard clauses reduce nesting and read better. That holds in a codebase where failure travels by exception and the guard is the only tool available. Here the guard clause and the failure channel are two mechanisms competing for one job, and keeping both is what makes error handling unauditable — you can no longer tell from a signature where a failure will surface.

    When a body genuinely has many independent checks, the answer is collectAll or flatMap(r1, r2, transform), which aggregate without leaving the channel — not a row of early returns.

  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.

Enforced by LargeSourceFile (tree).

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.

Enforced by ResultUnwrapping and FoldToNull (both tree).

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.

Enforced by SingleNormalizeFailure (tree).

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 -> /* ... */ }
}

Enforced by UnitifyOverEmptyMap (tree).

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()

Enforced by SingleBoundaryUnwrap (tree), which judges the body rather than the signature.

Some methods do not get to choose their return type. Overriding a method declared by a library or by generated code, or supplying a lambda for a functional type a library declares, means conforming to a signature written elsewhere: it cannot be widened to Result<T>, because the caller is the framework and the framework is not expecting one.

This is a narrow category, and the test is ownership rather than inconvenience:

  • It applies when the declaring type comes from a published dependency (kotlinx.serialization.KSerializer, io.grpc.ServerInterceptor, common-module’s BitemporalRecord) or from generated code we do not hand-edit (protobuf and gRPC bindings).
  • It does not apply to our own interfaces. If we declare the interface, we declare it returning Result<T> — an awkward signature we control is a signature to change, not to except.
  • It does not apply merely because the return type is fixed. Where the overridden declaration already returns Result, Unit, or Nothing, the failure channel is intact and nothing here is needed.

The exemption is on the signature, not on the body. Being unable to return a Result is not permission to stop working in one. Compose the body exactly as any other: build a Result<T>, keep it through map/flatMap, and unwrap once, at the end, to hand the framework the shape it demands.

// CORRECT — the body composes in Result; a single terminal unwrap converts at the boundary.
override fun fillPayload() {
payload = Facility.of(eId, name).getOrThrow()
}
// WRONG — the signature was imposed, so the body gave up on the channel entirely.
override fun fillPayload() {
val id = eId ?: throw IllegalStateException("no eId")
payload = Facility(id, name.trim().ifEmpty { throw IllegalStateException("no name") })
}

Two unwraps in one body means the body is not composing — combine the steps with flatMap or collectAll so there is one place where the channel ends. That single place is also where the failure becomes what the framework expects: a StatusException for gRPC, a SerializationException for kotlinx.serialization. Prefer a named conversion returning Nothing — a signature that cannot mislead a reader about whether it returns.

fillPayload is an instance of this category rather than a special case of its own. The bitemporal framework declares fillPayload(row): EntityPayload and it predates the Result convention, so a smart constructor returning Result is bridged with a single .getOrThrow() inside it. See Persistent Components § “The fillPayload boundary”.

This is the outbound direction. For the inbound one — calling a library that throws rather than implementing one that does — see Errors and Exceptions, which asks for runCatching and a conversion to AppError.

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

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).