Below is a four-step booking saga: reserve a seat, charge the card, notify the airline, and award loyalty points. Each step commits locally. Press Run saga to advance through the steps one by one. At any point, press Inject failure to simulate a step crashing — and watch the saga automatically execute the compensating transactions in reverse to undo all earlier work.
<!-- Saga pattern demo — root element -->
<div class="saga-app">
<p class="hint">Each row is one saga step. Press <b>Run saga</b> to commit steps one by one. Press <b>Inject failure</b> to simulate a crash — the saga runs its compensating transactions in reverse to undo all committed work.</p>
<div id="steps-list" class="steps-list"></div>
<div class="status-bar" id="status-bar"></div>
<div class="btns">
<button id="btn-run" type="button">Run saga</button>
<button id="btn-fail" type="button" class="danger">Inject failure</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
</div>
/* Styles for the saga step list and state indicators */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.saga-app { padding: .4rem 0; }
.hint { font-size: .87rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.steps-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: .8rem; }
.step { display: flex; align-items: center; gap: 10px; padding: .5rem .75rem;
border-radius: 8px; border: 1.5px solid #cdd9e3; background: #f4f7fa;
font-size: .9rem; transition: all .25s; }
.step .icon { width: 22px; text-align: center; font-size: 1.1rem; flex-shrink: 0; }
.step .label { flex: 1; font-weight: 600; }
.step .comp { font-size: .78rem; color: #888; margin-left: auto; white-space: nowrap; }
/* pending state — not yet reached */
.step.pending { background: #f4f7fa; border-color: #cdd9e3; }
/* running state — currently executing */
.step.running { background: #fff8e1; border-color: #f0c040; }
/* done state — committed successfully */
.step.done { background: #e8f5e9; border-color: #66bb6a; }
/* failed state — step crashed */
.step.failed { background: #fce8e8; border-color: #e57373; }
/* compensating state — undoing this step */
.step.compensating { background: #fff3e0; border-color: #ffa726; }
/* compensated state — undo complete */
.step.compensated { background: #f3e5f5; border-color: #ab47bc; }
.status-bar { font-size: .95rem; font-weight: 600; min-height: 1.5em; margin-bottom: .5rem; }
.status-bar.ok { color: #2e7d32; }
.status-bar.bad { color: #c62828; }
.status-bar.warn { color: #e65100; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border-radius: 8px;
cursor: pointer; border: 1.5px solid #1d3557; background: #1d3557; color: #fff; }
button.danger { background: #c62828; border-color: #c62828; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Saga pattern: a sequence of local transactions with compensating transactions.
// Each step has a forward action (label) and a compensating action (comp).
const STEPS = [
{ id: 'seat', icon: '✈️', label: 'Reserve seat', comp: 'Release seat' },
{ id: 'payment', icon: '💳', label: 'Charge card', comp: 'Issue refund' },
{ id: 'airline', icon: '📨', label: 'Notify airline', comp: 'Cancel notification' },
{ id: 'loyalty', icon: '⭐', label: 'Award loyalty points', comp: 'Revoke points' }
];
// Application state
let stepStates = STEPS.map(() => 'pending');
let cursor = 0; // index of the next step to execute
let failed = false;
let compensating = false;
let done = false;
function setState(idx, st) { stepStates[idx] = st; renderSteps(); }
function renderSteps() {
const list = document.getElementById('steps-list');
list.innerHTML = '';
STEPS.forEach((s, i) => {
const div = document.createElement('div');
div.className = 'step ' + stepStates[i];
div.innerHTML =
'<span class="icon">' + s.icon + '</span>' +
'<span class="label">' + s.label + '</span>' +
'<span class="comp">' + (
stepStates[i] === 'compensating' || stepStates[i] === 'compensated'
? '↩ ' + s.comp
: ''
) + '</span>';
list.appendChild(div);
});
const runBtn = document.getElementById('btn-run');
const failBtn = document.getElementById('btn-fail');
runBtn.disabled = done || compensating;
failBtn.disabled = done || cursor === 0 || compensating || failed;
}
function setStatus(msg, cls) {
const el = document.getElementById('status-bar');
el.textContent = msg;
el.className = 'status-bar ' + (cls || '');
}
// Advance the saga by one step (commit forward).
async function advance() {
if (done || compensating) return;
if (cursor < STEPS.length) {
setState(cursor, 'running');
setStatus(fmt('Executing: %label%…', { label: STEPS[cursor].label }), 'warn');
await pause(500);
setState(cursor, 'done');
cursor++;
if (cursor === STEPS.length) {
done = true;
setStatus('✓ Saga complete — all steps committed successfully.', 'ok');
document.getElementById('btn-run').disabled = true;
document.getElementById('btn-fail').disabled = true;
} else {
setStatus(fmt('✓ Committed: %label%. Ready for next step.', { label: STEPS[cursor - 1].label }), 'ok');
}
}
}
// Inject a failure at the current step and run compensations backward.
async function injectFail() {
if (done || compensating || cursor === 0 || failed) return;
failed = true;
compensating = true;
setState(cursor, 'failed');
setStatus(fmt('✗ Step failed: %label%. Running compensations in reverse…', { label: STEPS[cursor].label }), 'bad');
await pause(600);
// Walk backward through committed steps and compensate each one.
for (let i = cursor - 1; i >= 0; i--) {
setState(i, 'compensating');
setStatus(fmt('Undoing %label% (compensation: %comp%)…', { label: STEPS[i].label, comp: STEPS[i].comp }), 'warn');
await pause(700);
setState(i, 'compensated');
}
compensating = false;
done = true;
setStatus('✗ Saga rolled back — all compensations applied.', 'bad');
}
function pause(ms) { return new Promise(r => setTimeout(r, ms)); }
function reset() {
stepStates = STEPS.map(() => 'pending');
cursor = 0;
failed = false;
compensating = false;
done = false;
renderSteps();
setStatus('', '');
}
document.getElementById('btn-run').addEventListener('click', advance);
document.getElementById('btn-fail').addEventListener('click', injectFail);
document.getElementById('btn-reset').addEventListener('click', reset);
renderSteps();
setStatus('Press Run saga to start.', '');
<!-- Saga pattern demo — root element -->
<div class="saga-app">
<p class="hint">Each row is one saga step. Press <b>Run saga</b> to commit steps one by one. Press <b>Inject failure</b> to simulate a crash — the saga runs its compensating transactions in reverse to undo all committed work.</p>
<div id="steps-list" class="steps-list"></div>
<div class="status-bar" id="status-bar"></div>
<div class="btns">
<button id="btn-run" type="button">Run saga</button>
<button id="btn-fail" type="button" class="danger">Inject failure</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
</div>
/* Styles for the saga step list and state indicators */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.saga-app { padding: .4rem 0; }
.hint { font-size: .87rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.steps-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: .8rem; }
.step { display: flex; align-items: center; gap: 10px; padding: .5rem .75rem;
border-radius: 8px; border: 1.5px solid #cdd9e3; background: #f4f7fa;
font-size: .9rem; transition: all .25s; }
.step .icon { width: 22px; text-align: center; font-size: 1.1rem; flex-shrink: 0; }
.step .label { flex: 1; font-weight: 600; }
.step .comp { font-size: .78rem; color: #888; margin-left: auto; white-space: nowrap; }
/* pending state — not yet reached */
.step.pending { background: #f4f7fa; border-color: #cdd9e3; }
/* running state — currently executing */
.step.running { background: #fff8e1; border-color: #f0c040; }
/* done state — committed successfully */
.step.done { background: #e8f5e9; border-color: #66bb6a; }
/* failed state — step crashed */
.step.failed { background: #fce8e8; border-color: #e57373; }
/* compensating state — undoing this step */
.step.compensating { background: #fff3e0; border-color: #ffa726; }
/* compensated state — undo complete */
.step.compensated { background: #f3e5f5; border-color: #ab47bc; }
.status-bar { font-size: .95rem; font-weight: 600; min-height: 1.5em; margin-bottom: .5rem; }
.status-bar.ok { color: #2e7d32; }
.status-bar.bad { color: #c62828; }
.status-bar.warn { color: #e65100; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border-radius: 8px;
cursor: pointer; border: 1.5px solid #1d3557; background: #1d3557; color: #fff; }
button.danger { background: #c62828; border-color: #c62828; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Saga pattern: a sequence of local transactions with compensating transactions.
// Each step has a forward action (label) and a compensating action (comp).
const STEPS = [
{ id: 'seat', icon: '✈️', label: 'Reserve seat', comp: 'Release seat' },
{ id: 'payment', icon: '💳', label: 'Charge card', comp: 'Issue refund' },
{ id: 'airline', icon: '📨', label: 'Notify airline', comp: 'Cancel notification' },
{ id: 'loyalty', icon: '⭐', label: 'Award loyalty points', comp: 'Revoke points' }
];
// Application state
let stepStates = STEPS.map(() => 'pending');
let cursor = 0; // index of the next step to execute
let failed = false;
let compensating = false;
let done = false;
function setState(idx, st) { stepStates[idx] = st; renderSteps(); }
function renderSteps() {
const list = document.getElementById('steps-list');
list.innerHTML = '';
STEPS.forEach((s, i) => {
const div = document.createElement('div');
div.className = 'step ' + stepStates[i];
div.innerHTML =
'<span class="icon">' + s.icon + '</span>' +
'<span class="label">' + s.label + '</span>' +
'<span class="comp">' + (
stepStates[i] === 'compensating' || stepStates[i] === 'compensated'
? '↩ ' + s.comp
: ''
) + '</span>';
list.appendChild(div);
});
const runBtn = document.getElementById('btn-run');
const failBtn = document.getElementById('btn-fail');
runBtn.disabled = done || compensating;
failBtn.disabled = done || cursor === 0 || compensating || failed;
}
function setStatus(msg, cls) {
const el = document.getElementById('status-bar');
el.textContent = msg;
el.className = 'status-bar ' + (cls || '');
}
// Advance the saga by one step (commit forward).
async function advance() {
if (done || compensating) return;
if (cursor < STEPS.length) {
setState(cursor, 'running');
setStatus(fmt('Executing: %label%…', { label: STEPS[cursor].label }), 'warn');
await pause(500);
setState(cursor, 'done');
cursor++;
if (cursor === STEPS.length) {
done = true;
setStatus('✓ Saga complete — all steps committed successfully.', 'ok');
document.getElementById('btn-run').disabled = true;
document.getElementById('btn-fail').disabled = true;
} else {
setStatus(fmt('✓ Committed: %label%. Ready for next step.', { label: STEPS[cursor - 1].label }), 'ok');
}
}
}
// Inject a failure at the current step and run compensations backward.
async function injectFail() {
if (done || compensating || cursor === 0 || failed) return;
failed = true;
compensating = true;
setState(cursor, 'failed');
setStatus(fmt('✗ Step failed: %label%. Running compensations in reverse…', { label: STEPS[cursor].label }), 'bad');
await pause(600);
// Walk backward through committed steps and compensate each one.
for (let i = cursor - 1; i >= 0; i--) {
setState(i, 'compensating');
setStatus(fmt('Undoing %label% (compensation: %comp%)…', { label: STEPS[i].label, comp: STEPS[i].comp }), 'warn');
await pause(700);
setState(i, 'compensated');
}
compensating = false;
done = true;
setStatus('✗ Saga rolled back — all compensations applied.', 'bad');
}
function pause(ms) { return new Promise(r => setTimeout(r, ms)); }
function reset() {
stepStates = STEPS.map(() => 'pending');
cursor = 0;
failed = false;
compensating = false;
done = false;
renderSteps();
setStatus('', '');
}
document.getElementById('btn-run').addEventListener('click', advance);
document.getElementById('btn-fail').addEventListener('click', injectFail);
document.getElementById('btn-reset').addEventListener('click', reset);
renderSteps();
setStatus('Press Run saga to start.', '');
Notice that compensation is not rollback. A compensating transaction is a new forward action that logically reverses the effect of an earlier one — for example, issuing a refund rather than reversing a database write. Each step must be designed to be compensable from the start.
Comments
Loading comments...