Rafa Gómez
10/27/2023, 5:07 PMsealed class BookAgendaError {
data object InvalidIdentifier : BookAgendaError()
data object AgendaNotFound : BookAgendaError()
data object MaxCapacityReached : BookAgendaError()
data object PlayerAlreadyBooked : BookAgendaError()
data object AvailableHourNotFound : BookAgendaError()
class Unknown(val cause: Throwable) : BookAgendaError()
}
This way every possible error inside the use case is modeled and in the event of something unexpected were to happen on our infra (repos, publishers, 3rd parties) we catch the exception (using Either.catch) and short circuit until we reach the end of the workflow (normally a controller/subscriber) and then we handle it. Most of the times would look something like this
private fun BookAgendaError.toServerError() =
when (this) {
is MaxCapacityReached -> Response.status(CONFLICT).withBody(MAX_CAPACITY_REACHED)
is PlayerAlreadyBooked -> Response.status(CONFLICT).withBody(USER_ALREADY_BOOKED)
is AgendaNotFound -> Response.status(NOT_FOUND).withBody(AGENDA_DOES_NOT_EXIST)
is AvailableHourNotFound -> Response.status(NOT_FOUND).withBody(AVAILABLE_HOUR_DOES_NOT_EXIST)
is InvalidIdentifier -> Response.status(BAD_REQUEST).withBody(INVALID_IDENTIFIERS)
is Unknown -> throw cause
}
We end up throwing the root cause so our observality platform is aware of the root problem and our generic exception handler provides the default 500.
My main question is, whenever you model use case errors do you find useful to have the "infra" error as a part of that model? Personally I find it easier to reason about what happens if some unexpected error appears but I'm enjoying avoiding it since it's not something you may be able to recover from and adds some boilerplate. How does everyone here model these situations? Any thoughts are welcomeraulraja
10/27/2023, 5:21 PMUnknown
. I follow that if the error is something you constructed and raised yourself then you handle it otherwise just bubbles up.
If you just turn exception into Unknown and don't do anything with them but rethrowing I'm not sure I see the point of it.
Implicitly all functions in Kotlin can throw unchecked exceptions, so even having Unknown
there does not make the function total.Rafa Gómez
10/27/2023, 5:26 PMsimon.vergauwen
10/27/2023, 5:57 PMYoussef Shoaib [MOD]
10/27/2023, 7:52 PMRafa Gómez
10/27/2023, 8:07 PMmitch
10/27/2023, 9:11 PMsealed class BookAgendaError {
data object InvalidIdentifier : BookAgendaError()
data object AgendaNotFound : BookAgendaError()
data object MaxCapacityReached : BookAgendaError()
data object PlayerAlreadyBooked : BookAgendaError()
data object AvailableHourNotFound : BookAgendaError()
}
Don't "catch'em all" or don't use the "pokemon exception handling" interesting term we use in house. When there's unknown exception, just let that explode. The reason why it's best to avoid runCatching
in Spring (& Kotlin in general) is we can also catch unrelated exceptions such as cancellations, netty's aborted exception, OOM, etc. If we really need to catch exceptions (e.g. due to use of AWS lib or java libs), we use Arrow's Either.catch { }
and Option.catch { }
which already do the right things (i.e. they catch only non fatal error, and let cancellations bubble up).
(On another note, perhaps just fyi. we also try to avoid using nulls in our logic because we interact with reactive libraries. Also running webflux under the hood, we want to avoid propagating nulls down and creating empty monos. Also we've been bitten a couple of times by nested nullability bugs that is quite honestly pretty evil to debug)Rafa Gómez
10/27/2023, 10:03 PMsimon.vergauwen
10/28/2023, 10:29 AMCLOVIS
10/28/2023, 11:08 AMConnectionLost
and UnknownError
as part of my error hierarchies) mostly to differentiate between "an exception was thrown" (programming error) and "the server failed with an error I didn't recognize" (should be recovered).
This is mostly motivated by UI frameworks generally reacting very badly to exceptions, and I don't want to have two error handling strategies in all components (both catching errors and displaying failed eithers).simon.vergauwen
10/28/2023, 11:29 AM500
, and allowing it to bubble up as an exception is thus fine but in a UI application this crashes the app. Which is not the case for the server.
This is also kind-of why fold
for Raise
covers the Throwable
case, such that you can still deal with them as UnkownError
without having to model it in that way.
I was personally a big fan of using streaming UIs though, and it seems with Compose this has become vastly nicer. Using Molecule for example, and working with Either
is also much nicer in that way:
@Composable
fun ProfilePresenter(
userFlow: Flow<Either<Error, User>>,
balanceFlow: Flow<Either<Error, Long>>,
): Either<Error, ProfileModel> {
val user by userFlow.collectAsState(null)
val balance by balanceFlow.collectAsState(0L)
return either {
val u = user.bind()
if (u == null) Loading
else Data(user.name, balance)
}
}
Although I haven't used this in an actual UI application, this should work pretty similar to what I was doing before. Dealing with UnkownError
can then become:
moleculeFlow(mode = Immediate) {
ProfilePresenter(userFlow, balanceFlow)
}.catch { e -> SomethingWentWrong(e) }
CLOVIS
10/28/2023, 2:10 PM500
, and allowing it to bubble up as an exception is thus fine but in a UI application this crashes the app. Which is not the case for the server.
I share my domain (and thus failure cases) between client and server 🙂
> I was personally a big fan of using streaming UIs though, and it seems with Compose this has become vastly nicer.
I honestly love this about the Kotlin ecosystem. If you squint hard enough:
• Coroutines: write asynchronous stuff as if it was imperative
• Arrow: handle errors as if they were imperative exceptions
• Compose: write streaming UIs as if they were imperative