Lotu Radar About · RSS

Signatures, be true: domain errors and functional handling in Kotlin

JetBrains Blog Developers & Open Source Score 8/10

Summary

Here’s a function that signs a document: In Kotlin, Unit means the function completes without returning a meaningful value – roughly equivalent to void in Java. Got it? Now, tell me what could go wrong. You can’t. Yet, the code might be invalid. The signing window might have closed. The database might be down. The […]

Original Text

Sergey Chernov

Sergey Chernov is a Lead Software Engineer at Salmon, specializing in functional Kotlin and type-safe system design. At Salmon, a technology-driven financial company building banking and lending products in Southeast Asia, Sergey works on authentication and verification systems: the platform layer responsible for keeping user access secure, reliable, and consistent across products. He has 10+ years of experience designing and building scalable backend systems.

Here’s a function that signs a document:

fun signDocument( documentId: UUID, code: String, ): Unit

In Kotlin, Unit means the function completes without returning a meaningful value – roughly equivalent to void in Java.

Got it? Now, tell me what could go wrong. You can’t.

Yet, the code might be invalid. The signing window might have closed. The database might be down. The document might already be signed, or expired, or the request might have arrived out of order from a buggy client.

Every one of those is a real outcome this function must reckon with. Not one is visible in the line above.

To discover possible failures and how to handle them, you could open the implementation. Then, the service it calls. Then, the exception handlers, the route mapping, the tests, the OpenAPI spec, and the client code that consumes it.

You could read everything except the one thing that should have told you in the first place: the signature.

At Salmon, I work on authentication and verification. A mishandled failure is rarely cosmetic and the difference between two error cases can be the difference between letting the right person through and the wrong one. I’ve spent a fair bit of time on this question: how do you make a function’s expected failures part of what it tells you, instead of something you have to go digging for?

This article is my answer. It uses Kotlin, but the concept carries to any language with sealed types.

Have no fear of “functional error handling”

“Functional error handling”. That phrase scares people off. They expect monads, category theory, and a lecture. This isn’t the case. The goal is plain: the function signature should be enough to know how to call it and how to handle every expected outcome. Nothing hidden in the body.

If a failure is part of the business logic, it belongs in the function signature, the API contract, and the client’s handling code, not buried in the implementation.

Salmon’s engineering culture runs on a few commitments: real ownership from day one, high standards held in the open, and a refusal to ship things that don’t actually work. A function that hides its failures is at odds with all three.

So, in the case of the example above, the signature I actually want should look like this:

fun signDocument( documentId: UUID, code: String, ): Either type didn’t work out and isn’t recommended for domain modeling. If the left side is open, you’ve gained nothing.

The second is one broad union shared across a whole class, in the name of not repeating yourself:

sealed interface DocumentError { data object SignatureRejected : DocumentError data object SigningWindowClosed : DocumentError data object AlreadySigned : DocumentError data object TemplateNotFound : DocumentError data object ExportFailed : DocumentError } fun signDocument(...) : Either<DocumentError, Unit> fun prepareSigning(...) : Either<DocumentError, SigningSession> fun exportDocument(...) : Either<DocumentError, ExportFile>

The compiler is happy, but now every method appears to return every error. signDocument can never produce TemplateNotFound, yet every caller has to account for it anyway. You get exhaustive handling full of impossible branches, which is just catch-all programming wearing a type.

The fix is to define one narrow union per public method:

sealed interface DocumentSignError { /* the three real failures */ } sealed interface PrepareSigningError { /* its own set */ } sealed interface ExportError { /* its own set */ }

Then each when handles only what its method can actually return. No else or impossible cases:

when (error) { SignatureRejected -> showSignatureRejected() SigningWindowClosed -> showSigningWindowClosed() AlreadySigned -> showAlreadySigned() }

A little more typing up front, but worth it every single time you read one of these signatures later.

Composition, without drowning in the plumbing

Real flows chain steps, and each step can fail. Done naively with flatMap, the lambdas nest deeper with every step and the code gets ugly.

You have a few ways out. Plain Kotlin handles it with early return:

val document = findDocument(documentId) .getOrElse { return it.left() }

Flat, typed, and the pattern itself needs no library: if you hand-roll Either, you write these helpers yourself. The syntax above happens to use Arrow’s getOrElse and left, but nothing here depends on the abstraction being fancy.

If you want it cleaner, Arrow also gives you an either { } block where bind() unwraps a right value and short-circuits on the first left:

either { val document = findDocument(documentId).bind() validateStatus(document).bind() val signature = validateSignature(document, code).bind() markSigned(document, signature).bind() }

This is the same idea Scala has had in the language for years with for-comprehensions. Use Arrow if the ergonomics help your team; it also brings useful types like non-empty lists. (But the contract idea does not depend on Arrow, and I’d rather you adopt the discipline than the dependency.)

The contract should survive the whole trip

A typed failure is only useful if it stays typed across the stack. Here’s the rule I hold to: services and repositories return domain errors, and you map to HTTP at exactly one place, the route boundary.

service.signDocument(request) .mapLeft { error -> error.toHttpResponse() }

Expected domain failures become an Either.Left. API-client misuse collapses to a coarse 4xx. Unexpected infrastructure failures and bugs stay as exceptions and become a 500. The controller is the only layer that knows about HTTP, and the layers beneath it speak in business outcomes.

There’s also a bonus most teams don’t realize here: If you publish your API client alongside the service, publish the error types with it. If you do this, the client handles failures with the same sealed union the server produces, and the two stay consistent for free.

How does this impact code review, and AI-generated code?

The day-to-day return on all of this shows up in review. When failures live in the signature, a reviewer can start from the contract instead of doing implementation archaeology. Did the error union change? Is this API-client misuse dressed up as a domain error? Does the new failure map to HTTP? You can answer those by reading the interface, before you ever open the body.

At Salmon and elsewhere, this agility matters more now that a large share of code is drafted by agents.

When a model writes the implementation, an explicit contract is the cheapest way to check whether it did the right thing: you read the types, not the 200 lines underneath. You can put the rule in an agent instructions file, “return a typed error union, don’t throw for expected failures,” and the model will mostly follow it. But the way you verify is by reading the contract, not by trusting the prose.

In fact, on our team at Salmon this is less a personal preference than a shared default: the contract is the unit of review, and a generated implementation doesn’t lower that bar. Deciding which failures an operation can actually produce is a judgment call, and the signature is where that judgment gets written down so the next person, or the next agent, has to respect it. Essentially, the signature is where ownership lives.

The honest tradeoff

This costs you something. More types, more mapping code, more verbose signatures. I won’t pretend otherwise.

But the complexity was already there. The signing window could always close. The code could always be wrong. All this approach does is take that complexity out of the implementation, where it was hiding, and put it in the type, where it’s named, tested, and visible.

You are simply moving the work to where the compiler can help. It surfaces risk to the next caller instead of hiding it, makes clear what the code really does and stops broken paths from compiling. Making failures part of the signature is how those values show up at the smallest scale: one function telling the truth about what it can do. It is also how we work in practice at Salmon: we share these typed contracts across services and their clients, and in review we read the contract before the implementation.

A signature that returns Unit and throws in secret is lying to you about what it does. Make your signatures tell the truth!

Developer ToolsIDESoftware

Lotu Radar provides attributed news summaries and links to the original publisher. Full reporting and copyright remain with the source.