Functions and Result Handling
Functions and Methods
Section titled “Functions and Methods”-
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)}} -
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”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”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”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 { }”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()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.
Documented exception:
fillPayloadbridging. The bitemporal framework’sfillPayload(row): EntityPayloadis declared to throw on corrupt rows (it predates theResultconvention). Smart-constructors insideComponent.build()returnResult; bridge them with.getOrThrow()only insidefillPayload. This is the only place in module code wheregetOrThrow()is acceptable, and only because the framework contract demands it. See Persistent Components § “ThefillPayloadboundary”.
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