HomeWorks
ExperimentsWriting

© 2025 Guo Ying. Made with midnight builds.

Works>

Mekong Line — Railway Ticketing Platform

2026
Mekong Line — Railway Ticketing Platform
BookingMicroservicesDistributed systemsFull-stack

A full ticketing platform for Vietnam’s North–South railway: multi-parameter search across stations and seat classes, a seat-level booking wizard, ten-minute seat holds, VNPay checkout, QR e-tickets, and an operations console for trip inventory, orders, payments and vouchers. Six NestJS services behind one API gateway.

Visit Website Source on GitHub
Status
In production
Role
Full-stack developer — design & code
Platform
Web (responsive)
Stack
Next.js 16 · React 19 · TypeScript · Tailwind CSS · TanStack Query · NestJS · RabbitMQ · PostgreSQL · Prisma · Redis Sentinel · Docker · VNPay

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.

Captured from the running app

Screens & specifications

Every screen below was captured from the live stack — route, behaviour, and the implementation behind it. 12 screens

Home — hero search & featured departure
Route/

Home — hero search & featured departure

Editorial hero, a station/date search card, live stats, then the week’s featured trip with its cheapest fare and remaining seats.

  • Server-first Next.js 16 App Router with client islands for interactive cards.
  • Featured trip and fare come from GET /search/trips, so the homepage can never drift from inventory.
  • Design language: deep teal surface, gold accents, stamp badges — border over shadow, no gradients.
Search — multi-parameter trip results
Route/search

Search — multi-parameter trip results

Filter by origin, destination, date, sort order, departure time-of-day and seat class; results show schedule, duration and lowest price per trip.

  • Filter state lives in the URL query, so any result page is shareable and back-navigation is free.
  • Inputs run through useDeferredValue to keep typing smooth while a query is in flight.
  • The gateway validates every query parameter (enum sort, max limit 50) and rejects bad input with 400.
Catalogue — every trip on sale
Route/tickets

Catalogue — every trip on sale

The public inventory list: each card carries train code, route, departure time, duration, seat availability badge and lowest fare.

  • Pagination, station filters and sorting all run against the same public GET /tickets endpoint the admin console uses.
  • Fares and seat counts are aggregated per trip from its ticket items — one round-trip per card grid.
Ticket detail — booking wizard & seat map
Route/tickets/:id

Ticket detail — booking wizard & seat map

Coach and seat-class cards with per-class fare and availability, a seat map with live seat states, and a sticky order summary that carries the running total.

  • Seat availability is queried per coach from GET /tickets/:id/seat-map — never stored as a counter column.
  • Choosing seats calls the reserve endpoint, which takes the Redlock hold and the row lock before returning.
  • Money is handled as integer VND end-to-end; totals multiply as BigInt to avoid float drift.
Order detail — paid booking
Route/orders/:id

Order detail — paid booking

The order as the system sees it: status, trip, coach and seat labels, passenger count, total and — when a payment fails late — the cancellation reason recorded by the compensation worker.

  • Ownership is asserted server-side (403 for non-owners) — the UI never decides who may read an order.
  • Status transitions are driven by the saga, not by the client: mark-paid → confirm → issue-ticket.
  • A late payment on an expired order is compensated instead of silently fulfilled.
E-ticket — QR boarding pass
Route/profile/tickets

E-ticket — QR boarding pass

Issued tickets land in the account with train, coach, seat and departure details plus the QR payload that staff scan at the gate.

  • The ticket is issued by orders-service and carries a signed QR payload that the check-in screen verifies.
  • Ticket codes are generated server-side (TCK-…) and never reused across orders.
  • Rendering the QR is a client concern (qrcode), so the payload never has to be an image in the database.
Account — profile & security
Route/profile

Account — profile & security

Signed-in account surface: profile details, verified badge, join date, and the tabs leading to orders, tickets and notifications.

  • The session is read once from GET /auth/session and cached by TanStack Query — the client never decodes tokens itself.
  • Roles (USER / STAFF / ADMIN) drive which surfaces appear; the server still re-checks every request.
Notification inbox
Route/notifications

Notification inbox

Promotions, booking confirmations and payment results land in one inbox, with read state kept per user.

  • notification-service has no public HTTP surface — it only consumes events, which keeps the API attack surface small.
  • Queues are durable with manual acknowledgement and a dead-letter queue for failed handlers.
Route map — the network at a glance
Route/route-map

Route map — the network at a glance

Stations and segments rendered from the station catalogue — the visual anchor of the "Tuyến đường" (journey line) design language.

  • Stations come from one typed catalogue shared by search, checkout and the map.
  • Animated with restraint — motion respects prefers-reduced-motion.
Admin — operations dashboard
Route/admin

Admin — operations dashboard

Ticket, order, user and payment counts with the latest orders listed underneath — the console an operator opens first.

  • Every /admin route is guarded by @Roles(ADMIN) on top of the global JWT guard.
  • Aggregates are read from each service’s own database through the gateway.
Admin — trip inventory
Route/admin/tickets

Admin — trip inventory

Trip inventory with coaches and seat classes: create a trip, publish it, then manage its ticket items and seat rows.

  • Validation rejects unknown fields (forbidNonWhitelisted) so inventory payloads stay exact.
  • Path parameters are validated as UUIDs before they reach Prisma, turning would-be 500s into 400s.
Sign in — cookie sessions
Route/login

Sign in — cookie sessions

Split-screen sign-in: the brand panel carries the product story while the form stays narrow and quiet.

  • The gateway sets HttpOnly accessToken/refreshToken cookies — no tokens in localStorage.
  • Auth endpoints are rate limited (login 10 req/min, reset flows 5 req/min).
  • Reset and verification tokens are stored hashed; the raw token only travels by email.

Behind the product

Engineering systems

The parts that are not visible in a screenshot: locking, messaging, retries and payment integrity.

  1. 01

    API gateway + global guard chain

    Throttler → JWT → Roles run for every request; only endpoints marked @Public() bypass authentication.

    Where: api-gateway/src/api-gateway.module.ts

  2. 02

    Distributed locking (Redlock + fencing)

    Prevents two buyers from holding the same seat; locks auto-extend so a long checkout cannot expire mid-flow.

    Where: tickets-service/src/redis/redis.service.ts

  3. 03

    Transactional outbox

    payment.paid is written in the same transaction as the payment status, then published by a cron worker with exponential backoff.

    Where: payments-service/src/payment/payment.service.ts

  4. 04

    Saga choreography + compensation

    payment.paid → markPaid → confirm → issueTicket → email; any failure runs compensation (release seats, cancel) instead of a two-phase commit.

    Where: orders-service/src/order/order.service.ts

  5. 05

    Delayed queue (TTL + dead-letter)

    The 10-minute seat hold is a message TTL, not a scheduled job: expiry dead-letters into the order-expiry consumer.

    Where: orders-service/src/order/order.module.ts

  6. 06

    Idempotency key

    A unique key on checkout collapses double-clicks and retries into one order — a replayed key returns the original response.

    Where: orders-service/src/order/order.service.ts

  7. 07

    Redis Sentinel HA cluster

    One master, two replicas and three sentinels (quorum 2/3 against split-brain) with automatic failover; reads round-robin over replicas.

    Where: infra/docker/docker-compose.yml

  8. 08

    Database per service

    PostgreSQL for auth, tickets, orders, payments and notifications; Redis for cache and locks. Seat mutations serialize through SELECT ... FOR UPDATE.

    Where: infra/docker/init-databases.sql

  9. 09

    VNPay IPN integration

    Server-to-server IPN is the source of truth (checksum verified, amount checked, status idempotent); the return URL only displays the result.

    Where: api-gateway/src/payment/vnpay.service.ts

  10. 10

    Nginx reverse proxy + containers

    One entry port routes /api/* to the gateway and everything else to Next.js, with gzip, security headers and two-layer rate limits; services run as non-root multi-stage images.

    Where: infra/nginx/default.conf

  11. 11

    Client state layer + refresh dedupe

    TanStack Query holds server state; an axios interceptor collapses concurrent 401s into a single refresh call.

    Where: client/lib/http.ts