Two years later, I know what I got wrong about typed domain errors and what I’d keep

This is a personal engineering essay, not a blueprint. I don’t know what the perfect service architecture looks like, and I won’t pretend I do. I’m not trying to sell this approach, I just want to share what I learned, in case it helps someone else avoid a few wrong turns.
Two years later, I know what I got wrong about typed domain errors and what I’d keep

I love this style: a function’s expected failures are spelled out in its signature as a sealed type, rather than hidden in its body.

A couple of years ago, I tried something very similar in a toy project, but I never saw the contract benefit. The style wasn’t the problem, and doing more of it wasn’t the fix.

This essay responds to a JetBrains blog post about typed domain errors. I agree with its diagnosis, though I’m clearer about what not to do than about the ideal approach. I also share what I got wrong, in the hope you can avoid the same mistakes.

The blog post I’m responding to

Sergey Chernov’s “Signatures, be true: domain errors and functional handling in Kotlin” makes a claim I strongly agree with: a signature like fun signDocument(id, code): Unit that throws in secret is lying about what it can do, and the fix is to put the expected failures in the type.

His core contribution is the three-category taxonomy, and it’s the most useful part of the post.

  1. API client misuse, e.g. malformed JSON, a missing header or an out-of-sequence request. Collapse these to a coarse 4xx and keep them out of the domain model. Access control is a partial exception. A bare authentication failure is transport-level and stays out, but an ownership or permission decision that depends on business state often belongs in the union, because the caller needs the specific reason and not just a 403.
  2. Unexpected exceptions, e.g. the database being down, a timeout, a dropped network or a bug. Throw these, and let the global handler turn them into a 500 with a stack trace and an on-call page.
  3. Domain errors, e.g. a wrong sign code, a closed window or an already-signed document. This is the only category that belongs in the contract, as a sealed type.

A sealed type, in case that term is unfamiliar, is a closed hierarchy where the compiler knows every subtype. That is what makes a when over it exhaustive, with no else branch, and it’s the whole game.

Everything else in the post is implementation. It covers a sealed interface for the error union, an Either<DomainError, T> return, exhaustive when, HTTP mapping only at the route boundary (the Ktor layer that owns the routes). It also publishes the error types alongside the API client, and its central recommendation is one narrow union per public method, rather than one shared union across a class. Here Either is just a value that is either a Left failure or a Right success. The line I would keep is “the contract is the unit of review”, which means you read the types rather than the 200 lines underneath. That is doubly true when an AI wrote the implementation.

The post also skips over how to compose heterogeneous error unions, which is real work I had to figure out on my own. Real flows fail per step with different unions. A plain either { } block, which chains fallible steps and short-circuits on the first failure, infers a single left type from all of them. Mixing unrelated domain errors therefore gives you their least upper bound, meaning the most specific common supertype the compiler can find. When the types are unrelated, that is Any, which is the open-error problem the post warns about. A shared sealed interface is different, since it stays closed, but a union that broad is the contract bloat the post warns about elsewhere.

The post is also quiet about how to observe legitimate domain values. It says even less about the cost of mis-categorizing infrastructure failures as domain errors, which I ran into myself. The same open-type reasoning is why Result<T> is a poor fit for domain modeling. Its failure side is Throwable, so a when over the failure value always needs an else and the contract gains nothing.

A note on the boundary: API-client misuse vs validation

One line the taxonomy leaves murky is the line between API client misuse and validation. I read that line as two different things the post merges.

The first is structural or transport validation, such as malformed JSON, a missing field or a wrong wire type. This is genuine client misuse. A healthy client never produces it and no designed screen is needed, so a coarse 400 is enough.

The second is semantic validation, where a well-formed field violates a real rule and carries a meaningful message, like TooShortUsername or NoDigitsInPassword. Here the client benefits from knowing exactly which field is wrong and why, and often from getting all of the problems at once rather than just the first one.

Where the exact line falls is not well-defined, as it depends on the use case and on the implementor’s preference. Some APIs deliberately collapse semantic validation to a coarse 400. Others return rich, accumulated field errors, which is what my validation tree did and it’s the part I still like. My own leaning is to let structural junk stay a coarse 400 at the framework boundary. Semantic validation can come back as a value, accumulated, but kept as its own validation-result type rather than folded into the business domain union.

One wrinkle deserves naming. A rule like “this email is already taken” looks like validation, but it reads persisted state, so it’s really a domain conflict and belongs in the domain union, not in the request-validation result. It’s a fair criticism of the taxonomy that the first category needs this much boundary work, but it’s still a better starting point than nothing. I won’t pretend there is a single correct division here. It’s a judgment call for each API.

My case study: a good design at the wrong layer

In 2024 I built a Ktor/Kotlin/Arrow backend (instakt, now an unmaintained toy project) and went fully functional on error handling, but with no grouping by use case. This is how the style gets misused, and the taxonomy above is the frame I wish I had back then, because without it I mislabeled infrastructure failures as domain outcomes.

The design was coherent in principle. One wide root union DomainError held a large tree of cases, and my database bucket alone had about 26 of them. Services mostly returned EitherNel<DomainError, T>, though a few methods returned a non-accumulating Either<DomainError, T> instead. EitherNel is an Either whose failure side is a NonEmptyList, which is how several errors get accumulated.

A single total mapper DomainError -> HttpStatusCode owned all of the exhaustiveness. It handled every case and returned a status, while the response body carried the error detail. It looked elegant and it compiled. Reading it back, three things were wrong.

The most obvious problem was that the per-method contract was gone. Every method returned the whole DomainError union. A signature like upload(...): EitherNel<DomainError, ContentMetadata> told a reader almost nothing about which failures that one call could produce. The blog’s whole point, that the signature tells you what can go wrong for this call, was silently surrendered. Because the central mapper owned exhaustiveness, I avoided the impossible-branch work, but the signature stopped telling the truth.

The problem I most regret was observability. I had modeled infrastructure failures, a failed persist or a failed mail send, as union values. My exception<Throwable> handler logged only cause.message with no stack trace. Between the two, infrastructure failures were turned into a status by the mapper and never reached the global handler at all. That meant no log entry, no trace and no error-rate spike, which is exactly why the blog says unexpected exceptions should throw. Grouping errors by use case would not have fixed this, and I want to be explicit about that. It was a separate mistake that needs a separate fix.

The last problem was bloat. A shared root collects one more case from every operation, so there was no natural place to stop. Discovery got worse too, because every new failure landed next to unrelated ones.

What was actually good: validation

The part that aged best is validation, and that’s not a coincidence. Validation is the one concern where the design matched the shape of the problem. A form wants all of its errors at once rather than the first one, so the accumulating checks served a genuine UX need. Nowhere else in the project was accumulation load-bearing. Field validation is also the purest domain concern, because it judges the request itself and never needs the database, storage or infrastructure.

The nested tree earns itself here too, since field-level messages and group-level routing are two different consumers of the same structure. Each check is also a self-describing type such as ContentSizeIsValid, so a failure names exactly what was checked, even if the surrounding method signatures were still too wide to be useful.

The lesson I draw is that error handling works best when it’s shaped by the operation’s structure. In my experience independent checks want to aggregate, while sequential business steps want to short-circuit or narrow per use case. Validation happened to be the only thing that was structurally an aggregate problem, so it came out right almost by accident. Aggregate independent checks (like validation); short-circuit sequential business steps with a narrow, use-case-specific outcome. Validation happened to be the only thing that was structurally an aggregation problem, so it came out right almost by accident.

What I’d try next

I haven’t tested these in production, so treat them as hypotheses, not a proven design. The change I’d make first is to group by use case. By use case I mean the operation the caller asked for, not the function that happens to serve it. Uploading a profile photo and deleting a document are different use cases even when they share the same repository, and each one gets its own closed union of the failures it can produce. The wide union then lives only at the transport layer, where a single mapper to HTTP status genuinely belongs.

That leaves the composition question. A narrow service union widens into the transport union through a small total mapping at the boundary, and that mapping is where the composition happens. One function there turns each UploadError into the matching case of the wide union, as in UploadError -> DomainError, while the signature itself stays narrow, something like fun upload(...): UploadError. When a flow runs several steps, its union collects the cases those steps can produce that the caller must distinguish. That mapping is total, so adding a case stops the boundary compiling until it is handled, and it works the same whether the result is wrapped in an Either or a hand-written outcome type. It is deliberate and named, which separates it from the inferred collapse I described earlier, and it is acceptable only because the broad union never reaches a service signature. The cost is more up-front ceremony, a set of small types and a mapping per use case, but I think it’s worth it because the contract becomes readable, and I’d keep each mapping next to the type it converts so a new case stays a local edit.

For the result type, I now prefer plain when plus hand-written per-use-case outcome types over Either. I tried Either, and while it reads fine, the machinery around it wears on me. The cost is the Arrow either { } builder plus the chained calls that unwrap a success or short-circuit on a failure. It is not the Either type itself, which is a small sealed pair. As the years pass I’ve come to prefer the simplicity of a when and a set of small named types. This is a matter of taste, but it’s where I lean today. I’d also match the mechanism to the shape of the work. In my experience validation wants to accumulate, while a business flow wants to short-circuit through a narrow outcome type. I see no reason to force one mechanism everywhere.

Observability needs its own attention, and it matters more in this style, not less. Going from exceptions to domain errors means you lose the stack trace unless you deliberately keep it. So I’d log expected domain failures at info or debug with a correlation ID. The global handler would keep the error-level logging for thrown exceptions, so nothing gets logged twice. I’d still throw for genuine unexpected exceptions, the ones that cannot be categorized as domain or client. The rule I hold myself to is to build domain errors from data I already have and never wrap raw database or network failures into the union. That rule is still coarse. A bounced email is a domain outcome, while an unreachable mail server is not. The real test is whether a correct client could still hit the failure and act on it.

Narrow unions are also self-documenting in a way a wide one is not. A signature that returns a narrow, per-use-case error type tells a reader what can fail, while one that returns DomainError tells them almost nothing. For context, an early proposal in the Kotlin Evolution and Enhancement Process, KEEP-0441 “Rich Errors, aka Error Union Types”, would add error-union types to the language. It’s still at the design review stage, so it isn’t accepted or promised, and it isn’t an Arrow-specific idea. The error contract also has to survive releases. The concrete hazard is that adding a case to a shared sealed type is a breaking change for every client whose when is exhaustive. It also breaks serialization when an unknown case arrives from a newer producer. I’d treat an unknown incoming case as a defined fallback rather than a crash. This matters far less when the consumers are ours to rebuild in lockstep, as with internal models, and much more when a customer-facing connector is involved.

On conventions I don’t think there is one right answer, since it always depends on the use case. I like names that are clear and descriptive. I’d use a data class whenever an error carries data, and object or data object when it does not. A data object needs Kotlin 1.9 or later, and a data class requires at least one property. I lean toward carrying data where I can, because an error that carries a payload is easier to debug and test than one that carries nothing. For nesting, validation seems to benefit from it, while in general I’d strive for flat, per-use-case errors, though there’s no universal rule and it has to be tested in context. Testing gets easier too, because you assert on the returned error value directly instead of catching a thrown type.

Where I land

I still reach for this style, only with clearer boundaries. The blog’s taxonomy and its “contract is the unit of review” idea are worth keeping. Its flat “one narrow union per method” advice is a reasonable default at the level of the operation, though not for every helper function. A wide union with a central mapper still has a home at the transport layer.

The honest summary of my two years is that a coherent wide-union pipeline is a defensible transport design, but it was the wrong layer for it. I can’t promise the approach I’d try next is the right architecture either, because I haven’t built it yet. What I’m confident about is the failure mode. My first attempt taught me that the hard way, and I hope it saves you the tuition.

> subscribe shift-mag --latest

Sarcastic headline, but funny enough for engineers to sign up

Get curated content twice a month

Written by people, not robots - at least not yet. May or may not contain traces of sarcasm, but never spam. We value your privacy and if you subscribe, we will use your e-mail address just to send you our marketing newsletter. Check all the details in ShiftMag’s Privacy Notice