Domain Error Handling and the Introduction of Arrow Either
Key point
To overcome the limitations of sealed classes, the team used Arrow's Either to separate success and failure.
Details
The Payment Type BE team, while migrating old protocol-based payment logic to a new HTTP API system built on Kotlin + Spring Boot, considered how to treat domain errors as values. Previously, the conditions under which exceptions occurred and where they were handled were scattered, making it hard to trace root causes, and the number of exception classes kept growing, making it difficult to grasp the overall flow.
The team first modeled result types with sealed class to explicitly express success and failure cases. With a type like BillingPaymentResult handled via when branches, only the meaningful states of an API response could be selectively handled, and even when new error types were added, the compiler would check for missing cases.
However, as usage increased, the limitations became clear.
- A new sealed class had to be defined for each result type, such as
PaymentResult,UserInfoResult, and so on. - Helpers for extracting success values, like
getOrNull()andgetOrThrow(), also kept getting attached repeatedly. - Calling multiple result-returning functions in sequence caused
whenblocks to nest, and as the success flow went deeper, code duplication occurred.
In particular, in the flow of chaining payment approval after point charging, a structure emerged where payment approval was called again inside the charging-success branch, significantly hurting readability. Including cases where charging wasn't even needed, the same payment approval logic ended up duplicated, further worsening maintainability.
To solve this problem, the team adopted Railway Oriented Programming (ROP) and chose Arrow as the tool to implement it in code. Arrow's Either<L, R> separates failure as Left and success as Right, and provides combinator functions like map, flatMap, and getOrElse that naturally chain success and failure flows.
The first thing that decreased after applying this was boilerplate. Instead of creating a separate result class for each domain, reusing the Either<FailureType, SuccessType> form greatly simplified result type definitions. Also, failure-only logic could be made to accept only the failure type, eliminating the need to forcibly handle success cases or return nullable values.
By clearly separating success and failure with Either, concerns also became clearer. For example, a function that converts a failure result into DomainErrorDetail only needs to take the failure type as input, so there's no need to meaninglessly include a success branch, and the caller side can also focus solely on failure handling without null checks.
However, care was needed when using this together with @Transactional. If an exception was caught inside a catch block and converted into Either.Left, Spring would see it as if no exception had occurred, so the transaction would not roll back. This resulted in a problem where, even if a failure occurred during data storage, some operations ended up being committed.
The solution was separation of concerns.
- Internal services are annotated with
@Transactional, and on failure, they throw the exception as-is to trigger a rollback. - External services call the internal service outside the transaction, and upon receiving an exception, convert it into
Either.Left.
With this separation, transactions become responsible for data consistency, while Either becomes responsible for expressing business results. Arrow's Either has established itself not merely as a simple result wrapper, but as a tool that allows domain errors to be handled in a predictable and composable way.
This summary was generated automatically by AI. Check the original for the author's claims and context. Copyright belongs to the original author.
Our guide explains how the AI works. Report summary errors, attribution issues, or removal requests via Contact.