Skip to content

Repository files navigation

Booking Service

A gRPC service that sells train tickets, written to explore one deceptively simple requirement: a seat is never sold twice.

Booking is a read-then-write — find the free seats, pick one, mark it taken — which is exactly the shape that breaks under concurrency. Two buyers both see seat 7 free, and both get it. Everything interesting in this repository follows from taking that one invariant seriously: where the lock goes, what the tests have to prove, and what the API returns when the answer is "no".

┌───────────┐   gRPC    ┌────────────────┐        ┌──────────────────┐
│  client   │ ────────► │  TicketService │ ─────► │   Repository     │
└───────────┘           │   (handlers)   │        │  (in-memory)     │
                        └────────────────┘        └──────────────────┘
                          proto ↔ domain            the invariant
                          error ↔ status code       lives here

The interesting part

CreateBooking holds a single write lock across the whole decision — user lookup, duplicate check, seat search, allocation, and the map writes:

func (ir *InMemoryRepository) CreateBooking(user *models.User, ...) (*string, error) {
	ir.mu.Lock()
	defer ir.mu.Unlock()

	user = ir.addOrFetchUser(user)

	_, ok := ir.userTicket[user.ID()]
	if ok {
		return nil, exceptions.ErrUserHasPurchasedTicketAlready
	}

	seat, allocationErr := ir.allocateSeat() // find + claim, still under the lock
	...
}

Narrowing that lock to just the map writes would look like a harmless optimisation and would reintroduce the bug: the gap between "seat 7 is free" and "seat 7 is mine" is the whole problem. internal/repositories/in.memory/in_memory_repository_test.go exists to make that regression fail loudly rather than silently:

Spec What it pins down
200 buyers, 20 seats Every seat sells exactly once; the other 180 get ErrNoSeatsAvailable. Seats are then walked to prove no seat has two holders.
50 concurrent retries, 1 buyer A double-click or a client retry yields exactly one ticket and consumes exactly one seat.
Serial exhaustion Capacity is fully sold before anyone is refused — the refusal is real, not premature.

All tests run under -race, in CI too. Removing the lock fails them; it does not merely make them flaky.

These tests earned their keep immediately: the retry spec failed on first run and exposed a bug where a returning buyer kept a zero-value ID, so the duplicate check looked up the empty key. Every buyer could purchase a second ticket, stored under "" where no receipt lookup could reach it — and the next repeat buyer was then refused because that key was taken by someone else. It reproduced serially once the test pointed at it.

Architecture

Ports and adapters, so the storage decision stays reversible and the rules stay testable without a server:

Layer Path Responsibility
Transport pkg/transport gRPC server lifecycle, health service, graceful drain
Handlers internal/handlers proto ↔ domain translation, error → gRPC status mapping
Services internal/app/services use cases: purchase, receipt, seat map, modify, remove; coupons
Domain internal/domain Repository and Service ports, models, domain errors
Adapters internal/repositories in-memory implementation behind the port

Handlers never reach past the service layer, and nothing below handlers imports the generated protobuf types except where the domain genuinely mirrors the wire enums.

Errors are mapped, not leaked. Domain errors are sentinels in internal/exceptions; handlers translate each to the gRPC code that matches its meaning — AlreadyExists for a duplicate purchase, FailedPrecondition when the train is full, InvalidArgument for a bad section or coupon, Internal for anything unexpected. Clients can branch on the code without parsing strings.

API

Five RPCs on booking.v1.TicketService (booking/v1/booking_service.proto):

RPC Purpose
PurchaseTicket Buy a seat, optionally applying a discount coupon
GetReceipt Fetch a booking by user email
ViewSeatMap List seats and occupancy for a section
ModifySeat Move a user to a specific free seat
RemoveUser Cancel a booking and release the seat

The train has two sections (A and B) of inmemory.seats seats each — 20 seats in total by default. Coupons are a strategy per discount (DISCOUNT_FIVE, DISCOUNT_TEN), so adding one is a new type rather than a new branch.

A gRPC health service is registered, so grpc_health_probe or a Kubernetes grpc probe works without extra wiring.

Running it

Requires Go 1.25+ (golang.org/x/net, via gRPC, sets that floor). Everything else is fetched into ./bin on first use.

make run                    # start the gRPC server on :50051
make build                  # build ./bin/booking-server and ./bin/booking-client
./bin/booking-client        # drive the server through a scripted purchase flow

The server logs its startup and drains cleanly on SIGINT/SIGTERM:

level=info msg="starting grpc server on :50051"
level=info msg="shutdown signal received"
level=info msg="grpc server stopped gracefully"

Configuration

Defaults → optional config.yaml → environment (an optional .env is loaded for convenience and never overrides a real variable). All files are optional by design, so the same binary runs from a checkout or from a container image with nothing but environment variables.

Setting Env var Default
Server host SERVER_HOST localhost
Server port SERVER_PORT 50051
Seats per section INMEMORY_SEATS 10
Repository REPOSITORY_TYPE in-memory
Log level LOG_LEVEL info
Log format LOG_FORMAT text (or json)

Configuration is resolved once and passed as arguments. No package reads the environment on its own, which is what makes the logger and the repository constructible in a test without mutating process state.

Development

make check        # vet + lint + race tests: everything CI runs
make test         # race tests only
make cover        # coverage summary
make lint         # golangci-lint (pinned, auto-installed)
make proto        # regenerate gRPC code from the .proto

Codegen plugins are pinned in tools.go and installed into ./bin, so generated code is reproducible rather than dependent on whatever is on your PATH.

make cover needs a complete Go distribution. If your system Go is older than the version in go.mod, the Go-managed toolchain that gets downloaded in its place ships a trimmed tool set without covdata, and coverage across packages that have no tests fails. Installing Go 1.25 system-wide resolves it; CI is unaffected because it installs the full distribution.

Scope and limitations

Deliberately out of scope — this is a focused exercise, not a product:

  • One train, implicitly. There is no train, journey or departure entity — the repository holds a single seat map, and that map is the train. from_city and to_city are recorded on the receipt but never influence allocation, so two passengers booking unrelated journeys compete for the same twenty seats. This is also why a user may hold only one ticket: with a single inventory, "one ticket per user" and "one ticket per user per train" are the same rule, and every operation can key on email rather than on a booking ID. Modelling trains would make booking_id the natural identifier and turn the duplicate check into a (train, user) constraint.
  • In-memory only. The Repository port exists and the factory selects on type, but there is one implementation. Storage is per-process and resets on restart.
  • Single process. The invariant is enforced by a mutex, which holds for one server and stops holding the moment you run two. A second replica needs a shared authority — row locks, a lease with fencing tokens, or a single-writer partition — and that is a different design, not a bigger lock. Note that adding trains would not help here: partitioning by train reduces contention, but a single train served by two replicas still double-books.
  • No hold-then-confirm. Real booking systems reserve a seat for a few minutes before payment. That introduces expiry races, idempotency keys and reconciliation, none of which are modelled here.
  • No auth, no payments, no persistence of receipts beyond process memory.
  • booking_id on Receipt is deprecated in the proto; receipts are identified by user and allocated seat.

About

gRPC seat-booking service in Go that never sells the same seat twice — concurrency-proven under the race detector.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages