Eight Mile · Case study · Stadium ticketing

Stadium ticketing that cannot sell a seat twice.

TicketOps runs match-day ticketing for football clubs in Azerbaijan: seat maps, carts with a hold timer, card checkout, PDF and QR tickets pushed to the gate systems, a box office that prints, and the dashboard that runs it all. This is how it is built.

Stack

NestJS · Postgres 16

Size

~160k lines

Tests

4,000+

Languages

az · en · ru

the system

one deployment per club

Fans · webstorefrontFans · appFlutterBox officesells · printsStaffdashboardAPINestJS · FastifyPostgres 16seats · orders · outboxGatewayhosted checkoutGate systemsgrant · revokeEmailticket to the fanPDF · QRChromium

The brief

Three audiences, one seat map, and match day.

The customer is football clubs in Azerbaijan. Each sells its matches and season tickets through its own deployment of TicketOps, in Azerbaijani, English and Russian.

Three kinds of people use it. Fans buy on the web storefront or in the app. The box office sells and prints at the ground. Staff build the events, the seat maps and the prices, and run the day from a dashboard.

Demand does not arrive evenly. A fixture goes on sale and the best seats are clicked by many fans in the same second — exactly when a system that reads a seat and then writes it will sell it twice.

Cart hold

3 minutes

Payment window

15 minutes

Channels

Web · app · box office

Languages

az · en · ru

The rebuild

A year on Express and MongoDB, then rebuilt before launch.

TicketOps spent its first year, from September 2025 to August 2026, on Express and MongoDB. Its correctness under concurrency rested on conditional updates in Mongo, and its business rules were spread across middleware and model hooks with no seam a test could hold.

On 30 August 2026 it moved to NestJS 10 on Fastify, PostgreSQL 16 and Drizzle, in one change of about 60,000 lines added and 19,000 removed. Nothing was live yet, so there was no data to migrate. Transactions and SELECT … FOR UPDATE made the compare-and-set property stronger, and Fastify’s in-process requests put the real controllers, guards and database under every end-to-end test.

It is contracts first: the routes, their zod schemas and the table mapping each domain error to an HTTP status live in one shared package that the API, the web app and the tests all import. And it is decided in writing: every structural choice, starting with the rebuild itself, is an architecture decision record.

Commits

697

Before the rebuild

528

Rebuilt

30 Aug 2026

Decision records

68

Architecture

One monorepo, one deployment per club.

The API, the web app and the shared packages are one pnpm workspace, so a change to a contract and the code on both sides of it land in one commit. The Flutter app and the browser QA suite sit beside it in their own repositories.

Each club is its own deployment: an image built with that club’s configuration and look, its own Docker Swarm stack behind nginx, and its own database. The container applies its own migrations when it starts, so an image and the schema it expects cannot drift apart.

Tables

30

Migrations

28

API routes

148

Runbooks

16

ticketops · pnpm workspace

apps/

api

NestJS 10 on Fastify. Eight modules — catalogue, identity, inventory, notifications, orders, platform, reporting, ticketing — each split into domain, application and infrastructure.

client

Next.js 16, React 19 and Mantine 8: the storefront, checkout, the customer area and the staff dashboard.

packages/

contracts

The zod schemas, the route registry and the error-to-status table. The one place a request’s shape is defined.

domain

The vocabularies, the entities and each club’s settings.

time · utils

Date handling and the small helpers everything shares.

beside it/

app

The Flutter app, one build per club.

qa

Java, Selenium and axe: the browser suite the pipeline runs.

The seat

Four states, and one statement per move.

Nothing in the codebase assigns a seat a status. Every change is a transition from one table, run as a compare-and-set UPDATE whose WHERE clause names the state the seat must already be in, and the caller checks how many rows actually moved.

Reading a seat and then writing it leaves a gap between the two for another fan to fall into. One statement leaves none: of twenty simultaneous reserves, one moves the row, and nineteen move nothing and are told the seat has gone. Season tickets take the other shape — their candidate seats are read with FOR UPDATE, so a second season purchase waits for the first rather than choosing the same seats.

A test holds it. Twenty carts reserve one seat at once against a real PostgreSQL, and the test passes only if exactly one gets a 200, nineteen get a 409, and one cart item exists afterwards.

Simultaneous reserves

20

Holds

1

Refused (409)

19

Season tickets

FOR UPDATE

event seat · every arrow is one compare-and-set UPDATE

reservepreparefinaliserelease · expirerollbackunsellFREEon saleHELDin one cartPENDINGin one orderSOLDticket issued

Money

The server owns the price.

The amount an order asks for is the server’s, and what the gateway reports it charged is compared against it. The comparison is in whole minor units: a database decimal comes back as "50.00" while a callback may carry 50, "50.0" or "50", so both are turned into integers first, and anything that will not parse counts as a mismatch.

Payment happens on the gateway’s hosted page, 3-D Secure included, inside a fifteen-minute window so a fan answering their bank’s challenge does not lose the seat.

Settlement runs in one transaction that begins by locking the order row, so two callbacks for the same order are decided one after the other, never side by side — a test fires two at once and expects one completion. A callback that does not report success is checked with the gateway before anything is decided.

Amounts

Minor units

Checkout

Hosted · 3-D Secure

Payment window

15 minutes

Settlement

Row-locked

Getting tickets to the gate

An outbox between the sale and everything after it.

The transaction that completes an order also writes an order.completed row to an outbox table. Nothing downstream — the tickets, the email, the gates — can happen without the sale, and the sale cannot commit without leaving the message.

A worker leases due messages with FOR UPDATE SKIP LOCKED, so an overlapping run takes different rows. A failed message backs off from ten seconds, doubling to a one-hour cap; after eight attempts it stops and is raised as a critical notice on the staff dashboard.

The fan gets a PDF ticket with a QR code; the gate systems get a grant, and a cancelled order sends a revoke, so a refunded ticket stops opening the turnstile. The ticket itself is laid out by staff on a canvas measured in millimetres and rendered to PDF through Chromium. Apple Wallet passes are implemented, and are switched on for a club once its signing certificate is issued.

Lease

SKIP LOCKED

Backoff

10 s → 1 h

Attempts

8

Gate actions

Grant · revoke

Staff and access

A route nobody listed is a route nobody can call.

Every staff route is listed in the shared registry beside the role it needs. A route with no entry is denied rather than allowed, so a new endpoint is closed until somebody decides who may call it.

Sign-in is a JWT backed by a server-side session: a token is valid only while its session row exists, so signing out or revoking a session takes effect on the next request. Fans can also sign in with a six-digit code sent by email, or with Google or Facebook.

The box office prints tickets at the counter, and every print is recorded. A reprint is kept rather than overwritten — who printed it again, and when — because that is exactly the event a box office needs to be able to see.

Unlisted route

Denied

Sessions

Server-side

Email codes

6 digits · 5 min

Prints

Every one recorded

The app

A native seat map, one build per club.

The Flutter app does what the storefront does — browse a club’s matches and seasons, pick seats, hold them under the cart timer, pay, and carry the tickets — with the stadium map drawn natively from the venue plan rather than shown in a web view.

Tickets live in the app as pass cards. Each club gets its own build, name and icon from the one codebase, and the app carries its own test suite of more than 1,500 tests.

Framework

Flutter

Seat map

Drawn natively

Builds

One per club

Tests

1,534

Quality

Tests before the test site, the test site before the clubs.

More than 4,000 automated tests across five suites. The API’s coverage gate is 80% and its last recorded run was at 93%. Nothing reaches a club until the browser suite has completed a purchase on the test site built from the same image, and the axe accessibility walks are checked on every pipeline.

Suite

What it covers

Tests

API

1,526

Unit, integration and end-to-end, against a real PostgreSQL

Web app

1,527

Components, forms, flows and the dashboard

Shared packages

259

Contracts, domain, time and utils

Mobile app

1,534

Widgets, state and the seat map

Browser QA

13

Purchase journeys and axe accessibility walks

the pipeline · each stage waits on the one before

01

Tests

API on a throwaway Postgres 16, web app, packages

02

Test site

The same image, deployed with payments off

03

Browser QA

Selenium purchase journeys gate the release

04

Clubs

Each club’s deployment, from main only

Behind the project

Built by Eight Mile in London, as part of our software development work — the same engineers who build and run systems like it for clients.

Your problem next

Tell us what is wrong. We will tell you what it would take.

A straight answer about whether we can help, what we would build, and roughly what it would cost.

Talk to an engineer