Skip to content
Sanyam Jain
Back to home

2022 – 2023 · Self-directed · Distributed systems

Event-driven ticketing on Kubernetes

A self-directed build I took seriously: six TypeScript services on GKE coordinating through NATS Streaming, with per-service Mongo, optimistic concurrency, Stripe in the loop, and a CI/CD pipeline that actually deploys.

4 min read·
  • TypeScript
  • Kubernetes
  • NATS
  • MongoDB
  • Stripe
  • Event-driven
Source

The brief

A self-directed build I took seriously: a production-shaped ticket marketplace — list a ticket, lock it on order, charge the buyer, release the lock if the buyer ghosts. Standard product story. The interesting part wasn't the product. It was the architecture.

I wanted to see what it actually takes to run an event-driven system end-to-end: six services on Kubernetes, per-service databases, durable event delivery, optimistic concurrency to keep the model honest under races, Stripe in the loop, and a CI/CD pipeline shipping it to Google Kubernetes Engine. No tutorial shortcuts. No mocks where the real thing was the point.

Google Kubernetes Engine · ingress-nginx · Skaffold dev loopingress-nginxhost-based routing · TLS terminationauthExpress · TSticketsExpress · TSordersExpress · TSpaymentsExpress · TSexpirationExpress · TSmongomongomongomongoNATS Streamingdurable subscriptions · queue groups · ack on commitStripeexternalclient (SSR)Next.js
Six services on GKE behind ingress-nginx. NATS Streaming carries domain events between the four services that need to coordinate; auth stays stateless and JWT-only. Each service owns its own Mongo — no shared database.

How the pieces fit

Six services, one bus. Auth, tickets, orders, payments, expiration, and a Next.js SSR client. Each service owns its own MongoDB — no shared database — and every cross-service step travels through NATS Streaming with durable subscriptions and queue groups. ingress-nginx terminates TLS at the edge and routes by host.

Auth stays off the bus. It issues JWTs and lets every other service verify them statelessly. No login event published. No "user created" listener on the other side. Keeping auth flat made the rest of the system replayable without touching identity.

Orders is the coordinator. It listens for ticket lifecycle events to keep its local read model fresh, publishes order lifecycle events for the other services to react to, and accepts inbound payment-confirmation events to flip status. That centrality is the reason optimistic concurrency on the order model was non-negotiable.

clientordersticketsNATSpaymentsPOST /api/orderspublish: order:createddeliver: order:createddeliver: order:createdPOST /api/payments (token)publish: payment:created (→ NATS)deliver: payment:created → status=completeHTTP / RESTNATS event
The happy-path purchase flow. Solid lines are HTTP; dashed lines are NATS events. The orders service never calls the tickets or payments services directly — every cross-service step travels through the bus, so each consumer can be rebuilt or replayed independently.

The hard parts

Optimistic concurrency on a shared aggregate. When two consumers process events for the same order out of order, the naive last-write-wins behaviour silently corrupts state. Every model has a version field, every save asserts the version it saw, and out-of-order updates retry against the latest. Mongoose @save-version plugin pattern, applied uniformly across services.

Per-service databases mean per-service migrations. Every service ships its own Mongoose models, and its own data lives in its own DB namespace. No foreign keys. No JOINs across boundaries. Cross-service queries became "publish, listen, project into a local read model" — which is exactly the discipline event-driven systems are supposed to enforce.

The expiration service is the boring hero. It exists to do one thing: when an order:created event arrives, schedule a Bull job; if the order isn't paid before the deadline, publish expiration:complete so orders can flip status and tickets can release the lock. A small Redis-backed service that earns its place the first time you imagine doing this with cron and a polling loop.

Event surface per service, counted from the codebase. Orders is the coordinator — it talks to the most peers, which is why optimistic concurrency on its model matters. Auth stays stateless and off the bus entirely.

Durable subscriptions with queue groups. NATS Streaming queue groups give you horizontal scale (multiple replicas, one delivery) plus durable subscriptions (replay from last-acked when a consumer restarts). The naming convention — one queue group per service per event — is the kind of detail you only learn after watching a duplicated event corrupt your data once.

Stack

  • RuntimeNode.js + TypeScriptExpress on every service
  • DataMongoDB · per-serviceMongoose, optimistic concurrency
  • BusNATS Streamingdurable subs · queue groups
  • PaymentsStripewebhook + token charge
  • SchedulingBull + Redisexpiration service
  • Edgeingress-nginx on GKEhost-based routing
  • BuildDocker · per-serviceshared base image
  • Dev loopSkaffoldwatch + sync + redeploy
  • CI/CDGCP Cloud Buildauto-deploy on main
  • ClientNext.js (SSR)shares cookie auth
Production-shaped from the ground up. The interesting decisions were 'don't share a database' and 'don't call services directly' — everything else followed.

What this build taught me

Microservices is not a deployment topology. It's a contract about who owns what state, and what happens when that state is wrong.

  • Per-service databases are the load-bearing decision. Every other microservices pattern — events, retries, idempotency, eventual consistency — exists because you committed to this one. Skip it, and the rest is theatre.
  • Version every aggregate. Optimistic concurrency is cheap to add and free to ignore until the day a race silently corrupts a record. By that day, you can't add it without a migration. Add it on day one.
  • Idempotency is a code-review checklist, not a runtime feature. Every event handler has to be safe to deliver twice. If a reviewer can't answer "what happens if we redeliver this" in one sentence, the handler isn't ready.
  • CI/CD that actually deploys is a teacher. Pushing to GKE on every main commit forced me to take secrets, image hygiene, and rollout health seriously in a way local development never would.

Why this is on the site

I didn't ship this to users. It runs because I made it run — six services, a CI pipeline, a real Stripe key in a real environment, a real Mongo cluster per service. What I got out of it is the muscle memory for the parts of a distributed system that don't show up in any tutorial: the contract you have to keep with your future self when state is spread across boxes.

That's why it's here. It's the case study where my backend instincts come from.