AbsaOSS / fa-db

Functional Access to Database
Apache License 2.0
3 stars 0 forks source link

Modelling exceptions with Algebraic data types and handling status as Either[StatusException, R] #105

Closed salamonpavel closed 11 months ago

salamonpavel commented 12 months ago

The current implementation of checkStatus results in Try[FunctionStatus]. The Try type is a wrapper for a computation that might fail or succeed. If the computation is successful, Try returns a Success containing the value, but if it fails, Try returns a Failure containing the exception.

The problem with this approach is that when a Failure is returned, it contains a Throwable, which is a very general type that can represent any kind of exception or error. This makes it difficult for client code to handle different kinds of errors in a specific way, because it can't pattern match on the resulting exception.

Furthermore, the type signature of checkStatus doesn't make it clear what kind of errors can occur. It just says that it might fail with some Throwable, but it doesn't specify what kind of Throwable. This lack of specificity can make it harder to understand how the function behaves just by looking at its type signature, and it can lead to errors being handled inappropriately or not being handled at all.

It's considered good practice in functional programming to make the error type explicit in the type signature of a function. This makes it clear what kinds of errors can occur, and it allows client code to handle different kinds of errors in a type-safe way. It also makes the function easier to understand and reason about, because its behavior is more explicitly described by its type signature.

I would like to propose modelling errors as ADTs and implement status checking that returns Either[StatusException, R] where R represents the final result type of the db function.

The current exception model could be replace with:

sealed abstract class StatusException(val status: FunctionStatus) extends Exception(status.statusText)

final case class ServerMisconfigurationException(override val status: FunctionStatus) extends StatusException(status)
final case class DataConflictException(override val status: FunctionStatus) extends StatusException(status)
final case class DataNotFoundException(override val status: FunctionStatus) extends StatusException(status)
final case class ErrorInDataException(override val status: FunctionStatus) extends StatusException(status)
final case class OtherStatusException(override val status: FunctionStatus) extends StatusException(status)

The approach with case classes and a sealed abstract class has several advantages over using normal classes and extending from a common exception class like DBFailException:

  1. Pattern Matching: Case classes are designed to be used with pattern matching. This allows you to destructure instances of case classes in a match expression and handle each case differently. This is a powerful feature that can make your code more readable and easier to understand.

  2. Immutability: Case classes are immutable by default. This means that once a case class instance is created, it cannot be changed. This can help prevent bugs that are caused by mutable state.

  3. Equality: Case classes automatically provide an equals method that compares instances by structure rather than by reference. This means that two instances of a case class with the same values are considered equal, even if they are different objects.

  4. Exhaustiveness Checking: When you use a sealed abstract class, the compiler knows all possible subclasses of the class. This allows the compiler to check that all cases are covered in a match expression, which can help prevent bugs.

  5. Less Boilerplate: Case classes automatically provide methods like equals, hashCode, and toString, as well as a companion object with apply and unapply methods. This can save you from writing a lot of boilerplate code.

Changing the error handling (status handling support) to result in Either[StatusException, R] would require a significant amount of refactoring. It's important to approach such changes incrementally to ensure that each step is correct and doesn't introduce new issues. We could start by first implementing the new StatusException hierarchy and then sketch out a plan of further refactoring.