What problem does a saga solve?
Many business operations are pipelines: do A, then B, then C. In a single database you wrap that in one transaction—if B fails, A rolls back automatically. In the real world, A might be a room hold in a property system, B a card capture at a payment processor, and C an email via a provider. Those systems do not share one transaction log.
Without a structured approach you get ad hoc code: nested try/catch, manual “if payment failed, remember to release inventory” comments, and production bugs where orphan holds or double charges slip through when a step fails or times out.
A saga is a pattern for that situation:
- You run the pipeline as a sequence of local steps (each step can succeed or fail on its own).
- For steps that must be undone if a later step fails, you define an explicit compensating action—not a generic database rollback, but a business-meaningful reverse (release the hold, refund the charge).
- A coordinator (here,
SagaOrchestrator) runs the forward steps in order; if one throws, it runs compensators for the steps that already completed, in reverse order.
The purpose of a saga is therefore controlled consistency across independent systems: you accept that there is no global lock, but you refuse to leave the business in an inconsistent state when something breaks mid-flow.
This repository (hazeljs-saga-starter) is a minimal but honest example: a trip booking flow implemented with @hazeljs/saga orchestration inside a small HazelJS HTTP app. You can run it locally and watch both the happy path and a failed payment with automatic compensation.
Why not one giant distributed transaction?
ACID transactions are the right default inside one database. Across payment gateways, PMS/channel managers, and email APIs they are usually not available in a practical form. Two-phase commit is heavy, brittle, and often impossible when vendors only offer REST webhooks.
The saga pattern trades “all-or-nothing in one atomic commit” for:
- Clear forward steps (what we try to achieve).
- Clear compensators (how we undo what already succeeded if we must abort).
That trade is why sagas show up in checkout, travel, transfers, and provisioning: the workflow spans boundaries you do not control.
Orchestration vs choreography
- Orchestration — one place (a class or service) knows the full recipe and calls each step. Easy to read and debug; this starter uses that style.
-
Choreography — each service reacts to events; the global flow is implicit. Great for very loose coupling; HazelJS also exposes helpers (
@SagaChoreography,@OnEvent) for that model.
This example, end to end
We implement a three-step trip booking:
| Order | Forward step | What it does (demo) | If we must roll back |
|---|---|---|---|
| 1 | holdRoom |
Creates a temporary room hold (holdId) |
releaseRoomHold |
| 2 | chargeGuest |
Captures payment (paymentReference) |
refundGuest |
| 3 | sendConfirmation |
Assigns a confirmation code and logs mail | (no compensator) |
Why this order? Inventory is often held before money moves: you do not charge until you know you can fulfill, and you do not want paid bookings with no room. If payment fails after a hold exists, the business rule is: release the hold so another guest can book. The saga encodes that rule as compensation for step 1 when step 2 throws.
What happens when step 2 fails? Only step 1 has completed successfully. The orchestrator compensates in reverse order among completed steps, so it runs releaseRoomHold only. refundGuest does not run in that scenario—there was no successful charge to refund. (If step 3 failed after a successful charge, you would see refundGuest then releaseRoomHold; your real product might define different rules for that edge.)
The demo uses in-memory HotelService, PaymentService, and NotificationService. They stand in for HTTP clients; the saga class would look the same if those methods called external APIs.
The saga in code (TripBookingSaga)
The workflow lives in src/sagas/trip-booking.saga.ts:
-
@Saga({ name: 'trip-booking' })registers this flow under a stable name the orchestrator can start by string. -
@SagaStep({ order: 1, compensate: 'releaseRoomHold' })marksholdRoomas the first forward step and names the method to call to undo it. -
@SagaStep({ order: 2, compensate: 'refundGuest' })does the same for payment. -
@SagaStep({ order: 3 })is the final step; it has nocompensatehook in this demo (you could add one later, e.g. “send cancellation email,” if step 3 could fail after side effects).
Compensator methods (releaseRoomHold, refundGuest) are ordinary async methods on the same class; they are not decorated with @SagaStep because they only run during rollback.
Important implementation detail: SagaOrchestrator.start('trip-booking', state) passes the same object into every forward step and compensator. The starter’s TripBookingState is therefore mutated as the saga runs: holdId appears after step 1, paymentReference after a successful step 2, confirmationCode after step 3. That mirrors how teams often thread a “booking draft” DTO through a pipeline without a separate context bag.
How HazelJS triggers the saga (BookingController)
The HTTP layer does not use dependency injection for the saga registry. Instead, loading the saga module runs the class decorator and registers the workflow once. In app.module.ts you will see:
import "./sagas/trip-booking.saga";
That import exists for its side effect so the orchestrator knows about trip-booking before any request arrives.
BookingController (src/booking.controller.ts) handles POST /bookings: it builds a TripBookingState from JSON (including simulatePaymentFailure for demos), then:
const context = await SagaOrchestrator.getInstance().start(
"trip-booking",
state,
);
The response includes:
-
status— e.g.COMPLETEDorABORTED(after compensation). -
steps— each forward step’s outcome, and which steps were marked compensated. -
data— the same state object, now filled with ids on success. -
failureReason— when a step threw, the error message is surfaced for debugging.
GET /bookings/audit-log reads the in-memory notification log so you can see that a confirmation was actually “sent” on success, or inspect side effects while learning.
What to try with curl
Happy path — all steps succeed; response shows COMPLETED, a holdId, paymentReference, and confirmationCode.
Failure path — send "simulatePaymentFailure": true. PaymentService.capture throws; the orchestrator moves the saga to a compensating phase, releases the hold, and returns ABORTED. In the steps array you should see the payment step as failed and the inventory step as compensated—a concrete picture of what “rollback” means here.
That visibility is the point of the example: you can reason about purpose (keep inventory and money aligned) and mechanism (decorators + orchestrator + compensators) without Kafka or a workflow engine.
Orchestration vs choreography (again, in HazelJS terms)
For this booking story, orchestration wins because:
- The sequence and compensations fit in one class that new teammates can read top to bottom.
- Support and incident response benefit from a single named workflow (
trip-booking) tied to logs and HTTP responses.
Choreography remains valuable when many teams publish and subscribe to events and no one team should own the full script. HazelJS documents that path separately; the mental model for purpose is the same—explicit recovery—only the coordination style changes.
Before you ship something like this to production
This starter is intentionally small. In production you would typically add:
- Persistence — store saga id, status, and step results so you can survive process restarts and audit history.
- Idempotency — external APIs should accept idempotency keys (often derived from saga id + step name) so safe retries do not double-charge or double-hold.
- Timeouts and async steps — long-running or human-in-the-loop work usually moves to queues and state machines; the orchestrator here runs steps inline in the request.
-
Compensation failure — if
releaseRoomHolditself fails, you need alerting and manual playbooks; no pattern removes that need entirely.
Try it
Get the code:
git clone https://github.com/hazel-js/hazeljs-saga-starter.git
cd hazeljs-saga-starter
Repository: github.com/hazel-js/hazeljs-saga-starter.
Then run npm install and npm run dev (see README.md for port and curl snippets). d
A saga is not magic—it is explicit failure design: you document how success looks and how you undo partial success when the world says no. @hazeljs/saga gives you a thin, decorator-first way to express that in TypeScript next to your HazelJS controllers.
Last updated: @hazeljs/saga orchestration API, HazelJS 0.8.x.
Top comments (0)