David Kubecka
01/09/2024, 7:35 PMEither
(or Result
or whatever) pattern. How do you combine errors from different services/methods to a new one?
Example:
fun callTwoServices(): Either<???, String> {
val resultA: Either<ErrorA, Int> = serviceA.call()
val resultB: Either<ErrorB, Int> = serviceB.call()
// do something with the results, possibly propagating the errors
...
return Either.Right(myResult)
}
The question is what should be the ??? type?
There are two approaches I can think of
• Create a sealed class MyError
with subclasses MyErrorA
and MyErrorB
, and map the original Error*
to new ones. The resulting error type would then be MyError
.
• Create Error
sealed class already for the Error*
subclasses and use this Error
at all left positions.
Neither
approach seems ideal to me. While the first one looks more correct,
it's quite boilerplaty. The second one then quickly leads to a god
object (containing unrelated error types).
I guess the ideal solution would be to use union type Either<MyErrorA | MyErrorB, String>
. But since this feature is not in Kotlin yet what is the approach you take in this situation?Youssef Shoaib [MOD]
01/09/2024, 8:15 PMcontext
receivers, you can use 2 contexts context(Raise<MyErrorA>, Raise<MyErrorB>)
.David Kubecka
01/09/2024, 8:29 PMrecover { ... }
or something?Youssef Shoaib [MOD]
01/09/2024, 10:22 PMcontext(Raise<>)
everywhere so that you can always raise whatever errors a method needs, and the compiler will enforce that those errors are handled with an appropriate Raise
instance. Then, in the code that converts between different domain layers, you'd use withError
. At the very edge of your program, you'll likely use recover
.Youssef Shoaib [MOD]
01/09/2024, 10:23 PMrecover
if you want to, well, recover from an error. There's more APIs available, such as zipOrAccumulate
that allow for error accumulation.Alejandro Serrano.Mena
01/10/2024, 9:06 AMinterface Error
interface ConnectionOrAuthenticationError: Error
interface ConnectionError: ConnectionOrAuthenticationError {
data class WrongIP(val address: Address): ConnectionError
data ...
}
interface AuthenticationError: ConnectionOrAuthenticationError {
...
}
Alejandro Serrano.Mena
01/10/2024, 9:07 AMcallTwoServices
may only throw A or B, but because of implementation details later it's only A or C, you would need to change every usage of that method, which is not idealDavid Kubecka
01/10/2024, 10:35 AMsealed interface Error
sealed interface UserError : Error // typically results in to 40x
sealed interface NotFound(val message: String) : UserError
sealed interface Unauthorized : UserError
sealed interface GenericError : Error // for unspecified errors leading to 422
...
This error hierarchy would then be used in all services (unit of business logic) of my microservice (unit of deployment)David Kubecka
01/11/2024, 10:55 AMDavid Kubecka
01/11/2024, 10:58 AMFor example, if you say thatWell, some people regard this as a feature 🙂 If you add an additional error type to a method you are explicitly forced to handle it in the callers, similarly to java checked exceptions. While I somehow understand this argument, I don't think the increased compile-time safety benefits balance the incredibly verbose code.may only throw A or B, but because of implementation details later it's only A or C, you would need to change every usage of that method, which is not idealcallTwoServices
Alejandro Serrano.Mena
01/11/2024, 11:06 AMYoussef Shoaib [MOD]
01/11/2024, 12:28 PMwithError
). Basically treat it similar to how you'd treat data between layersYoussef Shoaib [MOD]
01/11/2024, 12:33 PMsealed
super type for your errors. For instance, a context(Raise<MyErrors.A>, Raise<MyErrors.B>) fun callTwoServices()
can be called in a way that handles A and B separately, or instead it'll likely be called with a Raise<MyErrors>
, and hence changing the code later to raise B instead of C won't actually require you to change everything everywhere.Kev
01/12/2024, 3:55 AMKev
01/12/2024, 3:55 AMUlrich Schuster
02/08/2024, 9:50 AMDavid Kubecka
02/08/2024, 12:39 PMfun doStuff1(): Result<Int, Error1>
// union of errors from callers
fun doStuff2(): Result<Int, Error1 | Error2> {
val x = doStuff1().bind()
...
return if (test()) {
Ok(1)
} else {
Err(Error2)
}
}
// we can selectively handle specific errors
fun doStuff3(): Result<Int, Error2 | Error3> {
val x = doStuff2()
handleError1(x)
...
return if (test()) {
Ok(1)
} else {
Err(Error3)
}
}
Unless I'm overlooking something this would be typesafe, easy to reason about and without boilerplate.Kev
02/08/2024, 12:44 PMYoussef Shoaib [MOD]
02/08/2024, 12:55 PMRaise
is the way to go here. It allows composing your error handling without weird nesting orders within Either
or similar. I forsee that such data types like Either
will be very rarely used: they'll only be needed when you want to store an error or a successful resultDavid Kubecka
02/08/2024, 12:59 PMDavid Kubecka
02/08/2024, 1:01 PMRaise
feature indeed looks promising but it's biggest disadvantage is the context receivers/parameters are still experimental. Which is still much better state than with union types 🙂David Kubecka
02/08/2024, 1:05 PMDomain1Error = Error1 | Error2
Domain2Error = Error2 | Error3
If I'm not mistaken, with Raise
you would have to create the sealed interface for each domain error type and we are back at the startline.Youssef Shoaib [MOD]
02/08/2024, 1:07 PMcontextalias RaiseDomain1 = (Raise<Error1>, Raise<Error2>)
Kev
02/08/2024, 1:39 PMfun something() contract [ implies condition] : String