Functions and Result Handling
Functions and Methods
Section titled “Functions and Methods”Enforced by ReturnCount (tree).
-
Any function or method that may fail must return
Result<T>instead of throwing an exception. -
Single exit point. Functions and methods must have a single
returnstatement (or a single expression body). Do not scatter multiplereturnorreturn@labelstatements throughout a function body. Instead, usewhenexpressions,Result.flatMapchains, or localvalbindings 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 pointssuspend 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 chainsuspend 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 withmap/flatMap/foldrather than branched around.An early
returnis what gets written instead of composing. Each one is a point where the failure left theResultchannel 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: awhenused 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
returnis 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
collectAllorflatMap(r1, r2, transform), which aggregate without leaving the channel — not a row of early returns. -
Prefer
whenexpressions overifstatements wherever possible. -
Use
Result.map,Result.flatMap, and similar operators to chain operations that may fail. Model the logic around Success and Failure channels. -
Do not use
getOrThroworgetOrNullto extract a value from aResult. UsemaporflatMapand place the logic inside the lambda. -
Result<T>for all fallible operations. Any operation that can fail — including URL construction, parsing, or other seemingly simple operations — must returnResult<T>. Consistency matters: if there is any code path that throws, wrap it inrunCatchingand returnResult. -
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). -
flatInApplicationContextfor coroutine context access. UseApplicationContext.Key.flatInApplicationContext { ctx -> ... }to accessApplicationContextfrom a coroutine. Do not useApplicationContext.current().flatMap { ... }—flatInApplicationContextis the idiomatic common-module pattern. -
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.
Tail-Recursive Retry Loops
Section titled “Tail-Recursive Retry Loops”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 auditvar result: SendOutcome? = nullvar attempts = 0while (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.
File Size and Cohesion
Section titled “File Size and Cohesion”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.
Result Handling
Section titled “Result Handling”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.
One normalizeFailure() per monadic chain
Section titled “One normalizeFailure() per monadic chain”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 chainbusinessAffiliateService.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.eIdval affiliateEId = supplierRef.affiliateEIdreturn 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 -> /* ... */ }}.unitify() over .map { }
Section titled “.unitify() over .map { }”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.
// WRONGbusinessAffiliateService.updateName(affiliateEId, supplierName, asOf.effective).map { }
// CORRECTbusinessAffiliateService.updateName(affiliateEId, supplierName, asOf.effective).unitify()Signatures imposed from outside
Section titled “Signatures imposed from outside”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’sBitemporalRecord) 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, orNothing, 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.
Related architecture patterns
Section titled “Related architecture patterns”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.
ResultExt Combinators
Section titled “ResultExt Combinators”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.
// Before — manual null check inside flatMapfun 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 stepfun 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.
// Before — manual fold with early exitfun 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 expressionfun 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).
Related
Section titled “Related”- Nullability and Return Types — choosing what a method returns before you compose it.
- Errors and Exceptions — what travels in the failure channel.
- Persistence and Transactions —
DBIO, the other monadic channel. - Kotlin Coding Standards — index.
Copyright: © Arda Systems 2025-2026, All rights reserved