volatile-saga

April 12, 2026 · View on GitHub

Non-persistent sagas for Scala — compensate on failure, do your best, and have a fallback plan.

Build Status codecov.io

A lightweight, non-persistent Saga implementation for Scala built on cats-effect. Compose effectful operations with compensating actions — when something breaks, everything rolls back. No message broker required, no persistence layer, no coordinator service. Just a monad that keeps track of how to undo what it did. And when even the rollback fails? Pair it with the outbox pattern to make sure nothing gets lost.

Why this exists

Most business logic doesn't live inside a single database transaction. You call an API, persist something locally, call another API, maybe charge a payment provider. Each step can fail, and when one does, the steps that already succeeded need to be undone. This is the fundamental problem of coordinating side effects across system boundaries.

Without something like volatile-saga, you end up with deeply nested try/catch blocks, manual cleanup logic that's easy to get wrong, and compensating actions that are scattered across your codebase instead of being declared alongside the operations they undo. Future.recoverWith only handles a single Future — it doesn't give you a way to accumulate and roll back a chain of effects.

This library gives you a Saga[F, A] monad that pairs each effectful step with its compensating action. You compose steps with flatMap like any other monad. If any step fails, all accumulated compensations run in reverse. If everything succeeds, nothing gets rolled back. The key constraint is cats.effect.Sync, which ensures computations are lazily evaluated rather than eagerly fired like a Future.

A real-world example: a financial system that charges a customer via a payment API, records the transaction in its own database, and then notifies a downstream service. If the downstream call fails, you want to reverse the database write and refund the charge — automatically, in order, without writing bespoke cleanup for every possible failure point.

How it works

Each step in a saga can be:

  • Recoverable: an effect F[A] paired with a compensating action A => F[Unit] that undoes it
  • Non-recoverable: an effect F[A] with no compensation (useful for the final step, or for reads)

When you run a saga, steps execute sequentially via flatMap. Each recoverable step pushes its compensating action onto a stack. If any step throws, all accumulated compensations execute in reverse order (last-in, first-out). If all steps succeed, no compensations run — the saga completes normally.

Example

import cats.effect.IO
import cats.implicits._
import cats.effect.concurrent.Ref
import volatilesaga._
import scala.util.control.NonFatal

def prg(ref: Ref[IO, Int]): Saga[IO, Unit] = for {
  _ <- Saga.recoverable(ref.tryUpdate(_ + 1))(_ => ref.tryUpdate(_ - 1) *> IO.unit).replicateA(500)
  _ <- Saga.recoverable(ref.tryUpdate(_ + 1))(_ => ref.tryUpdate(_ - 1) *> IO.unit).replicateA(500)
  _ <- Saga.nonRecoverable[IO, Nothing](IO.raiseError(new Throwable("Error")))
} yield ()

def main: IO[Int] = for {
  ref <- Ref.of[IO, Int](0)
  _ <- prg(ref).run.recoverWith { case NonFatal(_) => IO.unit }
  current <- ref.get
} yield current

The outcome of main is zero. The first two steps increment the Ref to 1000, but the third step fails. The saga then runs all compensating actions in reverse, decrementing back to 0.

Real-world scenario: booking a trip

Imagine a travel service that needs to book a flight, a hotel, and a rental car — three separate API calls. If the car rental fails, you want to cancel the hotel and the flight:

def bookTrip(flight: FlightReq, hotel: HotelReq, car: CarReq): Saga[IO, Booking] = for {
  f <- Saga.recoverable(flightApi.book(flight))(ref => flightApi.cancel(ref.id))
  h <- Saga.recoverable(hotelApi.book(hotel))(ref => hotelApi.cancel(ref.id))
  c <- Saga.recoverable(carApi.book(car))(ref => carApi.cancel(ref.id))
} yield Booking(f, h, c)

If carApi.book throws, the saga automatically calls hotelApi.cancel and then flightApi.cancel. No manual try/catch nesting, no fragile cleanup logic.

Short-circuiting with EitherT

You can combine EitherT with Saga for workflows that need to short-circuit on domain errors (not just exceptions):

import cats.data.EitherT

def prg(ref: Ref[IO, Int]): EitherT[Saga[IO, *], String, Unit] = for {
  _ <- EitherT.liftF(Saga.recoverable(ref.tryUpdate(_ + 1))(_ => ref.tryUpdate(_ - 1) *> IO.unit).replicateA(500))
  _ <- EitherT.liftF(Saga.recoverable(ref.tryUpdate(_ + 1))(_ => ref.tryUpdate(_ - 1) *> IO.unit).replicateA(500))
  _ <- EitherT.leftT[Saga[IO, *], Unit]("Ouch error occurred")
} yield ()

def decider(value: Either[String, Unit], compensatingActions: List[IO[Unit]]): IO[Unit] = value match {
  case Left(_)  => compensatingActions.sequence *> IO.unit
  case Right(_) => IO.unit
}

def main: IO[Int] = for {
  ref <- Ref.of[IO, Int](0)
  _ <- prg(ref).value.decide(decider).recoverWith { case NonFatal(_) => IO.unit }
  current <- ref.get
} yield current

The decide function gives you access to both the result and the accumulated compensating actions, so you can choose whether to roll back based on your own logic.

When to use this

Use volatile-saga when you're orchestrating multiple API calls or side effects in a short-lived request/response flow (HTTP handler, CLI command, batch job step) and you want structured rollback without infrastructure overhead.

Don't use volatile-saga when you need crash recovery, exactly-once semantics, long-running workflows, or built-in observability — see Alternatives for tools that provide these guarantees.

When compensations fail

Compensating actions can fail too. The network might be down, the third-party API might be unreachable, or your own database might be temporarily unavailable. When that happens, you're left with a partially compensated saga — some effects undone, others still lingering. This is the honest trade-off of a non-persistent, in-memory approach.

The outbox pattern is a natural complement here. You might already be familiar with it as a standalone technique: write an event to an outbox table in the same database transaction as your domain state change, then have a background process pick it up and deliver it. On its own though, the outbox gives you durable, at-least-once delivery of a single action — but it doesn't give you rollback coordination. If you need to do A, then B, then C, and undo A and B when C fails, the outbox alone can't express that. It can reliably fire an action, but it has no concept of a multi-step flow with compensations.

That's where combining the two patterns becomes powerful. The saga provides the coordination — the sequencing, the compensation stack, the rollback order. The outbox provides durability for the failure path:

  1. Run your saga normally — forward steps with compensating actions
  2. If a compensation fails, write the failed compensation to an outbox table in your local database
  3. A background worker picks up entries from the outbox and retries them with backoff

Alternatives

volatile-saga is deliberately minimal. If your problem outgrows it, here are the approaches worth knowing about:

Two-Phase Commit (2PC) provides strong consistency through two phases: a prepare phase where each participant acquires locks and promises it can commit, and a commit phase where the update is carried out. Each participant must ensure the durability of its decision (typically via a write-ahead log) so that even after a crash it can complete the protocol. This is the standard approach for coordinating writes across databases that support XA transactions.

2PC isn't inherently limited to databases though — an API could in principle expose a prepare endpoint that locks an entity and a commit endpoint that finalizes it. The problem is what happens at the edges. What if the commit call never arrives? The service holding the lock needs a timeout to release it, but now you've introduced a window where the coordinator thinks the resource is reserved while the service has already released it. What if the coordinator decides to roll back after the lock has timed out and the resource was given to someone else? These edge cases are why 2PC works well in database land — where participants are purpose-built for the protocol, share assumptions about timeouts, and persist their prepare state — but becomes fragile when applied to HTTP APIs that weren't designed for it.

Persistent Sagas store saga state to a database, allowing a process manager to resume a saga after crashes. This is the DDD-style saga: a long-running process with a durable state machine. More infrastructure than volatile-saga, but it gives you crash recovery. You'd typically build this yourself or use a framework that supports it.

Durable Execution / Workflow Engines like Temporal take this the furthest. Temporal persists the state of every workflow step via an event history — if a worker crashes, another worker replays the history and picks up exactly where it left off. Activities (side effects like API calls) get automatic retries, timeouts, and heartbeats. You write your workflow as ordinary code and Temporal handles failure recovery, long-running processes (days, months, years), and observability with a built-in UI. The trade-off is infrastructure: you need to run the Temporal Service (or use Temporal Cloud), manage task queues, and accept determinism constraints on your workflow code. If you need crash recovery, long-running workflows, or visibility into execution state, Temporal is likely the right tool.

Comparison

2PCOutbox (standalone)Persistent SagaTemporalvolatile-sagavolatile-saga + outbox
ConsistencyStrongEventualEventualEventual (durable)Best-effortEventually consistent
Rollback coordinationVia protocolNoYesYesYesYes
InfrastructureCoordinator + XADB + broker + CDCDB + process managerTemporal Service + workersNone (in-process)DB table + worker
Crash recoveryYesYesYesYes (event replay)NoPartial (compensations)
Works with HTTP APIsPossible but fragileYesYesYes (via activities)YesYes
Long-running workflowsNoNoYesYesNoNo
ObservabilityLowLowMediumHigh (built-in UI)NoneLow
ComplexityHighMedium-HighMedium-HighMediumLowLow-Medium

Limitations

This library is deliberately simple. Out of the box it does not:

  • Persist saga state — if the process crashes mid-execution, partial effects remain
  • Retry failed compensations — if a compensation fails, you get the exception (but see When compensations fail for how to pair this with an outbox)
  • Provide idempotency keys — you need to handle that at the service level
  • Manage timeouts — use cats-effect timeout combinators around individual steps

If you need these features as first-class primitives, consider Temporal or a persistent saga implementation. volatile-saga is a building block for the common case: a few API calls in a request handler that need best-effort rollback, with the option to layer on durability where you need it.