The product
Mekong Line sells seats on Vietnam's Bắc – Trung – Nam corridor. Two surfaces share one design system: a passenger storefront (search → seat map → booking wizard → VNPay → QR e-ticket) and an operations console for trip inventory, orders, payments, vouchers and users.
Six services, one gateway
The browser only ever talks to api-gateway (NestJS). Behind it, auth-service, tickets-service, orders-service, payments-service and notification-service are independent apps that speak RabbitMQ commands and events. Each service owns its own PostgreSQL database — no shared tables, no cross-service joins.
client (Next.js 16)
-> api-gateway HTTP + cookie auth + rate limiting
-> auth-service accounts, sessions, roles
-> tickets-service trips, coaches, seats, search
-> orders-service checkout, order workflow, e-ticket
-> payments-service payments + VNPay
-> notification-service email / in-app events
Holding a seat is the hard part
Two buyers, one last berth. The second must see "sold out" — never a double-booked train. A double layer handles it: Redlock (auto-extended, so the lock never expires mid-flow) as the UX-level hold, and SELECT ... FOR UPDATE row locks inside a short transaction as the source of truth.
// tickets-service: the row lock decides, Redlock only smooths the UX.
await this.prisma.$transaction(async (tx) => {
const [seat] = await tx.$queryRaw`SELECT * FROM ticket_items WHERE id = ${id} FOR UPDATE`;
if (seat.status !== 'AVAILABLE') throw new SeatTakenError();
await tx.ticketItem.update({ where: { id }, data: { status: 'RESERVED' } });
});
Seats stay held for ten minutes. The countdown is not a cron job: the order id is published to orders_expiration_queue with x-message-ttl: 600000, and the dead-letter fan-out runs the compensation — release the seats, expire the order and its payment.
Payment is a saga, not a transaction
A payment cannot be one ACID transaction across four databases, so it is a choreography: VNPay's IPN (server-to-server, the only source of truth) marks the payment paid and writes a payment.paid event into an outbox table in the same commit; a cron publisher drains the outbox; orders-service consumes the event and runs mark-paid → confirm → issue-ticket → email. Any failure on the way runs compensation instead of a rollback.
Checkout is idempotent
Double-clicks and retries hit a unique idempotency_key on the order. A replayed key returns the original response instead of creating a second booking.
What the screens below show
Every screen was captured from the running stack — Docker infrastructure, six services, and the Next.js client — not a mockup.