Designing a Booking System for a Travel Marketplace: Saga Pattern and Event-Driven Workflows in Medusa v2
Bài này có bản tiếng Việt. Đọc bản tiếng Việt →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
At SmileViet, the backend I maintain is a Medusa v2 service for a travel marketplace. Customers book tours, cruises, transport tickets and visas on one e-commerce platform, extended with vertical modules for travel.
It started when we realized that “all or nothing” transactions weren’t enough anymore. A tour order looks simple in the UI. Behind it sit five different sources of truth, and I don’t control one of them.
1. Why a booking is not a single DB transaction
The five sources are:
- Inventory service: holds a seat on a tour or cruise. Seats are limited, so the lock has to happen at exactly the right moment.
- Pricing/FX service: calculates the price by seat class, peak season and the exchange rate at booking time.
- Payment service: charges the card through Stripe/Adyen or a regional e-wallet (LINE Pay, KakaoPay).
- Supplier confirmation: for many tours I have to call an API (or send an email) to a third-party supplier to confirm the seat. This step can take anywhere from a few seconds to a few minutes, and it can be rejected after the customer has already paid.
- Notification/CMS sync: sends the confirmation email and syncs data to the CMS for the operations team.
None of these share a database. Some are out of reach entirely, and supplier confirmation is the clearest example.
Two-phase commit (2PC) requires every participant to support distributed transactions. A tour supplier’s API doesn’t, and never will. So if we charge the money and the supplier then says no, we need a way to undo what we’ve already done. Rolling back a DB transaction doesn’t help here.
2. Saga: a chain of steps with a way back
We went with a choreography-based saga. There is no big transaction at all. The booking is split into a chain of steps that run sequentially (or in parallel when that’s safe), and each step comes with a compensating action to back out if a later step fails.
Step 1: Hold inventory → Compensate: Release inventory
Step 2: Calculate final price → Compensate: (not needed, read-only)
Step 3: Charge payment → Compensate: Refund payment
Step 4: Confirm with supplier → Compensate: Cancel + release inventory + refund
Step 5: Send confirmation → Compensate: Send cancellation notice
If step 4 fails, say the supplier reports no seats left at that very moment, the saga runs backwards. It refunds the money charged in step 3, releases the seat held in step 1, and sends the customer a cancellation notice instead of a confirmation email. The diagram below draws both branches side by side:
sequenceDiagram
autonumber
actor KH as Customer
participant WF as Booking Workflow<br/>(Medusa v2)
participant INV as Inventory
participant PAY as Payment<br/>(Stripe/Adyen)
participant SUP as Supplier API
participant BUS as Event Bus
KH->>WF: Book tour (with idempotencyKey)
WF->>INV: Hold seat
WF->>PAY: Charge
WF->>SUP: Confirm seat
alt Supplier confirms
SUP-->>WF: OK
WF-->>KH: Booking successful
WF->>BUS: booking.confirmed
Note over BUS: Confirmation email + CMS sync<br/>run in the background, retry independently
else Supplier rejects
SUP-->>WF: No seats left
Note over WF,PAY: Compensate in reverse order
WF->>PAY: Refund
WF->>INV: Release seat
WF-->>KH: Cancellation notice
WF->>BUS: booking.cancelled
end
I implemented this saga with Medusa v2’s built-in workflow engine (defineWorkflow + createStep), where each step declares its invoke and compensate functions explicitly:
import { createWorkflow, createStep, StepResponse } from "@medusajs/framework/workflows-sdk";
const chargePaymentStep = createStep(
"charge-payment",
async (input: { bookingId: string; amount: number }, { container }) => {
const paymentService = container.resolve("paymentModuleService");
const payment = await paymentService.capturePayment(input);
// Save the data needed to compensate (for example the payment id)
return new StepResponse(payment, payment.id);
},
async (paymentId, { container }) => {
// Compensation: refund the payment if a later step fails
const paymentService = container.resolve("paymentModuleService");
await paymentService.refundPayment(paymentId);
},
);
const bookTourWorkflow = createWorkflow("book-tour", (input) => {
const inventory = holdInventoryStep(input);
const price = calculatePriceStep(input);
const payment = chargePaymentStep({ bookingId: input.bookingId, amount: price.total });
const confirmation = confirmWithSupplierStep({ bookingId: input.bookingId });
return new WorkflowResponse({ inventory, payment, confirmation });
});
The workflow engine calls compensate on its own, in the reverse order of the steps that succeeded. So I don’t have to write rollback logic by hand at every place that invokes the workflow.
3. Anything that doesn’t need to be synchronous goes to a subscriber
Sending the confirmation email. Syncing the booking to the CMS. Writing the audit log. None of these need to live inside the saga, so we pushed them all out into subscribers listening for booking.confirmed or booking.cancelled:
export default async function bookingConfirmedHandler({
event: { data },
container,
}: SubscriberArgs<{ bookingId: string }>) {
const notificationService = container.resolve("notificationModuleService");
await notificationService.createNotifications({
to: data.bookingId,
template: "booking-confirmation",
});
}
The upside: if the email service is slow or briefly down, the booking flow isn’t blocked. Once the supplier confirms, the saga is done. Notifications retry independently through a job queue (Redis-backed).
4. Idempotency: the most painful part
Writing the saga isn’t hard. What’s hard is making every step safe to run more than once. A network timeout, a client retry, a worker restarting halfway through: sooner or later some step will get called again.
Here’s how we handle it:
- Every booking-creation request carries an
idempotencyKeygenerated by the client (or by the server the moment the user clicks “Book now”, and stored in local storage in case the connection drops). - Before charging payment, the step checks whether a payment is already tied to this
idempotencyKey. If there is one and it succeeded, it returns the earlier result instead of charging a second time. - The
holdInventorystep usesINSERT ... ON CONFLICT DO NOTHINGon aninventory_holdstable with a unique constraint on(booking_id, inventory_item_id), so calling it again doesn’t create two duplicate holds. - Supplier confirmation APIs usually aren’t idempotent by the third party’s design, so we store a “request sent” state ourselves before calling. On a retry we check that state first. If the request was sent but no response has come back, we poll the status instead of sending a new request.
5. The result, and the price
We used to handle this with try-catch and manual rollback. After moving to a saga, the number of bookings stuck in an ambiguous state (money charged, no clear answer on whether the tour was confirmed) dropped to almost zero. Every intermediate state is modeled as a step, and every step knows how to clean up after itself.
The price is noticeably more complex code. Each step has to think through both the success path and the compensation path. Debugging a multi-step saga, especially while it’s compensating backwards, takes much better logging and tracing than a single DB transaction does. In exchange, this domain touches real money and has a third party we don’t control. I think it’s worth it.
Keep reading
- Multi-Provider Payment Gateway Architecture: Handling Stripe, Adyen and Asian Regional Payment ProvidersEngineering Deep Dive
- When Redis Goes Stale: Lessons on Cache Invalidation in an E-commerce SystemDev Life & Incidents
- Debugging a Hidden Race Condition in a Real-Time Payment SystemDev Life & Incidents
Need a developer for a contract or freelance project?
I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.