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
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.
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.
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.
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 flowThe 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"
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.
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 .protoCodegen 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 coverneeds a complete Go distribution. If your system Go is older than the version ingo.mod, the Go-managed toolchain that gets downloaded in its place ships a trimmed tool set withoutcovdata, 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.
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_cityandto_cityare 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 makebooking_idthe natural identifier and turn the duplicate check into a(train, user)constraint. - In-memory only. The
Repositoryport 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_idonReceiptis deprecated in the proto; receipts are identified by user and allocated seat.