{"id":4472,"date":"2026-08-20T04:03:42","date_gmt":"2026-08-20T04:03:42","guid":{"rendered":"https:\/\/tucumandevelopers.com\/index.php\/2026\/08\/20\/signatures-be-true-domain-errors-and-functional-handling-in-kotlin\/"},"modified":"2026-08-20T04:03:42","modified_gmt":"2026-08-20T04:03:42","slug":"signatures-be-true-domain-errors-and-functional-handling-in-kotlin","status":"publish","type":"post","link":"https:\/\/tucumandevelopers.com\/index.php\/2026\/08\/20\/signatures-be-true-domain-errors-and-functional-handling-in-kotlin\/","title":{"rendered":"Signatures, be true: domain errors and functional handling in Kotlin"},"content":{"rendered":"<div>\n<div>\n<section data-clarity-region=\"article\">\n<div>\n<p><a href=\"\/kotlin\/category\/backend\/\">Backend<\/a> <a href=\"\/kotlin\/category\/kotlin\/\">Kotlin<\/a><\/p>\n<h2 id=\"major-updates\">Signatures, be true: domain errors and functional handling in Kotlin<\/h2>\n<div>\n<div>\n<h4>Sergey Chernov<\/h4>\n<p>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.<\/p>\n<\/p><\/div>\n<\/p><\/div>\n<p>Here\u2019s a function that signs a document:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">fun signDocument( documentId: UUID, code: String, ): Unit<\/pre>\n<p>In Kotlin, <code>Unit<\/code> means the function completes without returning a meaningful value \u2013 roughly equivalent to <code>void<\/code> in Java.<\/p>\n<p>Got it? Now, tell me what could go wrong. <em>You can\u2019t<\/em>.&nbsp;<\/p>\n<p>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.&nbsp;<\/p>\n<p>Every one of those is a real outcome this function must reckon with. Not one is visible in the line above.<\/p>\n<p>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.&nbsp;<\/p>\n<p>You could read everything except the one thing that should have told you in the first place: <strong>the signature<\/strong>.<\/p>\n<p>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\u2019ve spent a fair bit of time on this question: <strong>how do you make a function\u2019s expected failures part of what it tells you, instead of something you have to go digging for<\/strong>?&nbsp;<\/p>\n<p>This article is my answer. It uses Kotlin, but the concept carries to any language with sealed types.<\/p>\n<h2>Have no fear of \u201cfunctional error handling\u201d<\/h2>\n<p>\u201c<em>Functional error handling<\/em>\u201d. That phrase scares people off. They expect monads, category theory, and a lecture. This isn\u2019t 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.&nbsp;<\/p>\n<p>If a failure is part of the business logic, it belongs in the function signature, the API contract, and the client\u2019s handling code, not buried in the implementation.<\/p>\n<p>Salmon\u2019s 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\u2019t actually work. A function that hides its failures is at odds with all three.&nbsp;<\/p>\n<p>So, in the case of the example above, the signature I actually want should look like this:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">fun signDocument( documentId: UUID, code: String, ): Either&lt;DocumentSignError, Unit&gt;<\/pre>\n<p>We now have the inputs on the left of the function and the expected failure type and the success type on the right.&nbsp;<\/p>\n<p>Now, before we get to what <code>Either<\/code> is, we need to agree on what belongs inside <code>DocumentSignError<\/code> in the first place, because that\u2019s where a lot of the value of this system comes from.<\/p>\n<h2><strong>Three kinds of failure, but only one belongs in the signature<\/strong><\/h2>\n<p>Not every bad thing that happens is the same kind of bad thing. I split failures into three groups, and each group gets handled differently.<\/p>\n<h3><strong>01 \u00b7 API CLIENT ERRORS<\/strong><\/h3>\n<p>The caller used the API wrong: this means a malformed JSON, a missing header, an unsupported operation, a request that arrived out of sequence, access that isn\u2019t allowed.&nbsp;<\/p>\n<p>A healthy client should almost never see these, and there is no designed screen for them, because a working app doesn\u2019t produce them. Thus, you can collapse the whole category into coarse HTTP responses: a 400, a 403, a 404. You do not enumerate them one by one in your domain model<strong>.<\/strong><\/p>\n<h3><strong>02 \u00b7 UNEXPECTED EXCEPTIONS<\/strong><\/h3>\n<p>The database is unavailable. A dependency timed out. The network dropped. A null slipped through and you have a <code>NullPointerException<\/code>, or an invariant broke and you\u2019re in an illegal state. These are <em>not<\/em> business outcomes.&nbsp;<\/p>\n<p>Nobody designs a user flow for \u201cPostgres fell over.\u201d You do not model these as domain errors. Instead, they become operational signals: a 500 to the client, a full stack trace in the logs, a spike in your error-rate metric, a page to whoever is on call.<\/p>\n<h3><strong>03 \u00b7 DOMAIN ERRORS<\/strong><\/h3>\n<p>Here, the client behaved correctly, yet the operation still can\u2019t succeed.&nbsp;<\/p>\n<p>The signing code was wrong. The window has closed. The document was already signed. Approval is missing. The policy rejected it. These are the failures a real user hits while doing everything right, and your designers have a specific screen for each one.&nbsp;<\/p>\n<p>This is the category that has to be visible. If a healthy client needs to handle two outcomes differently, those two outcomes have to be distinguishable in the type. <strong>This is the group that belongs in the contract.<\/strong><\/p>\n<p>I often see people mistakenly dragging the second group into the other two. For instance, people add <strong><code>DatabaseUnavailable<\/code><\/strong> to their error union as if it were a business failure. It isn\u2019t. Let it throw, let the global handler catch it, and keep your domain model honest.&nbsp;<\/p>\n<p><code>HTTP 400<\/code> is not a domain concept. \u201cSigning window closed\u201d is.<\/p>\n<p>In any case, if you recognize and split these three categories correctly, most of the design work is already done. The rest is choosing a mechanism that keeps the second group visible.<\/p>\n<h2>Why exceptions and their relatives keep losing<\/h2>\n<p>The default in most Java and Kotlin codebases is to validate, then throw:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">fun signDocument(documentId: UUID, code: String) { if (signingWindowClosed(documentId)) throw SigningWindowClosedException() if (!codeMatches(documentId, code)) throw SignatureRejectedException() if (alreadySigned(documentId)) throw AlreadySignedException() \/\/ ... sign it }<\/pre>\n<p>The signature says \u201creturns nothing, succeeds.\u201d But the implementation tells a different story, and the compiler will not make the caller listen to it. If someone adds a fourth exception next quarter, every call site still compiles, and every call site silently fails to handle the new case. You find out in production, and that\u2019s not great.<\/p>\n<p>Java tried to fix this with checked exceptions, and the instinct was right: force the caller to handle declared failures or pass them on. But it didn\u2019t scale. And the Stream API doesn\u2019t compose with checked exceptions at all, so you end up doing sneaky throws and wrapping everything back into runtime exceptions.<\/p>\n<p>As it turns out, the better tool is already in the language itself. A sealed interface tells the compiler the complete set of subtypes, this means that when you handle these errors (using Kotlin\u2019s <code>when<\/code> expression), the compiler can safely verify you haven\u2019t missed a single case:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">sealed interface DocumentSignError { data object SignatureRejected : DocumentSignError data object SigningWindowClosed : DocumentSignError data object AlreadySigned : DocumentSignError }<\/pre>\n<p>Now the caller handles every case, and the compiler enforces it:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">when (error) { SignatureRejected -&gt; showSignatureRejected() SigningWindowClosed -&gt; showSigningWindowClosed() AlreadySigned -&gt; showAlreadySigned() }<\/pre>\n<p>Add a fourth failure to the sealed interface and this <strong><code>when<\/code><\/strong> stops compiling until you handle it. And this is the whole game: the compiler now knows what <em>can<\/em> fail, and it won\u2019t let you forget.<\/p>\n<h2><strong>You just reinvented Either<\/strong><\/h2>\n<p>Once you have a sealed error type, you need a way to say \u201cthis function returns either that error or a success.\u201d You can build a wrapper by hand, and people do, for each result type, over and over. That gets verbose fast.<\/p>\n<p>What you\u2019re reaching for is a generic version of the same shape: a value that is one thing or the other, never both. Left for the failure, right for the success. That is <strong><code>Either<\/code><\/strong>, and you don\u2019t need a library to understand it. It\u2019s a sealed type with two cases and a handful of helper methods (<strong><code>map<\/code><\/strong>, <strong><code>flatMap<\/code><\/strong>, <strong><code>fold<\/code><\/strong>, <strong><code>getOrElse<\/code><\/strong>). If you\u2019ve used <strong><code>Optional<\/code><\/strong> in Java or nullable types in Kotlin, you already know how it feels to work with. An <strong><code>Optional<\/code><\/strong> is roughly an <strong><code>Either<\/code><\/strong> whose left side carries no information, just <strong><code>Unit<\/code><\/strong>.<\/p>\n<p>The payoff is that the failure set moves into the public type:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">fun signDocument( documentId: UUID, code: String, ): Either&lt;DocumentSignError, Unit&gt;<\/pre>\n<p>Failures are no longer hidden in the function body; they are part of what the function tells you upfront.<\/p>\n<h2><strong>Two unions people get wrong<\/strong><\/h2>\n<p>Unfortunately, two anti-patterns show up constantly once teams adopt this, and both undo most of the benefit.<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">fun signDocument(documentId: UUID, code: String): Either&lt;Throwable, Unit&gt;<\/pre>\n<p>While this looks typed, the type says only \u201csomething can fail.\u201d It does not say which expected failures the caller must handle, because <strong><code>Throwable<\/code><\/strong> is open, so a <strong><code>when<\/code><\/strong> over it always needs an <strong><code>else<\/code><\/strong>. You\u2019re back to not knowing.&nbsp;<\/p>\n<p>This is essentially the same as throwing an error, and it\u2019s why Kotlin\u2019s own <strong><code>Result&lt;T&gt;<\/code><\/strong> type didn\u2019t work out and isn\u2019t recommended for domain modeling. If the left side is open, you\u2019ve gained nothing.<\/p>\n<p>The second is one broad union shared across a whole class, in the name of not repeating yourself:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">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&lt;DocumentError, Unit&gt; fun prepareSigning(...) : Either&lt;DocumentError, SigningSession&gt; fun exportDocument(...) : Either&lt;DocumentError, ExportFile&gt;<\/pre>\n<p>The compiler is happy, but now every method appears to return every error. <strong><code>signDocument<\/code><\/strong> can never produce <strong><code>TemplateNotFound<\/code><\/strong>, 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.<\/p>\n<p>The fix is to define one narrow union per public method:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">sealed interface DocumentSignError { \/* the three real failures *\/ } sealed interface PrepareSigningError { \/* its own set *\/ } sealed interface ExportError { \/* its own set *\/ }<\/pre>\n<p>Then each <strong><code>when<\/code><\/strong> handles only what its method can actually return. No <strong><code>else<\/code><\/strong> or impossible cases:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">when (error) { SignatureRejected -&gt; showSignatureRejected() SigningWindowClosed -&gt; showSigningWindowClosed() AlreadySigned -&gt; showAlreadySigned() }<\/pre>\n<p>A little more typing up front, but worth it every single time you read one of these signatures later.<\/p>\n<h2><strong>Composition, without drowning in the plumbing<\/strong><\/h2>\n<p>Real flows chain steps, and each step can fail. Done naively with <strong><code>flatMap<\/code><\/strong>, the lambdas nest deeper with every step and the code gets ugly.&nbsp;<\/p>\n<p>You have a few ways out. Plain Kotlin handles it with early return:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">val document = findDocument(documentId) .getOrElse { return it.left() }<\/pre>\n<p>Flat, typed, and the pattern itself needs no library: if you hand-roll <strong><code>Either<\/code><\/strong>, you write these helpers yourself. The syntax above happens to use <code><strong>Arrow\u2019s<\/strong> <strong>getOrElse<\/strong><\/code> and <strong><code>left<\/code><\/strong>, but nothing here depends on the abstraction being fancy.&nbsp;<\/p>\n<p>If you want it cleaner, <strong><code>Arrow<\/code><\/strong> also gives you an <strong><code>either { }<\/code><\/strong> block where <strong><code>bind()<\/code><\/strong> unwraps a right value and short-circuits on the first left:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">either { val document = findDocument(documentId).bind() validateStatus(document).bind() val signature = validateSignature(document, code).bind() markSigned(document, signature).bind() }<\/pre>\n<p>This is the same idea Scala has had in the language for years with for-comprehensions. Use <strong><code>Arrow<\/code><\/strong> if the ergonomics help your team; it also brings useful types like non-empty lists. (But the contract idea does not depend on <strong><code>Arrow<\/code><\/strong>, and I\u2019d rather you adopt the discipline than the dependency.)<\/p>\n<h2><strong>The contract should survive the whole trip<\/strong><\/h2>\n<p>A typed failure is only useful if it stays typed across the stack. Here\u2019s the rule I hold to: services and repositories return domain errors, and you map to HTTP at exactly one place, the route boundary.<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">service.signDocument(request) .mapLeft { error -&gt; error.toHttpResponse() }<\/pre>\n<p>Expected domain failures become an <strong><code>Either.Left<\/code><\/strong>. 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.<\/p>\n<p>There\u2019s also a bonus most teams don\u2019t 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.<\/p>\n<h2><strong>How does this impact code review, and AI-generated code?<\/strong><\/h2>\n<p>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.<\/p>\n<p>At Salmon and elsewhere, this agility matters more now that a large share of code is drafted by agents.&nbsp;<\/p>\n<p>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, \u201c<em>return a typed error union, don\u2019t throw for expected failures,<\/em>\u201d and the model will mostly follow it. But the way you verify is by reading the contract, not by trusting the prose.&nbsp;<\/p>\n<p>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\u2019t 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.<\/p>\n<h2><strong>The honest tradeoff<\/strong><\/h2>\n<p>This costs you something. More types, more mapping code, more verbose signatures. I won\u2019t pretend otherwise.&nbsp;<\/p>\n<div>\n<p>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\u2019s named, tested, and visible.<\/p>\n<p>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.<\/p>\n<\/div>\n<p>A signature that returns <strong><code>Unit<\/code><\/strong> and throws in secret is lying to you about what it does. Make your signatures tell the truth!<\/p>\n<\/p><\/div>\n<p> <a href=\"#\"><\/a> <\/section>\n<div>\n<p><h2>Discover more<\/h2>\n<\/p><\/div>\n<\/p><\/div>\n<\/div>\n<\/div>\n<\/div>\n<p>Fuente: <a href=\"https:\/\/blog.jetbrains.com\/kotlin\/2026\/08\/signatures-be-true-domain-errors-and-functional-handling-in-kotlin\/\">Art\u00edculo original<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Backend Kotlin Signatures, be true: domain errors and functional handling in Kotlin 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 [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":4471,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2},"webixso_pending_account_ids":""},"categories":[46],"tags":[],"class_list":["post-4472","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-jetbrain"],"jetpack_publicize_connections":[],"_links":{"self":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/4472","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/comments?post=4472"}],"version-history":[{"count":0,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/4472\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media\/4471"}],"wp:attachment":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media?parent=4472"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/categories?post=4472"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/tags?post=4472"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}