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).
- Swagger UI: http://localhost:8080/swagger-ui.html
- OpenAPI JSON: http://localhost:8080/v3/api-docs
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:
- The target resource row is loaded with a pessimistic write lock
(
SELECT ... FOR UPDATEvia@Lock(PESSIMISTIC_WRITE)), which serializes all concurrent bookings for that one resource. - An overlap query looks for any active reservation on the resource where
existing.start < new.end AND existing.end > new.start. - 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:
@Versionoptimistic locking onResourceandReservationprotects against lost updates on other concurrent operations (for example a concurrent cancel). Any optimistic-lock failure is also mapped to409.- 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 anorgclaim 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 optionalorganizationSlug; without one the account joins the seededdefaultorganization. - Recurring reservations.
POST /api/reservations/recurringtakes 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 still409. - Waitlist.
POST /api/reservations/waitlistrecords interest in a taken slot. When a reservation is cancelled the oldest matching waiter is promoted into a real reservation and notified. - Notifications. A
NotificationChannelabstraction (email channel logs; webhook channel is enabled only whenslotline.notifications.webhook.urlis set) fans out on booking, cancellation and waitlist promotion. Fully testable without sending anything by registering a recording channel. - Resource groups. Nestable
resource-grouphierarchy 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.icsandGET /api/reservations/calendar.icsreturn 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.