Welcome to the Train Seat Reservation API!
This is a learning project that demonstrates modern approaches to building backend applications on the .NET platform. The goal is to deliver a reliable, testable, and easily scalable train seat reservation service using the principles of Clean Architecture and Domain-Driven Design (DDD).
The project is a good fit for studying design patterns, CQRS, domain events, concurrent access, and microservice infrastructure.
The problem domain is modeled in two layers.
Physical layer β long-lived rolling stock that rarely changes:
- Train β a physical trainset; reused across many trips. Aggregate root.
- Wagon β a car of a train, with a class (
FirstClass/SecondClass/Bistro). Internal entity of the Train aggregate. - Seat β a physical seat in a wagon, identified by its number within the wagon. Internal entity of the Train aggregate.
Logical / booking layer β per-trip availability and bookings:
- Trip β a specific run of a train on a route at a given date and time. Aggregate root.
- TripSeat β the availability and price of a specific seat on a specific trip (status:
Available/Reserved/Sold). A separate aggregate root, isolated fromTripso that pessimistic locking can target it independently β see ADR-0005. - Reservation β a booking entity linking a passenger to one to four trip seats. Aggregate root.
- ReservationSeat β internal entity of a Reservation, with a
PriceSnapshotfield that fixes the price at booking time.
Identity:
- User β a thin local cache of Auth0 identity (email, full name). Keyed by an internal Guid
Idwith a uniqueAuth0Subcolumn. See ADR-0002.
Full ERD and column-level details live in docs/database-schema.md.
Key business rules enforced in the domain:
- Idempotency and concurrency: A trip seat cannot be booked if its status is already
ReservedorSold. Implementation details are covered in the π Concurrency strategy section. - Reservation TTL: A reservation lives for exactly 15 minutes. If its status has not moved to
Confirmed, it is cancelled automatically by a background job, and the status of its trip seats flips back toAvailablevia a domain event. - Limits: A reservation must include between 1 and 4 trip seats.
- Time validation: A reservation cannot be created for a trip that has already departed. A reservation cannot be confirmed after its 15-minute window has expired (race-safety against the expiry job). A reservation cannot be cancelled less than 24 hours before the trip's departure.
- Pricing: The seat class (the
SeatClassvalue object) encapsulates the business logic for final price calculation. Class is attached at the wagon level and inherited by seats. TheReservation.TotalPriceandReservationSeat.PriceSnapshotare denormalized at booking time so later price changes don't retroactively rewrite existing bookings.
- Clean Architecture: Strict separation into layers. The Domain has no external dependencies β no MediatR, no EF Core, no FluentValidation, only the .NET BCL.
- CQRS via MediatR: Commands are isolated from queries. Repetitive infrastructure plumbing (UnitOfWork, uniform handling of domain exceptions) is extracted into base classes
CommandHandler<T>andQueryHandler<T>, so concrete handlers contain only pure business logic. An alternative approach using Pipeline Behaviors is discussed in the backlog. - Domain Events:
IDomainEventlives in Domain as a marker interface;DomainEventNotification<T>in Application wraps it for MediatR. Events are dispatched by overridingSaveChangesAsyncin EF Core (see ADR-0004 for the no-PII rule). - Result pattern: No exceptions for business logic. The domain uses
Result<T>andError(withErrorTypefor HTTP-status mapping at the API layer). Exceptions remain only for programmatic-contract violations (null parameters, invalid Guids), thrown byGuard.Against.*clauses. TimeProviderfor time-dependent logic: Aggregates that reason about time (Trip,Reservation) acceptTimeProvideras a parameter on factory methods and operations. Tests substituteFakeTimeProviderfromMicrosoft.Extensions.TimeProvider.Testing. See ADR-0003.
- Database: Microsoft SQL Server 2022, with migrations extracted into a separate console project.
- Caching: Redis for frequent queries (for example,
GetTripSeatsQuery). The cache is invalidated by domain events when seat state changes. - Background jobs: An
IHostedServicethat automatically checks for expired reservations every minute. - Authentication: Auth0 (cloud-hosted JWT validation via JWKS). A thin local
Usertable cachesemailandfull name, keyed by an internal GuidIdwith a uniqueAuth0Subcolumn. Populated lazily by middleware on the first authenticated request. See π Auth0 setup. - Logging: Serilog (structured logging).
Seat booking is the central contention point in the system: multiple users click on the same seats of a popular trip at the same time. For that load profile, we use pessimistic row-level locking in SQL Server on the TripSeats table:
- Lock hints:
WITH (UPDLOCK, ROWLOCK, HOLDLOCK)on theSELECTinside the reservation transaction.UPDLOCKβ an intent-update lock: it blocks anyone else who wants to modify the row, while still allowing plain SELECTs.ROWLOCKβ forces row-level locking instead of page/table level.HOLDLOCKβ holds the lock until the transaction ends (the equivalent of SERIALIZABLE range locks, but scoped to the selected rows).
- Isolation level:
ReadCommitted(the default) together with the hints above. We do not raise the global isolation level to SERIALIZABLE, to avoid blocking anything unnecessary. - Deterministic lock order: trip seats are locked with
ORDER BY TripSeatIdβ this is critical to prevent deadlocks when two requests compete for overlapping sets of seats (recall the 4-seat limit per reservation). - Deadlock policy: we catch SQL Server error 1205 and retry up to 3 times with exponential backoff via Polly. If the conflict persists, the client gets a 409 Conflict.
- Lock timeout:
SET LOCK_TIMEOUT 3000at the command level, so a connection is not held indefinitely. - Supporting index:
IX_TripSeats_TripId_Statusbacks both the hot-path availability query (GET /trips/{id}/seats) and the lockedSELECTinside the reservation command.
This approach gives strict correctness under the moderate contention expected at the level of individual trips. Horizontal scaling is possible through sharding by TripId if the need arises.
The project is split into logical modules to keep coupling loose:
π¦ TrainBooking.sln
β£ π src
β β£ π TrainBooking.Domain # Entities, Value Objects, IDomainEvent, Domain Errors/Results, Guard clauses
β β£ π TrainBooking.Application # Commands, Queries, Handlers, FluentValidators, IRepository interfaces
β β£ π TrainBooking.Infrastructure # EF Core DataContext, Redis integration, MediatR adapters, Serilog
β β£ π TrainBooking.Api # Endpoints, Global Exception Handler, Result -> HTTP extensions
β β π TrainBooking.Migrations # EF Core Migrations runner (Console App)
β£ π tests
β β π TrainBooking.Tests # Architecture Tests (NetArchTest), Unit Tests, Integration Tests (Testcontainers)
β π docs # ERD, ADRs, supporting documentation
The scope is deliberately fixed. We are not adding payment gateways, user registration, or email notifications at this stage. All development revolves around 5 endpoints and 1 background job:
Write (Commands):
POST /reservationsβ create a new reservation.PUT /reservations/{id}/confirmβ confirm payment/reservation.PUT /reservations/{id}/cancelβ cancel a reservation on the user's behalf.
Read (Queries):
GET /trips/{id}/seatsβ get the list of available seats (cached in Redis).GET /reservations/{id}β get the status of a specific reservation.
Background:
- A job that checks for and expires reservations (TTL > 15 min).
Ideas for extending the functionality are welcome, but they first go into the backlog so they do not block the MVP release.
The project is fully containerized. Infrastructure is brought up via Docker Compose.
Requirements
- .NET 10 SDK
- Docker Desktop
- A dev tenant on Auth0 (free up to 25k MAU)
Steps
- Clone the repository.
- Configure Auth0 (see π Auth0 setup).
- In the root directory, run:
docker compose up -d(this starts SQL Server and Redis). - Run the
TrainBooking.Migrationsproject to apply the database schema. - Run
TrainBooking.Api. The Scalar UI will be available athttps://localhost:5001/scalar.
- Create a dev tenant on Auth0.
- Under Applications β Applications, create an Application of type Regular Web Application (or Machine-to-Machine for service-to-service tests).
- Under Applications β APIs, create an API with an identifier (audience), for example
https://train-booking-api. In Permissions, define the scopes:reservations:read,reservations:write. - In
appsettings.Development.json, configure:"Auth0": { "Domain": "your-tenant.eu.auth0.com", "Audience": "https://train-booking-api" }
- The JWT Bearer pipeline pulls the JWKS automatically from
https://{Domain}/.well-known/openid-configurationβ signature validation does not require manual key setup. - Scopes from the token are mapped to policies via
[Authorize(Policy = "reservations:write")].
Local user cache. Auth0 is the source of truth for identity. We keep a thin User table keyed by an internal Guid Id with a unique Auth0Sub column, caching email and full name so reservation reads don't need to call the Auth0 Management API on every request. On the first authenticated request, an EnsureUserCache middleware looks up the user by the sub claim from the JWT, inserts a new row with a fresh Guid if missing, and exposes the resulting internal User.Id to handlers. A LastSyncedAt column allows periodic refresh if profile data drifts. Decoupling the internal Id from Auth0Sub means changing the identity provider later does not require migrating every foreign key.
- Unit tests: pure tests of the domain and handlers, with no infrastructure.
xUnit+Shouldly. Tests substituteFakeTimeProviderfromMicrosoft.Extensions.TimeProvider.Testingfor any time-dependent logic. - Architecture tests:
NetArchTestβ verifies that Domain does not reference Infrastructure, that every handler inherits from the base class, that domain events implementIDomainEvent, and so on. - Integration tests:
WebApplicationFactory+Testcontainers(Testcontainers.MsSql,Testcontainers.Redis) β a real SQL Server and Redis are spun up in Docker on every run. No in-memory database substitutes.
Before submitting a Pull Request, please make sure all tests (Unit, Integration, Architecture) pass.
This section honestly documents the MVP's compromises. Every item here is a deliberate decision, not an oversight.
- Domain events are dispatched inside
SaveChangesAsync, without an Outbox. If a handler fails after the commit, the event is lost (for example, Redis invalidation does not happen β stale cache until the TTL expires). For production-grade reliability, moving to a Transactional Outbox + a dedicated worker is on the backlog. - The Redis cache is eventually consistent. The baseline defense is a short TTL (60 sec) as a fallback in case event-driven invalidation does not fire.
- Idempotency for
POST /reservationsvia anIdempotency-Keyheader (+ Redis with a 24-hour TTL) β not in the MVP, but on the backlog. Without it, client retries on a network timeout will produce duplicate reservations. - Base handler classes vs. Pipeline Behaviors. The current approach with
CommandHandler<T>/QueryHandler<T>reduces boilerplate through inheritance. The alternative β a set ofIPipelineBehavior<TRequest, TResponse>implementations (Logging / Validation / Transaction) β is the more idiomatic MediatR path and gives better composition of cross-cutting behavior. A likely refactor once the domain stabilizes. - The retry policy is limited to deadlocks (error 1205). Other transient SQL Server failures are not retried automatically β for production this should be extended via Polly combined with the
Microsoft.Data.SqlClienttransient error detector. TripSeatsare generated per trip (denormalization). Creating a newTripmaterializes aTripSeatrow for every seat of the linked train. This simplifies locking and availability queries but duplicates rows across trips. For a longer booking horizon (years of trips across many routes), revisiting with table partitioning or a lighter-weight availability model is the next step. See ADR-0001.- Local
Usercache is lazy and eventually consistent with Auth0. Profile changes made in Auth0 (email, name) don't propagate to our DB until the next refresh window. Acceptable for MVP; a webhook-driven sync is the next step if it matters. UserandEntityBaseaudit fields useDateTime.UtcNowdirectly instead ofTimeProvider. Doesn't affect correctness β only test determinism. Migration to TimeProvider is on the backlog.- No strongly-typed IDs (yet). All identifiers are raw
Guid. The genericEntity<TId>keeps the door open for migration later via a source generator likeStronglyTypedId. See the trade-off discussion in the foundation PR.
This is a learning project that evolves iteratively. Its goal is to reinforce the patterns of Clean Architecture, DDD, CQRS, domain events, and concurrent access in SQL Server.