Error handling as values in Scala
Model expected failures with sealed types and Either, keep unexpected failures distinct, and translate both deliberately at HTTP and asynchronous boundaries.
Not every unsuccessful result is an exception
A rejected command, missing account or invalid transition may be an expected outcome of an operation. Representing that outcome in the return type tells callers that they must consider it. A thrown exception hides the possibility from the method signature and relies on runtime control flow.
Scala’s Either[E, A] describes a computation that produces an error E or a successful value A. By convention Left contains the error and Right contains the success. In Scala 2.13, Either is right-biased, so map and flatMap operate on the successful value while leaving an existing Left unchanged.
This does not mean exceptions should never exist. Failed database connections, timeouts and defects still need an exceptional channel. The useful distinction is whether the current operation can name and reasonably handle the outcome as part of its contract.
sealed trait CloseAccountError
object CloseAccountError {
case object AlreadyClosed extends CloseAccountError
final case class OutstandingBalance(amount: BigDecimal) extends CloseAccountError
}
def close(account: Account): Either[CloseAccountError, Account] =
if (account.isClosed) Left(CloseAccountError.AlreadyClosed)
else if (account.balance != 0)
Left(CloseAccountError.OutstandingBalance(account.balance))
else Right(account.copy(isClosed = true))Give errors a closed vocabulary
A sealed trait or sealed abstract class gives the operation a finite error vocabulary. Callers can pattern match on the cases and the compiler can warn when a new case is not handled. The cases can carry structured context without reducing the result to a message string.
Keep domain errors independent of presentation. InvalidAccountName is a domain fact; “account_name_invalid” may be an API code; a sentence shown to a user is presentation. Putting HTTP status codes or translated messages inside the domain error couples application logic to one delivery mechanism.
An error algebra should be as small as the decision requires. A single global ApplicationError hierarchy tends to collect unrelated failures and allows every method to appear capable of returning all of them. Prefer errors scoped to an operation or coherent domain.
def render(error: CloseAccountError): Result = error match {
case CloseAccountError.AlreadyClosed =>
Conflict(Json.obj("code" -> "account_already_closed"))
case CloseAccountError.OutstandingBalance(amount) =>
UnprocessableEntity(Json.obj(
"code" -> "outstanding_balance",
"amount" -> amount
))
}
service.close(id).map {
case Right(account) => Ok(Json.toJson(AccountResponse.from(account)))
case Left(error) => render(error)
}Compose operations without discarding the error type
Because Either is right-biased, a for-comprehension can sequence dependent operations. The first Left stops the remaining steps and becomes the result. Each step must agree on the error type, so boundary-specific failures often need to be translated into the operation’s vocabulary.
map changes a successful value. flatMap starts another operation that may fail. left.map changes the error while leaving a Right untouched. These operations keep the happy path linear without making failure invisible.
Do not widen every error to Throwable merely to make types line up. Translate low-level errors into a meaningful application error where the abstraction changes, and retain the original cause in logs when it is operationally useful.
def create(input: CreateAccountRequest): Either[CreateAccountError, Account] =
for {
name <- AccountName.from(input.name)
.left.map(CreateAccountError.InvalidName)
balance <- OpeningBalance.from(input.openingBalance)
.left.map(CreateAccountError.InvalidBalance)
account <- Account.open(name, balance)
} yield accountFuture and Either describe different kinds of failure
Future[A] represents an asynchronous result and has a success or failure channel. Either[E, A] represents an expected typed choice. Combining them as Future[Either[E, A]] is reasonable when an asynchronous operation can complete successfully with either a domain rejection or a domain success.
A failed Future should normally mean the operation could not produce that answer: the database was unavailable, a timeout expired or an unexpected defect occurred. A successful Future containing Left means the operation ran and produced a known negative result.
Nested types add handling code, but collapsing both channels into failed Futures loses information. Keep the distinction where callers respond differently, then translate it once at a boundary such as a Play controller. Effect systems can encode typed errors differently, but the same design question remains: which failures are part of the operation’s declared result?
def close(id: AccountId): Future[Either[CloseAccountError, Account]] =
repository.find(id).flatMap {
case None =>
Future.successful(Left(CloseAccountError.NotFound(id)))
case Some(account) =>
closeAccount(account) match {
case left @ Left(_) => Future.successful(left)
case Right(closed) => repository.save(closed).map(_ => Right(closed))
}
}
// A repository outage remains a failed Future.Fail-fast and error accumulation solve different problems
Either’s flatMap is fail-fast because a later step may depend on the value produced by an earlier one. That behaviour is appropriate for workflows such as loading an account and then applying a transition.
Independent input validation may need to report several problems together. Play JSON validation can accumulate structural errors, and functional libraries provide applicative validation types such as Cats Validated. Accumulation requires a way to combine errors and is appropriate only when checks are independent.
Do not run a dependent operation after its prerequisite failed merely to collect more messages. Choose fail-fast sequencing for dependencies and accumulation for independent fields, then convert the validated input into a domain command.
- Use Either when later work depends on earlier success
- Accumulate independent request-field errors when it improves the client experience
- Keep operational causes available for logging and diagnosis
- Avoid returning internal exception messages through an API
Typed errors are still part of a public contract
Once another module matches on an error case, that case is part of the interface. Renaming, removing or broadening it can require coordinated changes just like changing a successful return type.
Errors should contain enough information for a caller to decide what to do, but not database implementation details or sensitive data. Logging belongs near the boundary that has both technical context and responsibility for the failure; logging the same Left in every layer creates duplicated noise.
Error values work best when they improve a concrete decision. If every caller immediately converts an elaborate hierarchy into the same generic response, the model is probably more detailed than the application needs.