Modelling domain and application errors in Scala

(, en)

Software projects can get out of hand quickly. There are various reasons for it and I picked one reason that can slow engineers down considerably. Often it goes unnoticed:

Error Modelling

Perhaps it starts even earlier: the lack of thinking about error and exceptional conditions. Engineers gloss over this topic all too often in the first stages of a new project. The consequence is that errors and exceptions are created »on-the-fly« and they grow like weed across the source code. The underlying cause you might find back in the Eisenhower Matrix:

            Urgent         Not Urgent
           +-------------------------+
           |                         |
 Important |            | ERROR      |
           |            | MODELLING  |
           |            |            |
           |    -----------------    |
           |            |            |
 Not       |            |            |
 Important |            |            |
           |                         |
           +-------------------------+

Error Modelling can be considered important, but not urgent. Its importance stems from the (invisible) impact on a teams velocity and user experience (UX):

A consistent error model developers are supported and even encouraged to deal with error cases. For Scala (and probably also Java or Kotlin) errors can fall in 3 base categories:

validation errors
these are errors caused by invalid user input. It can be a missing or invalid field in the API request. These errors form the “first line of defence”.
domain errors
errors caused by business logic, often caused by a user action, such as withdrawing 1000 EUR when you account only has 100 EUR left. There is some overlap with validation errors, depending on what is considered domain or not.
system/application errors
What is left. The real errors. These errors require action (or at least monitoring)

These categories are sufficient to model finer-grained errors.

Interaction with the Frontend

At some point users need to be informed that something is off. Deal with errors under the hood, without direct impact to the user or consumer of the API is preferred, but often not an option.

Passing internal errors to API endpoints creates an attack vector. Application internals migth be exposed. This leaves the seasoned software developer with two options:

  1. handle the errors internally
  2. map the errors to an API or app error.

Given that validation errors are very close to the API requests, it makes sense to model validation as a subclass of app errors. You can think of app errors as a combination of a subset of client errors (Bad Request, Not Found, Conflict, etc.). Most of them will map to a Bad Request, because the user supplied invalid request data.

The complete picture looks like this:

   | ^                           ^
   | |     API response          |
+--|-|---------------------------|-----------------+
|  | |                           |         BACKEND |
|  | | a validation error        |                 |
|  | | is already an app error   |                 |
|  v |                           |                 |
|                                |                 |
|"API/input validation errors"   |                 |
|"(exception w/o stacktrace)"    |                 |
|                                |                 |
|                        +-------------+           |
|   +------------------->|conversion to|           |
|   |                    |app error    |           |
|   |                    +-------------+           |
|   |                                              |
|"domain/business errors"     "system/application" |
|"(exception w/o stacktrace)" "errors (exceptions)"|
|                                                  |
|            |                     |               |
+------------|---------------------|---------------+
   LOGGING   |                     |
   SYSTEM    | WARNING             |
             v or INFO       ERROR v

Domain Errors

Scala gives you a big toolbox for modelling errors. Perhaps too big. You can choose between trait and abstract class, inheritance, case class, etc.

It really depends on the project what suits best. In my case I tend to use unsealed traits that extend exceptions.

Sealed vs Unsealed

A sealed trait can be extended only in the same file as its declaration.

If your project is modular and you decide to organise your code by function (see also functional vs. logical cohesion), having the domain error base class sealed will make it difficult to stick to that pattern. All potential errors need to go into the same file.

In my opinion, a sealed trait restricts the usage too much with very little practical gain. Additionally, Scala will let you know when your pattern match is not exhaustive.

I could not find an answer to the question

If sealing traits is restrictive, what alternative ensures consistency while preserving extensibility?

Exception vs no Exception

Having Exception as base class allows domain errors to blend in with the rest of the exceptions. Remember: system/application errors do not have a separate base class. They are just »exceptions«. In my experience this keeps the code simple. Especially if you work with effect systems and for expressions.

trait DomainError

val aZIO: Task[String] = ZIO.succeed("bear")
val bZIO: IO[DomainError, String] = ZIO.succeed("cat")

val effect2: ZIO[Any, Throwable, String] = for {
  a <- aZIO
  b <- bZIO
  // Error for "b"
  // Found:    zio.ZIO[Any, DomainError, String]
  // Required: zio.ZIO[Any, Throwable, String]
} yield (s"$a $b")

To deal with different types in the error channel you’ll have to work with union types.

val effect: ZIO[Any, DomainError | Throwable, String] = for {
  a <- aZIO
  b <- bZIO
} yield (s"$a $b")

If a function expects a ZIO[*, Throwable, *], you have to get creative.

Of course you could argue that this approach doesn’t really fit into functional programming and you might be right. But is it worth the effort to go for 100% functional programming? Even Martin Odersky argues that going 100% functional programming might not be the best option

In the end it is a trade-off everybody has to decide for him/herself.

Still, one issue to tackle. Exceptions are quite heavy-weight. They contain the stack trace. Luckily there is a way around it: extending from NoStackTrace.

NoStackTrace is

A trait for exceptions which, for efficiency reasons, do not fill in the stack trace.

The Final Result

Taking the points from above into consideration, you’ll end up with:

trait DomainError extends Exception, NoStackTrace {
  def msg: String
  def cause: Option[Throwable] = None

  override def getCause: Throwable = cause.orNull
  override def getMessage: String = msg
}

In the API layer, you can pattern match on a Throwable and drag out the DomainError.

extension (ex: Throwable) {
  def toAppError: AppError = ex match {
    case e: DomainError     => DefaultAppError("DATA_PROCESSING_ERROR", e.msg)
    case e: ValidationError => e
    case e: AppError        => e
    // we might want to debug the error, so it can be handy to have the original Throwable available
    case e: Throwable => UnknownAppError("UNKNOWN_ERROR", "Unknown error", Some(e))
  }
}

With Scala 3 supporting union types, one can be more specific. A function can return a union of errors:

trait User
case class InactiveUserError(msg: String, override val cause: Option[Throwable]) extends DomainError
def findUser1(id: String): Either[NotFoundError | InactiveUserError, User] = ???

// Use sparingly
type UserErrors = NotFoundError | InactiveUserError
def findUser2(id: String): Either[UserErrors, User] = ???

My personal experience is that this works very well for few, let’s say around 3, errors, but as soon as this limit is exceeded, it tends to get messy and very verbose. A common strategy is to create a »alias type«, which very fast becomes a bag of potential errors: developers create new code and add a new error there. Over time more and more errors are appended and the specificity of the alias type becomes meaningless.

For me is passing on the generic DomainError trait handier. It is like a package: the sender, a function, is wrapping the error it returns into a DomainError. This package is passed through all layers of abstraction (and believe me, Scala code tends to have a lot) to finally get unwrapped in the API layer. There it is handled and mapped to an app error.

App Errors

An app error models the API response. That what the frontend uses to show a error message to the user. In international websites it is common to translate the error message, therefore it has the key field. This field can be used for I18N translation functionality.

trait AppError extends Exception, NoStackTrace {
  def key: String
  def msg: String
  def cause: Option[Throwable] = None

  override def getCause: Throwable = cause.orNull
  override def getMessage: String = msg
}

One of the more common error conditions arise from user input validation. Therefore it is handy to have a separate ValidationError.

It looks like an app error, except of the field issues. This field is a list, because input validation can find multiple issues. Think of address validation: zip code can be wrong, but also the country name. This fits neatly into error accumulation approaches using Cats’ Validated or ZIO prelude’s Validation

case class ValidationError(key: String, issues: Seq[String]) extends AppError {
  val msg: String = issues.mkString("; ")
}

One last detail: assume you have a domain error and you want to include internal information for debugging or error tracing. Then you use this construction:

case class DomainErrorDetail(msg: String) extends Throwable(msg), NoStackTrace

The DomainErrorDetail can be supplied as cause, giving you the opportunity to get more insight at the API layer.

Examples

Here are some constructs to clarify the usage (or intention) of the domain error classes. Overall it is quite straight forward.

Concrete Domain Error Implementations

case class TransactionNumberNotValid(number: String) extends DomainError {
  val msg = s"$number is not valid"
}

case class NotFoundError(msg: String, override val cause: Option[Throwable] = None) extends DomainError

case class UnknownError(msg: String, override val cause: Option[Throwable]) extends DomainError

App Error Examples

App errors tend to have a low cardinality: you only need a few to tell the user what is going on. The key can be used as main distinguishing criterion.

case class DefaultAppError(key: String, msg: String) extends AppError

case class UnknownAppError(key: String, msg: String, override val cause: Option[Throwable] = None)
    extends AppError

Example Error Instances

And last, but not least, some concrete instances:

val exampleValidationError =
  ValidationError("USER_INVALID", issues = Seq("invalid characters in field user_id", "user not known"))

val userFacingMessage = "KABOOM!"
val internalMessage = "Flux Capacitor calculated the wrong date"
val exampleNotFoundErrorWithInternalInfo =
  NotFoundError(userFacingMessage, Some(DomainErrorDetail(internalMessage)))

Conclusion

I hope this gives a consistent and practical approach to error modelling and handling. It is not always 100% type-safe and in the style of functional programming. A trade-off chosen by design: to make the error handling more ergonomic for the lazy developer.

See also