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 four levels of indentation 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.
Code is read more times than it’s written. With nested control structures like ifs or flatMaps, every read is a tree traversal. With sealed-monad, every read is a list – easy to scan, easy to understand. The difference is invisible on day one but it compounds for the next five years.
Łukasz Sowa
This article covers two tools that solve the structural problem together: ADTs for expressing typed business outcomes, and sealed-monad for composing them linearly. I’ll go through why nested composition breaks down, what the alternative looks like, and when to reach for it. The article has four parts: ADTs as the right type for business outcomes, the composition problem with standard Scala, how sealed-monad solves it, and the practical boundaries of where it fits.
Part 1: 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
}The sealed modifier means the compiler knows the complete list of cases. No subclass can be added outside the file. When you pattern match on LoginResult, the compiler enforces exhaustiveness. Add case object AccountLocked extends LoginResult, recompile, and the compiler tells you every call site that doesn’t handle it. That’s a compile-time guarantee, not a convention.
The alternatives don’t give you this. Exceptions are invisible to the type system: the signature def login(...): Future[String] gives the caller no information 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. Sealed traits are typed alternatives the compiler can reason about.
This is not a new idea in Scala. What’s less obvious is what happens when you try to compose operations that return typed outcomes like this.
Part 2: 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 the login example with four steps:
- Find the user by email. If none exists:
InvalidCredentials. - Check the user isn’t archived. If they are:
Deleted. - Find the auth method. If none:
ProviderAuthFailed. - 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)))
}
}The types are correct. The logic is correct. Look at the structure anyway:
- The happy path is buried in match arms, one indentation level deeper for each step.
- 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.
This is the composition problem. It’s not a mistake by the developer who wrote it. It’s what you get when you use flatMap and match to chain typed outcomes directly. The structure of the code fights against the structure of the requirement. Four steps, four levels deep. Six steps, six levels deep.
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. We decided to solve the structure problem rather than keep managing it case by case.
sealed-monad was the result.
Part 3: 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))).runRead it top to bottom:
- Find the user. If none:
InvalidCredentials. - Ensure not archived. If archived:
Deleted. - Find auth method. If none:
ProviderAuthFailed. - Ensure credentials pass. If not:
InvalidCredentials. - 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
There are two operators doing the work here.
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)ensure(predicate, fallback) checks a condition on a successful result. If the predicate fails, the chain short-circuits with the fallback:
// Short-circuit if user is archived
.ensure(!_.archived, LoginResult.Deleted)
// Short-circuit if payment amount exceeds limit
.ensure(_.amount <= maxAmount, OrderResult.PaymentDeclined)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].
Scaling Up
The login example has four steps. Here is an order processing flow with six, to show what happens as flows get longer:
sealed trait OrderResult
object OrderResult {
case class Placed(orderId: OrderId) extends OrderResult
case object CustomerNotFound extends OrderResult
case object CustomerSuspended extends OrderResult
case object ProductNotAvailable extends OrderResult
case object InsufficientStock extends OrderResult
case object PaymentDeclined extends OrderResult
case object OrderCreationFailed extends OrderResult
}
def placeOrder(
customerId: CustomerId,
productId: ProductId,
quantity: Int
): Future[OrderResult] =
(for {
customer <- findCustomer(customerId)
.valueOr(OrderResult.CustomerNotFound)
.ensure(!_.suspended, OrderResult.CustomerSuspended)
product <- findProduct(productId)
.valueOr(OrderResult.ProductNotAvailable)
.ensure(_.stock >= quantity, OrderResult.InsufficientStock)
payment <- chargeCustomer(customer, product.price * quantity)
.valueOr(OrderResult.PaymentDeclined)
order <- createOrder(customer, product, quantity, payment)
.valueOr(OrderResult.OrderCreationFailed)
} yield OrderResult.Placed(order.id)).runSeven possible outcomes, zero nesting. Adding a fraud score check before the payment charge is one line in the for-comprehension, not another nesting level in a match tree.
The Compile-Time Guarantee
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
}The compiler surfaces the gap before the code reaches production. Add a new failure mode to the business logic, and the compiler finds every call site that doesn’t handle it.
With exception-based error handling: adding a new failure case compiles cleanly. The surprise arrives at runtime. With sealed traits: adding a case is a contract change the compiler enforces throughout the codebase.
Testing
The linear structure has a testing benefit that’s worth stating explicitly. With nested flatMap, testing a step in the middle of the flow requires constructing mocks that return the right values to thread execution to the branch you want. With sealed-monad, each failure case is declared at its step’s position. You stub the relevant dependency, verify the outcome, and the steps below the short-circuit point don’t need stubs:
"placeOrder" should "return CustomerSuspended for suspended customers" in {
val suspendedCustomer = Customer(id = customerId, suspended = true)
when(customerRepo.find(customerId)) thenReturn Future.successful(Some(suspendedCustomer))
placeOrder(customerId, productId, quantity = 1).futureValue shouldBe
OrderResult.CustomerSuspended
// Product and payment stubs not needed -- suspended check short-circuits before them
}Effect System Compatibility
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)).runMigrating from Future to IO means changing the return types of the dependencies and the .run call. The business logic in the for-comprehension stays identical.
Part 4: 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, 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. If you put infrastructure failures there, you lose the semantic clarity that makes the approach useful in the first place.
The practical question before reaching for this library: 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 guarantee: 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. The value is in the exhaustiveness check. Let the compiler do the work.
Getting Started
// build.sbt
libraryDependencies += "pl.iterators" %% "sealed-monad" % "2.0.1"import pl.iterators.sealedmonad.syntax._Scala 2.13 and Scala 3, cats-core as the only dependency. Apache 2.0 license.
GitHub: https://github.com/theiterators/sealed-monad
Connection to the Rest of the Series
The error classification from the Error handling article maps directly onto this pattern. Domain errors are the cases in the sealed trait: named, typed, first-class values. Exceptions remain exceptions and propagate outside the Sealed context. Bugs become compile-time errors through exhaustiveness checking. The three categories from that article don’t blur here; the structure keeps them separate.
The Validations and Constraints article is relevant for the same reason: validation results are naturally ADTs, and sealed-monad composes them the same way it composes any other step.
The Code complexity metrics article makes the cyclomatic complexity argument. The nested flatMap version adds a decision point for every match arm, increasing the function’s complexity score with each new case. The sealed-monad version keeps the decision points linear and local. The metric reflects what’s actually happening: the code is easier to reason about because the structure doesn’t require tracing a tree.
sealed-monad is maintained by Iterators and is open source under the Apache 2.0 license. Other articles in this series: Code complexity metrics, Error handling, Validations and Constraints.

