Sage: distributed transactions in Elixir with automatic compensation
A dependency-free tool to run distributed transactions in Elixir, inspired by Sagas pattern.
At a glance
- What is it?
- Sage is a dependency-free Elixir implementation of the Sagas pattern that runs transaction steps in a pipeline and executes their compensations in reverse when one fails. It covers third-party API and multi-database work where a plain with block stops being maintainable, but it explicitly cannot protect against a node failure.
- Who is it for?
- Sage fits Elixir teams running multi-step business flows that touch third-party APIs or more than one database and need a compensation path for partial failure. It does not fit services where the operating system or the node itself can die mid-saga, because the README states plainly that a node failure is outside what Sage can protect you from.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 77 days ago.
- What is it written in?
- Mainly Elixir, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
A saga expressed as a pipeline of run and compensate steps
Sage is a dependency-free implementation of the Sagas pattern in pure Elixir, published on Hex as the sage package. The problem it addresses is specific and common: a business flow that commits to several things in sequence, some of which live outside your database, and where a failure halfway through leaves side effects you need to undo.
The mechanism is a two-way flow. Each step defines a transaction function and a compensation function, and the steps are chained with the pipe operator. When one of the transactions fails, Sage ensures that the failed transaction's compensation and the compensations of all its predecessors are executed. As the README puts it, it is like Ecto.Multi but across business logic and third-party APIs.
The API shape is worth looking at directly, because it is where the design decisions live.
defmodule SageExample do
import Sage
@spec create_and_subscribe_user(attrs :: map()) :: {:ok, last_effect :: any(), all_effects :: map()} | {:error, reason :: any()}
def create_and_subscribe_user(attrs) do
new()
|> run(:user, &create_user/2)
|> run(:plans, &fetch_subscription_plans/2, &subscription_plans_circuit_breaker/3)
|> run(:subscription, &create_subscription/2, &delete_subscription/3)
|> run_async(:delivery, &schedule_delivery/2, &delete_delivery_from_schedule/3)
|> run_async(:receipt, &send_email_receipt/2, &send_excuse_for_email_receipt/3)
|> run(:update_user, &set_plan_for_a_user/2)
|> finally(&acknowledge_job/2)
|> transaction(SageExample.Repo, attrs)
endNotice what a step carries. A run step takes a name, a transaction function and an optional compensation function. The final transaction call receives the repo and the attributes, and the return type is either {:ok, last_effect, all_effects} or {:error, reason}, so every effect produced along the way is collected into a map keyed by step name.
This is aimed at applications that interact with third-party APIs to offload work to SaaS products or micro-services, and at flows where you need to commit to more than one database, in all the cases where you do not have transaction isolation between business logic steps.
Compensation runs in reverse order, not in failure order
The ordering of compensations is the part that most hand-rolled implementations get wrong, and it is where Sage earns its keep.
A successful four-step flow looks like this.
[T1] -> [T2] -> [T3] -> [T4]If the third step errors, Sage runs the compensations so that the effects are unwound in the reverse of the order they were created.
[T1] -> [T2] -> [T3 has an error]
↓
[C1] <- [C2] <- [C3]The failed step gets its own compensation too, C3, and then the predecessors are unwound, C2 and C1, rather than being compensated in the forward order they ran. That ordering matters because compensations usually depend on the state their predecessor left behind. A refund that assumes the subscription record still exists has to run after that subscription is deleted, not before.
The guarantee the project describes is bounded rather than absolute: Sage does its best to guarantee that either all transactions in a saga complete successfully, or that all of them ran to amend a partial execution. The wording is deliberately cautious, and it reflects what a compensating transaction can actually promise. A compensation is itself a distributed operation against a third-party API, so it can fail too, and Sage is the mechanism that decides the order and the timing of those calls, not a guarantee that they succeed.
For a team used to RDBMS isolation, this is the mental shift. There is no transaction spanning the whole flow. There is a sequence of commits and a sequence of undo operations, ordered by Sage.
The with block alternative and the three problems it creates
The README makes its case against the obvious approach by showing what happens when you write the flow with with and else inside a Repo.transaction.
defmodule WithExample do
def create_and_subscribe_user(attrs) do
Repo.transaction(fn ->
with {:ok, user} <- create_user(attrs),
{:ok, plans} <- fetch_subscription_plans(attrs),
{:ok, charge} <- charge_card(user, subscription),
{:ok, subscription} <- create_subscription(user, plan, attrs),
{:ok, _delivery} <- schedule_delivery(user, subscription, attrs),
{:ok, _receipt} <- send_email_receipt(user, subscription, attrs),
{:ok, user} <- update_user(user, %{subscription: subscription}) do
acknowledge_job(opts)
else
{:error, {:charge_failed, _reason}} ->
# First problem: charge is not available here
:ok = refund(charge)The comments in that snippet name the first three problems directly. The first is scoping: when the failure is a charge failure, the charge variable is not available in that branch. The second is that the list of compensations grows with every new error case. The third is deciding at which stage the failure happened and which email should still be sent.
The README adds two more. To know the failing stage you have to watch the special return values of every function in the chain. And it is hard to guarantee that a compensation exists for every possible error case.
There is also a structural problem that the snippet cannot show: bare expressions in with do not leak to the else block, so the relative order of the compensation code has to be kept close to the code it undoes. Then the README points out that splitting the function only helps while the number of transactions is very small, and that adding retries, async operations or a circuit breaker to this shape makes it worse rather than better.
Retries, async steps and circuit breakers ride on the same pipeline
The additional features listed in the README are not separate subsystems bolted on afterwards. They are visible in the pipeline itself.
Retries are one of the listed features, so a step that fails transiently can be retried without restructuring the flow. Retries with exponential backoff and jitter are also listed, which is the difference between a naive retry loop that synchronises across every caller and one that spreads load.
Asynchronous steps appear as run_async, and each async step carries a timeout. In the example pipeline, scheduling the delivery and sending the receipt are both async while creating the user, fetching plans, creating the subscription and updating the user are synchronous runs. That maps onto a real business flow where an email receipt should not hold up the transaction that produced it, but the compensation for it still has to run in the right order if the flow fails afterwards.
Circuit breakers are described as easy to write, and the example shows the shape: a run step can take a third function, as the:plans step does with a circuit breaker argument. The project also lists extensibility as a feature, with the note that you can write your own handler for critical errors or a metric collector to measure how much time each step took.
Two other properties on the list are design claims rather than features you can switch on: the ability to not lock the database with long running transactions, and a low cost of integration in an existing code base with low performance overhead. The second is a claim the README makes and does not quantify, so treat it as a design intention rather than a measured result.
A node failure is explicitly outside what Sage protects
The most important sentence in the project is a caveat, and it is easy to miss because it sits at the end of the paragraph describing the guarantee. Sage can not protect you from a node failure that executes given Sage.
Read that literally. The library runs inside your application node. If that node dies while a saga is half executed, the compensations never run, because the process that was going to run them is gone. Sage compensates for a failed transaction, not for a failed runtime. This is a structural property of the pattern rather than a bug in the implementation: any saga coordinator that lives in a single process has the same exposure.
That places Sage in a specific position on the reliability spectrum. It replaces hand-rolled compensation logic with an ordered, testable mechanism, which removes a large class of application bugs. It does not replace durable coordination, so a flow that must survive node loss needs something that survives it.
There is a second boundary in the same area. A compensation is code you write, and a step that cannot really be undone, a charge that cannot be refunded, a message already delivered, cannot be made safe by ordering. Sage can run the compensation function and report the result, but it cannot make an irreversible effect reversible. The practical consequence is that the set of steps where a compensation is genuine business logic rather than a best effort call is the set of steps your saga design actually depends on.
Testing is listed as a benefit of the pattern, and it is genuine for the failure paths. The compensation behaviour in the example takes the effect to compensate, the effects so far and the attributes, so a test can drive a failure at a chosen step and assert the order of the calls that follow.
A small mix project with dialyzer and coveralls wired in
The repository layout is compact. The top level holds lib/, test/, mix.exs, mix.lock, the README, LICENSE.md, coveralls.json, dialyzer.ignore-warnings, .formatter.exs, and the .github and .gitignore entries.
There is no application template and no scaffolding generator here. Sage is a library you add to a dependency list, and the flow of a saga is written in your own module, as the example shows, with import Sage at the top.
The tooling tells you something about how the project is checked. dialyzer.ignore-warnings means static analysis runs as part of the workflow and that a small number of known warnings are explicitly tolerated rather than left to fail the build. coveralls.json and the coverage badge in the README indicate that test coverage is measured. The CI workflow badge points at an Elixir workflow on the master branch, and the default branch of the repository is master.
The library is dependency-free, which is the project's stated goal of staying simple to use and small to maintain, with the reasoning that less code means fewer bugs. That choice has an obvious upside, since there is nothing to version or audit alongside your application, and an obvious limit, since everything the pattern does beyond the core pipeline, retries, async, circuit breakers, metrics, has to be built inside Sage rather than pulled from a library.
Hex releases stopped at 0.6.3 while commits continued
The package is published on Hex under the name sage, and the README links the package page and the hosted documentation at hexdocs.pm. The licence is MIT, stored in LICENSE.md at the root of the repository.
The release history is where the maintenance picture gets interesting. The most recent tagged release is 0.6.3 from 2022-09-20, preceded by 0.6.1 and 0.6.0, both dated 2020-07-02. The repository itself is not archived, and the last push was on 2026-08-07, which is well after the newest release tag.
So the source tree is receiving work while the published package version has not moved since 2022. There are two ways to read that. If you consume Sage from Hex, the code you get is 0.6.3 unless the maintainer has published something newer that the version list does not show. If you follow the master branch, you get changes that have never been cut as a release, which means your mix.lock will pin a git revision rather than a Hex version.
The project's own stated goals mention becoming a de facto tool for running distributed transactions in the Elixir world and educating people to run them pragmatically. Against that ambition, a package whose newest tag is more than three years old and whose repository moved in mid 2026 is a project to pin deliberately, check the diff on master before taking it, and treat as a dependency you own rather than one you simply upgrade.
Editorial conclusion
Sage fits Elixir teams running multi-step business flows that touch third-party APIs or more than one database and need a compensation path for partial failure. It does not fit services where the operating system or the node itself can die mid-saga, because the README states plainly that a node failure is outside what Sage can protect you from. Verify first which of your steps can actually be compensated, since a step without a real inverse is where a saga breaks, then check the published Hex version against your mix.exs requirement because the newest tagged release is 0.6.3 from 2022-09-20.
Frequently asked questions
What does the Sage Elixir library do?
Sage is a dependency-free implementation of the Sagas pattern in pure Elixir. It runs a pipeline of transaction steps and, when a step fails, executes the compensations of the failed step and all its predecessors.
In what order does Sage run compensation functions?
Compensations run in reverse of the order the transactions ran, so the failed step's compensation runs first and its predecessors are unwound afterwards. The README illustrates this with a failure on the third step of a four-step flow leading to C3, then C2, then C1.
Can Sage recover if the node running it crashes mid-saga?
No. The README states that Sage can not protect you from a node failure that executes the saga, so it compensates for failed transactions rather than for a failed runtime.
How do I add retries or an asynchronous step with Sage?
Retries are one of the listed features, including retries with exponential backoff and jitter. Asynchronous steps use run_async with a timeout, and a run step can take a third function for a circuit breaker, as the:plans step does in the example.
Where is the Sage package published and what is its latest version?
Sage is published on Hex as the sage package with hosted documentation on hexdocs.pm. The newest tagged release listed is 0.6.3 from 2022-09-20.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/nebo15-sage)Was this useful?