DEV Community

Cover image for Sealed Monad: Linear Business Flows as Algebraic Data Types
Katarzyna Kozłowska
Katarzyna Kozłowska

Posted on

Sealed Monad: Linear Business Flows as Algebraic Data Types

Every team that builds a Scala backend long enough makes the same structural mistake: they use nested flatMap and match to express multi-step business logic. The types are right. The code does what it's supposed to do. And then requirements change, someone adds a step, and the next developer has to trace a decision tree through nested match arms to understand what the function actually does. At that point, the maintainability cost is already sunk. You're just paying it in review time and bugs.

This article covers two tools that solve the structural problem together: ADTs for expressing typed business outcomes, and sealed-monad for composing them linearly.

ADTs for business outcomes

A sealed trait is the right type for business outcomes. Not exceptions, not raw booleans, not strings. Here's what a login result looks like:

sealed trait LoginResult
object LoginResult {
  case class LoggedIn(token: String) extends LoginResult
  case object InvalidCredentials     extends LoginResult
  case object Deleted                extends LoginResult
  case object ProviderAuthFailed     extends LoginResult
}
Enter fullscreen mode Exit fullscreen mode

The sealed modifier means the compiler knows the complete list of cases. No subclass can be added outside the file. Add case object AccountLocked extends LoginResult, recompile, and the compiler flags every pattern match on LoginResult that doesn't handle it.

The alternatives don't give you this. Exceptions are invisible to the type system: the signature def login(...): Future[String] tells the caller nothing about what can go wrong. Raw booleans lose the reason: you know the login failed, not why. String errors can't be distinguished by the compiler: Left("invalid credentials") and Left("account deleted") are the same type.

The composition problem

Defining the outcome type is the easy part. The problem is chaining multiple operations, each returning a typed outcome, into a single flow. Consider a login with four steps:

  1. Find the user by email. If none exists: InvalidCredentials.
  2. Check the user isn't archived. If they are: Deleted.
  3. Find the auth method. If none: ProviderAuthFailed.
  4. Verify the credentials. If they fail: InvalidCredentials.

In standard Scala, this is what you write:

def login(email: String): Future[LoginResult] =
  findUser(email).flatMap {
    case None =>
      Future.successful(LoginResult.InvalidCredentials)
    case Some(user) if user.archived =>
      Future.successful(LoginResult.Deleted)
    case Some(user) =>
      findAuthMethod(user.id, Provider.EmailPass).flatMap {
        case None =>
          Future.successful(LoginResult.ProviderAuthFailed)
        case Some(am) if !checkAuthMethod(am) =>
          Future.successful(LoginResult.InvalidCredentials)
        case Some(_) =>
          Future.successful(LoginResult.LoggedIn(issueTokenFor(user)))
      }
  }
Enter fullscreen mode Exit fullscreen mode

The types are correct and the logic is correct. Look at the structure anyway:

  • The happy path is buried in the innermost match arm.
  • Error cases are scattered across the nesting rather than declared next to the step they belong to.
  • Adding a step means adding another nesting level, not another line.
  • A reviewer has to mentally trace the execution tree to verify the logic matches the requirement.

Four steps already take 6 match arms across 2 nested blocks.

We ran into this at Iterators while building a backend for a client with unusually dense business rules. Login was fine. Order creation was manageable. But the cancellation flow had seven distinct failure modes, each conditional on the previous steps, and the nested version had become something nobody wanted to modify. Adding a step for fraud detection before the payment charge required reading the full function, finding the right nesting level, and not breaking the cases already there. It took longer than it should have, and the resulting review was not clean. The logic was right. The structure was wrong.

sealed-monad

sealed-monad is an open-source Scala library built at Iterators. It wraps the composition pattern in a monadic abstraction that makes for-comprehensions work with ADT-based business flows. The same login logic:

import pl.iterators.sealedmonad.syntax._

def login(email: String): Future[LoginResult] =
  (for {
    user       <- findUser(email)
                    .valueOr(LoginResult.InvalidCredentials)
                    .ensure(!_.archived, LoginResult.Deleted)
    authMethod <- findAuthMethod(user.id, Provider.EmailPass)
                    .valueOr(LoginResult.ProviderAuthFailed)
                    .ensure(checkAuthMethod, LoginResult.InvalidCredentials)
  } yield LoginResult.LoggedIn(issueTokenFor(user))).run
Enter fullscreen mode Exit fullscreen mode

Read it top to bottom:

  1. Find the user. If none: InvalidCredentials.
  2. Ensure not archived. If archived: Deleted.
  3. Find auth method. If none: ProviderAuthFailed.
  4. Ensure credentials pass. If not: InvalidCredentials.
  5. If all succeed: LoggedIn.

The happy path is the yield. Each failure case is an inline annotation on the step it belongs to. The structure of the code is the structure of the requirement.

The two operators

valueOr(fallback) converts an Option or Either result. If the step produces None or Left, the chain short-circuits with the fallback:

// Option: short-circuit on None
findUser(email).valueOr(LoginResult.InvalidCredentials)
Enter fullscreen mode Exit fullscreen mode

ensure(predicate, fallback) checks a condition on a successful result. If the predicate fails, the chain short-circuits with the fallback:

// Short-circuit if the user is archived
.ensure(!_.archived, LoginResult.Deleted)
Enter fullscreen mode Exit fullscreen mode

Both work inside a single for-comprehension because the Sealed wrapper is a monad over the effect type F. The .run at the end collapses the chain back into F[LoginResult].

What the compiler checks

Because LoginResult is a sealed trait, the compiler knows every branch. If a call site doesn't handle all cases:

login(email).map {
  case LoginResult.LoggedIn(token) => Ok(token)
  case LoginResult.InvalidCredentials => Unauthorized
  // Warning: match may not be exhaustive.
  // It would fail on: Deleted, ProviderAuthFailed
}
Enter fullscreen mode Exit fullscreen mode

The compiler surfaces the gap before the code reaches production. Add a new failure mode to the business logic, and the compiler flags every call site that doesn't handle it. By default that is a warning, so enable fatal warnings in your build if you want a missing case to fail the compile. With exception-based error handling, adding a new failure case compiles cleanly and the surprise arrives at runtime.

Effect systems

sealed-monad works with any effect type that has a cats.Monad instance:

// Future
def login(email: String): Future[LoginResult] =
  (for { ... } yield LoginResult.LoggedIn(token)).run

// cats-effect IO
def login(email: String): IO[LoginResult] =
  (for { ... } yield LoginResult.LoggedIn(token)).run

// Monix Task
def login(email: String): Task[LoginResult] =
  (for { ... } yield LoginResult.LoggedIn(token)).run
Enter fullscreen mode Exit fullscreen mode

Migrating from Future to IO means changing the effect type of the functions you compose. The business logic in the for-comprehension stays identical.

Where it fits and where it doesn't

sealed-monad is for multi-step business flows where each step can fail with a typed domain error. That's the scope.

It is not a general-purpose error handling library. Infrastructure errors, database failures and network timeouts do not belong in your business ADT. A database timeout is not a LoginResult case. It's an exception, and it should propagate as one. The sealed trait should contain only the outcomes the calling code needs to handle and route.

The practical question before reaching for it: does the function implement a business requirement that has multiple named failure modes, each meaningful to the calling code? If yes, sealed-monad is worth using. If the function is infrastructure plumbing with one failure mode (it worked or it didn't), standard error handling is correct.

One more thing about the compile-time check: it only holds if you treat it seriously. If you add a catch-all case to your pattern matches to make the compiler stop warning, you've opted out of the guarantee.

Getting started

// build.sbt
libraryDependencies += "pl.iterators" %% "sealed-monad" % "2.0.1"
Enter fullscreen mode Exit fullscreen mode
import pl.iterators.sealedmonad.syntax._
Enter fullscreen mode Exit fullscreen mode

Scala 2.13 and Scala 3, cats-core as the only dependency. Apache 2.0 license.

sealed-monad is maintained by Iterators.

Top comments (0)