CinX — Cinema Ticketing Platform
A seat-level cinema booking system built as a distributed system: five-minute seat holds, asynchronous payments, event-driven ticket issuance, and refunds that follow the money instead of flipping a status flag.
- Project
- Sep 2026 – Oct 2026
- Vue 3 · NestJS · gRPC · PostgreSQL · Redis · RabbitMQ · Xendit
A seat-level cinema booking system built as a distributed system: five-minute seat holds, asynchronous payments, event-driven ticket issuance, and refunds that follow the money instead of flipping a status flag.
| Type | Portfolio / engineering showcase |
| Role | Solo — domain design, backend, frontend, infrastructure, tests |
| Stack | Vue 3 · NestJS · gRPC · PostgreSQL · Redis · RabbitMQ · Xendit |
| Status | Working end-to-end; development/demo topology (no HA claim) |
| Repo | cinx |
1. Project Overview
Cinema ticketing looks simple until two customers tap the same seat at the same second. The interesting problems are not CRUD screens — they are seat contention, payment that arrives late or never, and state that has to stay correct across services that own different data.
CinX models the full journey: browse a catalog, pick a showtime, choose seats, hold them for five minutes, pay through a hosted checkout, receive tickets by email, and — when something goes wrong — cancel or refund without ever selling a seat twice.
The core implementation combines two layers of protection:
- Redis
redlockper seat (seat:{showtimeId}:{seatId}, TTL 5 minutes) as the fast concurrency layer. UNIQUE (showtime_id, seat_id)onbooking_seatsas the guarantee. The database is the source of truth; Redis is only an optimization.
Everything else follows from that decision: the booking service snapshots seat, movie, theater, and showtime data at hold time, payment and ticket issuance run through a RabbitMQ topic exchange, and refunds are a saga compensation rather than a UI action.
At a glance
| Dimension | Value |
|---|---|
| Deployable apps | 8 (Vue SPA, REST gateway, 6 backend services) |
| gRPC domain services | 5 — user, cinema, booking, payment, ticket |
| Databases | 5 logical PostgreSQL databases, one instance in dev |
| REST surface | 27 documented operations in docs/openapi.yaml |
| Hold window | 5 minutes, maximum 8 seats per booking |
| Payment provider | Xendit hosted invoices + webhooks + refunds |
| Resend (best-effort, non-transactional) | |
| Observability | OpenTelemetry → Collector → Tempo / Prometheus / Grafana |
| Verification | Jest, Vitest, Playwright, k6 (100 / 500 / 1,000 VU matrix) |
What is intentionally out of scope
CinX is a portfolio project, not a production deployment. High availability for PostgreSQL, Redis, and RabbitMQ is not configured; the default topology is a single instance of each. There is no transactional outbox, no bounded-retry dead-letter queue, and no versioned migration workflow yet. These are documented as follow-ups rather than hidden.
2. Features
Customer experience
- Catalog browsing — now-showing and coming-soon movies, genres, duration, age rating, posters, and showtimes with server-provided pricing.
- Seat selection — a live seat map that combines theater geometry from
cinema-servicewith availability frombooking-service; seats areAVAILABLE,HELD, orBOOKED. - Accounts — JWT authentication, registration, login, session verification, and personal booking history.
- Five-minute hold — a countdown-backed reservation window. Up to eight
seats per booking; conflicting seats return
409with the conflicting seat IDs so the UI can highlight them. - Hosted checkout — an Xendit invoice is created server-side; the browser is redirected to the provider, so card data never touches the application.
- Confirmation that converges — the confirmation page polls while
payment.settled → booking.confirmed → tickets.issuedpropagates, andsync-paymentacts as an anti-entropy fallback when a webhook is lost. - Tickets — issued once per paid seat, with a human-readable
TKT-XXXXXXXXcode, and publicly verifiable by code. - Cancel and refund — unpaid bookings cancel immediately; paid bookings go
through
REFUND_PENDINGand only becomeREFUNDEDwhen the provider confirms the money moved. Tickets are voided only after that confirmation. - Email — booking confirmation, ticket codes, and payment receipts through Resend, with logging-only fallback when no API key is configured.
Administration
- Catalog management — create, edit, and delete movies, genres, theaters, and showtimes behind an admin role check.
- Theater layout — define rows and seats when creating an auditorium; seats
carry category (
REGULAR,VIP,COUPLE), accessibility, and disabled flags. - Schedule integrity — showtime creation and updates reject overlapping slots in the same theater, using a half-open interval check against the movie's duration.
- Safe pricing — amounts are stored as integer IDR (
bigint), never floating point.
Platform and engineering
- Database per service — each service writes only its own database; other domains are referenced logically and composed through APIs or events.
- Saga orchestration — the gateway routes commands,
booking-serviceowns saga state, and payment/ticket react to events. No service reaches into another service's database. - Idempotent consumers — duplicate webhooks and replayed events do not double-issue tickets or double-refund.
- Reconciliation crons — expiry, confirmation retry, payment sync, and refund retry repair state when an event goes missing.
- Honest errors — a dead Redis returns
503 Seat lock unavailable, not a misleading409 seat already taken. - Observability — OTLP traces and metrics from every service, browser trace propagation, and Grafana dashboards over Tempo and Prometheus.
- Test and load tooling — unit and integration tests, Playwright end-to-end flows, and k6 scenarios with a local mock Xendit so benchmarks are deterministic and never consume provider quota.
- CI/CD — format, lint, unit tests, build, and Compose validation on every
push; eight container images built and pushed to GHCR on
main.
3. Tech Stack
| Layer | Technologies | Why it is here |
|---|---|---|
| Frontend | Vue 3, TypeScript, Vite 7, Pinia, Vue Router, Naive UI, Tailwind CSS 4 | Fast SPA with typed API contracts, a component library for complex admin tables, and utility CSS for layout |
| API layer | NestJS 11, REST/JSON, Passport JWT, Helmet, express-rate-limit, class-validator |
One backend-for-frontend that shapes responses for the UI and keeps gRPC out of the browser |
| Inter-service | gRPC, Protocol Buffers, typed stubs in @ticketing/shared |
Strict contracts and binary efficiency between services |
| Data | PostgreSQL 18, TypeORM, Redis 6 client, redlock |
ACID per service, unique constraints as the final guard, fast expiring locks |
| Messaging | RabbitMQ, topic exchange ticketing.internal, durable queues, manual ack |
Decouple booking, payment, ticket, and notification |
| Integrations | Xendit (invoices, webhooks, refunds), Resend (email) | Realistic payment lifecycle with an asynchronous provider |
| Observability | OpenTelemetry SDK, OTel Collector, Tempo, Prometheus, Grafana | End-to-end traces across HTTP, gRPC, and events |
| Tooling | pnpm workspaces, Turborepo, Docker Compose, GitHub Actions, GHCR | Monorepo builds, reproducible local stack, automated checks and images |
| Testing | Jest, Vitest, Playwright, k6 | Unit, component, browser, and load coverage |
4. Architecture
System shape
The browser talks to exactly one origin: a stateless REST gateway. The gateway is a backend-for-frontend — it translates JSON into gRPC calls, aggregates catalog and availability, normalizes gRPC errors into HTTP semantics, and owns authentication middleware. The SPA never learns that six services exist.
Service ownership
| Service | Owns | Commands / responsibilities | Data |
|---|---|---|---|
gateway |
Nothing (stateless) | REST routing, JWT auth, DTO validation, response aggregation, webhook entry | — |
user-service |
Identity | Register, login, profile, admin role, contact lookup | user_db |
cinema-service |
Catalog | Movies, genres, theaters, seats, showtimes, pricing, overlap rules | cinema_db |
booking-service |
Reservation lifecycle | Hold, cancel, list, availability, charge preparation, expiry and retry crons | booking_db + Redis locks |
payment-service |
Money state | Charge, webhook, status sync, refunds, payment reconciliation | payment_db |
ticket-service |
Issued admission | Issue, void, list, lookup by code | ticket_db |
notification-service |
Nothing (stateless) | Consume events, render and send email | — |
The booking saga
The important detail is the paid-but-unticketed state: a booking stays
PENDING with paid_at set until tickets.issued arrives. If ticket-service
is down, the event stays queued, the UI keeps polling, and no state is lost.
A separate cron re-emits booking.confirmed for bookings that never received
tickets.issued.
Two layers against double-booking
| Layer | Mechanism | Nature |
|---|---|---|
| 1 | Redis redlock on seat:{showtimeId}:{seatId}, TTL = hold window |
Fast, best-effort, auto-expiring |
| 2 | UNIQUE (showtime_id, seat_id) on booking_seats |
Guarantee enforced by PostgreSQL |
This is a deliberate answer to the Redlock critique: lock safety is not assumed.
If a lock expires during a GC pause, the unique constraint still rejects the
second write. If Redis is unreachable, the request fails closed with 503
instead of pretending the seat is taken.
Consistency model, per domain
| Domain | Choice | Rationale |
|---|---|---|
| Seat hold | CP | Two customers holding one seat is a business failure; rejecting a request beats accepting both |
| Payment settlement | CP via DB | The unique constraint is the final line, even if two paths (webhook + sync) race |
| Catalog browsing | AP | A slightly stale movie list is harmless; availability wins |
| Email / telemetry | AP, eventual | Duplicate or late email must never fail a settled booking |
This is also a PACELC decision: with no partition, CinX chooses low latency
(one Redis quorum, one gRPC call, one insert) and pays for consistency only when
there is a conflict (409) or a dependency failure (503).
Failure handling
| Failure | Behavior |
|---|---|
| Redis unavailable | Hold fails closed with 503 Seat lock unavailable |
| Duplicate Xendit webhook | Idempotent handler; no duplicate ticket, no duplicate refund |
Lost payment.settled |
sync-payment polling repairs the booking when the user returns |
Lost booking.confirmed |
Booking confirmation-retry cron republishes the event |
Lost tickets.issued |
Booking remains paid-pending; retry cron converges |
| Payment settles after expiry | Booking emits booking.refund.requested; no tickets are issued |
| Refund fails at provider | Booking returns to its paid state; tickets stay valid |
| RabbitMQ down | Notification is best-effort; a paid booking is never rolled back for email |
| Duplicate cancel | REFUND_PENDING / REFUNDED return 409, so a second refund is never sent |
Refunds follow one rule: never say REFUNDED before the provider says
succeeded. While REFUND_PENDING, the booking is still paid in every way that
matters — the seats stay BOOKED and the tickets still admit entry.
Observability and delivery
- Every backend service exports OTLP traces and metrics to an OTel Collector.
- Tempo stores traces, Prometheus stores metrics, Grafana displays both.
- The Vue client propagates
traceparent, so a click can be followed through gateway → gRPC → event → ticket in a single trace. - Sampling is set to 20% by default, because observability must not dominate the hot path.
- CI validates formatting, lint, unit tests, builds, and both Compose files.
- CD builds eight images and publishes them to GitHub Container Registry.
5. ERD
Five databases, one per data-owning service. Cross-database references are
logical — dotted lines below — and there are no cross-database foreign keys.
booking_seats keeps a local FK to bookings because both live in
booking_db.
Constraints that carry business meaning
| Constraint | Purpose |
|---|---|
booking_seats UNIQUE (showtime_id, seat_id) |
The double-booking guard |
bookings.status enum |
PENDING, CONFIRMED, EXPIRED, CANCELLED, REFUND_PENDING, REFUNDED |
bookings.cancellation_reason |
Separates customer cancellation from payment decline |
payments.booking_id UNIQUE |
One payment record per booking |
payments.external_id |
Our reference to the provider (cix-{bookingId}), not the provider's ID |
tickets.code UNIQUE |
Public, human-readable admission code |
tickets UNIQUE (booking_id, seat_id) |
One ticket per paid seat, even after a replay |
seats UNIQUE (theater_id, row_label, seat_number) |
No duplicate physical seat |
showtimes overlap rule |
App-enforced per theater, using movie duration |
Design notes
- Snapshots over joins. Booking records store movie title, theater name, start time, and seat/price snapshots. History stays correct even if the catalog changes later, and ticket issuance does not depend on cinema-service uptime.
- Availability is derived, never stored. A seat is
BOOKEDwhen aCONFIRMEDorREFUND_PENDINGbooking owns it,HELDwhen aPENDINGbooking is unexpired and the Redis lock is alive, andAVAILABLEotherwise. A refunded seat becomes resellable, and the unique guard still permits it because the old booking row is reaped on the next hold. - Cancellation is not expiry.
EXPIREDmeans the hold lapsed;CANCELLEDmeans someone decided, withCUSTOMERorPAYMENT_DECLINEDrecorded. Mixing those loses the difference between funnel abandonment and a payment problem. - Payment identifiers are distinct.
external_idis ours,invoice_idis the hosted invoice, andprovider_txn_idis the provider's settlement ID — which may legitimately stayNULLfor the Invoices API.
6. Trade-offs
Every architectural choice here has a bill attached. The point is to know what it costs.
| Decision | What it buys | What it costs | How it is contained |
|---|---|---|---|
| Microservices + gRPC over a modular monolith | Explicit ownership boundaries, independent deploy and scaling, strict contracts | Seven backend processes for one product; more coordination, more failure modes | Typed protos in a shared package, gateway as the only synchronous router |
| Database per service | Real data ownership; booking/payment/ticket can evolve without touching cinema | No cross-DB joins or FKs; cannot use one transaction across domains | Logical references, write-time snapshots, gRPC validation before writes |
| Redis lock + DB constraint | Fast holds with automatic expiry, and a guarantee that survives lock failure | Two systems to operate; Redis is a single instance in Compose | Fail-closed 503, unique index as the final fence, idempotent handlers |
| Asynchronous payments | Follows the provider instead of blocking the user; handles delayed settlement | Webhooks can be late, duplicated, or lost | Sync-payment polling, retry crons, idempotency, automatic late-settlement refund |
| RabbitMQ domain events | Booking, payment, and ticket stay decoupled; one event can fan out | Event contracts, ordering, duplicates, and poison messages become your problem | Durable queues, manual ack, idempotent consumers, retry crons |
| Saga orchestration | One place to read the booking lifecycle; one trace end to end | booking-service owns saga state and must tolerate duplicate events |
Explicit state machine, retry markers, compensating refund step |
| BFF gateway | The SPA gets one API shaped for the UI; gRPC stays internal | Extra network hop (~1–5 ms) and an extra component to scale | Stateless gateway, simple aggregation, normalized errors |
| Seat-map fallback | The page still renders when booking-service is down | Seats may look available | The server re-validates every seat at hold time |
TypeORM synchronize |
Fast schema iteration during development | No migration history, unsafe for production | Documented as a limitation; migrations are a planned follow-up |
| No outbox / DLQ yet | Simpler system while the event surface is small | A crash between DB commit and publish can lose an event | Reconciliation crons and idempotent replays; outbox + DLQ are explicit next steps |
| One instance per dependency | Reproducible local stack, easy demo | No high availability; an outage stops writes | Fail-closed behavior and availability probes; production HA documented separately |
What I would do differently at production scale
- Add a transactional outbox so a database commit and an event publish can never disagree, plus a dead-letter queue with bounded retries and alerting.
- Replace
synchronizewith versioned migrations, tested backups, and a restore drill. - Run Redis as Sentinel or Cluster and PostgreSQL with a replica/failover setup; the current locks and writes assume a single healthy node.
- Publish reproducible benchmark numbers per level, including the knee and the bottleneck, rather than only a harness. The k6 suite already supports the 100 / 500 / 1,000 VU matrix, open-loop reads, contention, and failure injection against a mock Xendit.
- Exercise refund and late-settlement paths against Xendit in staging, not only against the local mock.
The honest summary: for a real cinema chain, this topology would be over-engineered at small scale. It exists to demonstrate the boundaries, failure modes, and consistency reasoning that a high-contention ticketing product actually needs — and to show those choices can be defended, not just drawn.
7. Conclusion
CinX is built around one invariant: a seat is never sold twice, and money never moves without a matching state change.
The design reaches that through layers rather than trust:
- Redis makes the common case fast and self-expiring.
- PostgreSQL rejects the wrong case.
- Snapshots keep history correct across service boundaries.
- Events decouple confirmation and ticketing without losing the booking.
- Retries and reconciliation make asynchronous failures converge instead of corrupting state.
- Refunds are compensation, not a status flip: seats and tickets follow the money.
The project also demonstrates the less visible half of backend engineering:
typed contracts in a shared package, a stateless BFF, honest 409 versus 503
semantics, idempotent consumers, trace propagation from browser to event, and a
test strategy that includes unit, integration, browser, and load tests.
The result is not a claim of production readiness. It is a working system with documented limits, deliberate trade-offs, and a clear list of what should change before real traffic and real money depend on it.
Further reading
README.md— quick start and booking flowdocs/ARCHITECTURE.md— CAP/PACELC analysis and glossarydocs/ERD.md— full column-level data modeldocs/openapi.yaml— REST contractCONTEXT.md— shared domain languagebenchmark/README.md— k6 scenarios and methodology