Multi-step mutations
For operations that require multiple steps to succeed atomically — like checkout, a transfer, or an onboarding flow — use the read-check-act pattern with withRollback.
The pattern
- Read all data needed for validation upfront
- Check all business rules before making any changes
- Act — register a
ctx.onRollback()compensation before each change
ts
import { z } from 'zod';
import { withRollback } from '@capixjs/core';
import { authCap } from '../../capabilities.js';
import { mustBeCustomer } from '../../guards.js';
export const checkout = authCap(z.object({}), async (_, ctx) => {
// 1. Read
const cart = await ctx.db.carts.getByCustomer(ctx.user.id);
const products = await Promise.all(cart.items.map((i) => ctx.db.products.get(i.productId)));
// 2. Check everything before changing anything
if (cart.items.length === 0) throw errors.BadRequest({ reason: 'Cart is empty' });
for (const [i, item] of cart.items.entries()) {
const product = products[i]!;
if (product.status !== 'active') throw errors.BadRequest({ reason: 'Product unavailable' });
if (product.stock < item.quantity) throw errors.OutOfStock({ productId: product.id });
}
// 3. Act — register rollback before each side effect
const order = await ctx.db.orders.create({ customerId: ctx.user.id, items: cart.items });
ctx.onRollback(() => ctx.db.orders.delete(order.id));
for (const item of cart.items) {
await ctx.db.products.decrementStock(item.productId, item.quantity);
ctx.onRollback(() => ctx.db.products.incrementStock(item.productId, item.quantity));
}
await ctx.db.carts.clear(ctx.user.id);
ctx.onRollback(() => ctx.db.carts.restore(ctx.user.id, cart));
await ctx.jobs.enqueue({ type: 'send_order_confirmation', orderId: order.id });
// No rollback for the job — job processing is idempotent
return order;
}, 'mutation').guard(mustBeCustomer).enhance(withRollback);If any step throws after the order is created, withRollback runs the compensations in reverse order:
- Restore the cart
- Restore inventory for each item
- Delete the order
What withRollback is not
withRollback is not a database transaction. It does not provide:
- Atomicity: other requests can observe intermediate state between steps
- Isolation: concurrent requests can conflict
- Durability: rollbacks are in-memory; a crash between steps leaves partial state
When to use it:
- In-memory stores where each step is independently reversible
- External APIs where you can issue compensating calls (create → delete, increment → decrement)
- Development or testing environments
When to use a real transaction instead:
- Any relational database — use
db.transaction(async (trx) => { ... }) - Operations where partial state is unacceptable
Partial failure handling
If a rollback itself fails, withRollback logs the error and continues with the remaining rollbacks. It does not re-throw from the rollback — the original error is always what propagates to the caller.
Sequence matters
Register rollbacks immediately before the operation they compensate:
ts
// ✓ Correct — rollback registered right before the side effect
const order = await db.orders.create(...);
ctx.onRollback(() => db.orders.delete(order.id));
const charge = await stripe.charges.create(...);
ctx.onRollback(() => stripe.charges.refund(charge.id));
// ✗ Wrong — if stripe.create throws, the order rollback still runs (this is fine)
// but if you registered all rollbacks upfront, you might rollback operations
// that never happenedRollbacks run in reverse registration order, which naturally undoes steps in reverse sequence.