Files
slotline/README.md
T

13 KiB

slotline

A small reservation platform built with Spring Boot. Resources (rooms, courts, equipment) are booked for time slots, and the core guarantee is that a single slot can never be double-booked, even under heavy concurrency.

The project is deliberately focused on the parts that are hard to get right in a booking system: concurrency, transactions, security, and a documented API.

Tech stack

  • Java 21, Spring Boot 3.5
  • Spring Web, Spring Data JPA, Spring Security (stateless JWT)
  • Flyway for schema migrations (vendor-specific: H2 for dev/test, PostgreSQL for deploy)
  • H2 in-memory (dev/test), PostgreSQL (deploy profile)
  • springdoc-openapi for Swagger UI
  • Maven Wrapper (./mvnw) - no local Maven install required

Running it

Prerequisites: a JDK 21. The build uses the Maven Wrapper, so Maven itself does not need to be installed.

# dev profile (H2 in-memory), starts on http://localhost:8080
./mvnw spring-boot:run

# run the test suite (unit + integration + the concurrency test)
./mvnw test

On startup a default ADMIN account is seeded if none exists (see Configuration).

Architecture

Layered, one package per bounded slice:

com.slotline
  config/       SecurityConfig, OpenApiConfig, SlotlineProperties, AdminBootstrap
  security/     JwtService, JwtAuthenticationFilter, AuthenticatedUser, RestAuthenticationHandlers
  user/         User, Role, AuthService, AuthController, dto/
  resource/     Resource, ResourceService, ResourceController, dto/
  reservation/  Reservation, ReservationService, ReservationController, dto/
  common/       domain exceptions + GlobalExceptionHandler (RFC 7807 ProblemDetail)
  • Controllers are thin; all logic and transaction boundaries live in the services.
  • Requests and responses are immutable records; entities never leave the service layer.
  • Errors are returned as application/problem+json (ProblemDetail), including field-level validation violations.

No double-booking under concurrency (the core feature)

Booking a slot runs inside a single transaction in ReservationService.create:

  1. The target resource row is loaded with a pessimistic write lock (SELECT ... FOR UPDATE via @Lock(PESSIMISTIC_WRITE)), which serializes all concurrent bookings for that one resource.
  2. An overlap query looks for any active reservation on the resource where existing.start < new.end AND existing.end > new.start.
  3. If an overlap exists, the booking is rejected with 409 Conflict; otherwise the reservation is inserted.

Because the resource row is locked for the duration of the check-and-insert, two concurrent requests for overlapping slots cannot both pass the overlap check. The lock is released on commit, so the loser sees the committed reservation and gets a clean 409.

Extra layers of defense:

  • @Version optimistic locking on Resource and Reservation protects against lost updates on other concurrent operations (for example a concurrent cancel). Any optimistic-lock failure is also mapped to 409.
  • The PostgreSQL migration adds a partial unique index (resource_id, start_time, end_time) WHERE status = 'ACTIVE' as a database-level backstop in production.

Why pessimistic locking for the booking path rather than optimistic alone: it is deterministic across H2 and PostgreSQL and it correctly rejects arbitrary overlapping slots, not just byte-identical ones. Optimistic @Version is kept as a second layer, and the unique index is the last line of defense in production.

This is verified by ReservationConcurrencyTest: 12 threads are released simultaneously against the same slot and the test asserts exactly one success, eleven 409s, and exactly one active reservation in the database. Removing the pessimistic lock makes the test fail (all 12 succeed), so the test genuinely exercises the guarantee.

API

Authentication is a Bearer JWT obtained from /api/auth/login.

Method Path Access Notes
POST /api/auth/register public creates a USER account (201)
POST /api/auth/login public returns a JWT
GET /api/resources USER, ADMIN list resources
GET /api/resources/{id} USER, ADMIN single resource
POST /api/resources ADMIN create resource (201)
PUT /api/resources/{id} ADMIN update resource
DELETE /api/resources/{id} ADMIN delete resource (204)
POST /api/reservations USER, ADMIN book a slot (201) or 409
GET /api/reservations/mine USER, ADMIN caller's reservations
POST /api/reservations/{id}/cancel owner/ADMIN cancel a reservation
GET /api/resources/{resourceId}/reservations USER, ADMIN active reservations

curl examples

An end-to-end script lives at scripts/smoke.sh (happy path plus the 409 conflict). It expects a running instance:

BASE_URL=http://localhost:8080 ./scripts/smoke.sh

The individual calls:

# log in as the seeded admin
ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"admin12345"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")

# create a resource (ADMIN only)
curl -s -X POST http://localhost:8080/api/resources \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"Court 1","description":"Outdoor tennis court"}'

# register and log in as a normal user
curl -s -X POST http://localhost:8080/api/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"password123"}'
USER_TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"password123"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")

# book a slot -> 201
curl -s -X POST http://localhost:8080/api/reservations \
  -H "Authorization: Bearer $USER_TOKEN" -H 'Content-Type: application/json' \
  -d '{"resourceId":1,"startTime":"2026-09-01T10:00:00Z","endTime":"2026-09-01T11:00:00Z"}'

# book the same slot again -> 409 Conflict
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8080/api/reservations \
  -H "Authorization: Bearer $USER_TOKEN" -H 'Content-Type: application/json' \
  -d '{"resourceId":1,"startTime":"2026-09-01T10:00:00Z","endTime":"2026-09-01T11:00:00Z"}'

Testing

./mvnw test

Suite:

  • JwtServiceTest - token issue/parse round trip and rejection of tokens signed with another key.
  • ReservationApiTest - full HTTP flow through Spring Security: register, login, role enforcement (USER cannot create resources), book, 409 on conflict, cancel, and re-book after cancel.
  • ReservationConcurrencyTest - the concurrency guarantee described above.
  • SlotlineApplicationTests - context loads.

Docker

Multi-stage build (JDK 21 to build, JRE 21 to run):

docker build -t slotline:latest .
docker run --rm -p 8080:8080 \
  -e SPRING_PROFILES_ACTIVE=postgres \
  -e SLOTLINE_DB_URL=jdbc:postgresql://db:5432/slotline \
  -e SLOTLINE_DB_USERNAME=slotline \
  -e SLOTLINE_DB_PASSWORD=secret \
  -e SLOTLINE_JWT_SECRET=please-change-this-to-a-long-random-secret \
  slotline:latest

Without SPRING_PROFILES_ACTIVE=postgres the container runs on the in-memory H2 database (useful for a quick demo, not for real data).

Configuration

All values are overridable by environment variable:

Variable Default Purpose
SLOTLINE_JWT_SECRET dev-only placeholder HMAC signing key (set in prod)
SLOTLINE_ADMIN_USERNAME admin seeded admin username
SLOTLINE_ADMIN_PASSWORD admin12345 seeded admin password (set in prod)
SPRING_PROFILES_ACTIVE (dev / H2) set to postgres for deploy
SLOTLINE_DB_URL localhost postgres JDBC url (postgres profile)
SLOTLINE_DB_USERNAME slotline DB user (postgres profile)
SLOTLINE_DB_PASSWORD slotline DB password (postgres profile)

The dev JWT secret and admin password are for local use only and must be overridden before any real deployment.

Wave 2 (depth)

Wave 2 is additive: the v1 anti-double-booking core (pessimistic lock plus overlap check plus the PostgreSQL partial unique index) is untouched and its concurrency test still passes. New capabilities:

  • Multi-tenant. Every user, resource and reservation belongs to an Organization. The JWT carries an org claim and all resource and reservation queries are scoped to the caller's organization, so a tenant can neither see nor book another tenant's resources (cross-tenant access is a 404). Registration accepts an optional organizationSlug; without one the account joins the seeded default organization.
  • Recurring reservations. POST /api/reservations/recurring takes an RRULE subset (FREQ=DAILY|WEEKLY, INTERVAL, COUNT, UNTIL, BYDAY), expands it into concrete slots in the resource time zone, and books the whole series in one transaction (all-or-nothing: any conflict rolls the series back).
  • Availability engine. Per-resource operating hours, blackout date ranges and a buffer (minutes required between reservations). A booking outside the rules is rejected with 422 Unprocessable Entity; a straight slot clash is still 409.
  • Waitlist. POST /api/reservations/waitlist records interest in a taken slot. When a reservation is cancelled the oldest matching waiter is promoted into a real reservation and notified.
  • Notifications. A NotificationChannel abstraction (email channel logs; webhook channel is enabled only when slotline.notifications.webhook.url is set) fans out on booking, cancellation and waitlist promotion. Fully testable without sending anything by registering a recording channel.
  • Resource groups. Nestable resource-group hierarchy with resource assignment.
  • Admin reports. GET /api/admin/reports/resources/{id}/occupancy?from=..&until=.. and .../history (ADMIN only, tenant-scoped).
  • iCal export. GET /api/resources/{id}/calendar.ics and GET /api/reservations/calendar.ics return an RFC 5545 feed of active slots.

New endpoints (all under the existing JWT security):

Method Path Access
GET /api/organizations ADMIN
POST /api/organizations ADMIN
POST /api/reservations/recurring USER, ADMIN
POST /api/reservations/waitlist USER, ADMIN
GET /api/reservations/waitlist/mine USER, ADMIN
GET /api/reservations/calendar.ics USER, ADMIN
GET /api/resources/{id}/calendar.ics USER, ADMIN
GET/PUT /api/resources/{id}/operating-hours GET all, PUT ADMIN
GET/POST /api/resources/{id}/blackouts GET all, POST ADMIN
DELETE /api/resources/{id}/blackouts/{blackoutId} ADMIN
GET/POST /api/resource-groups GET all, POST ADMIN
POST /api/resource-groups/{groupId}/resources/{resourceId} ADMIN
GET /api/resource-groups/{groupId}/resources USER, ADMIN
GET /api/admin/reports/resources/{id}/occupancy ADMIN
GET /api/admin/reports/resources/{id}/history ADMIN

PostgreSQL migration verification (Testcontainers)

PostgresMigrationTest boots a real PostgreSQL container, applies the postgresql migrations and asserts the partial unique index enforces a single ACTIVE reservation per slot. It self-skips (JUnit assumption) when Docker is not reachable, so the H2 suite always runs.

Non-goals (v1)

Payments, a separate frontend (Swagger UI is the console), notifications, and external calendar sync are intentionally out of scope.