Reservvo
Online booking platform — Spring Boot 4 API + Next.js 16 front, with Redis Streams queue and coordinated cache.
Back-end (API)
- Java 25
- Spring Boot 4
- Spring Security
- Spring Data JPA
- Hibernate 7
- PostgreSQL 17
- Redis 7 (Streams + cache)
- JJWT
- BCrypt
- AWS SES
- JUnit 5
- Mockito
- SpringDoc OpenAPI
- Maven
Front-end
- Next.js 16
- React 19
- TypeScript
- Tailwind v4
- Zustand
- TanStack Query v5
- React Hook Form
- Zod
- Axios
- date-fns
Infra & Deploy
- Docker (multi-stage)
- Docker Compose
- Caddy
- Hetzner VPS
- Vercel
Full-screen for best quality.
Context
Reservvo is an online booking platform for small service providers — barbershops, clinics, studios, sports courts. The system covers resource registration, availability per weekday, public booking link for clients and a provider dashboard.
The back-end (Spring Boot 4 + Java 25 + Hibernate 7 + Postgres 17 + Redis 7) implements pagination with eager loading, cache with coordinated invalidation, async queue (Redis Streams) with retry and DLQ, stateless JWT auth, SQL conflict detection, and transactional email via AWS SES.
The front-end (Next.js 16 + React 19) implements paginated filtering, context-based cache invalidation via TanStack Query, multi-role flows (client, provider, both), and explicit separation between client state (Zustand) and server state (TanStack Query).
Technical decisions
API · Redis Streams + DLQ instead of ApplicationEvent
First version used Spring’s ApplicationEventPublisher to fire email after saving the booking. Events live in memory — if the process crashes between save() and send, the notification is lost. No retry, no visibility.
Migrated to Redis Streams with a consumer group. Producer publishes to the stream after persisting; @Async consumer sends via SES and XACKs. On failure, republishes with attempt + 1 until MAX_RETRY_ATTEMPTS = 2. Past that, goes to the Dead Letter Queue (reservvo:notifications:dlq) with timestamp and failure reason.
API · Pagination with JOIN FETCH + explicit countQuery
Paginating reservations loads Reservation → Resource → Provider → User. Without JOIN FETCH, Hibernate generates N+1 — 1 query for Reservation and N for each association.
JOIN FETCH on every association + DISTINCTto dedupe the cartesian. This breaks Spring Data’s automatic count on paginated queries (count with fetch join fails). Fix: separate countQuery in @Query, ignoring joins and counting only by filters. Result: 2 total queries (data + count) with zero N+1.
API · Slots cache with coordinated eviction
GET /api/reservations/slots is the most-called endpoint — clients see available times before booking. The computation fetches AvailabilityRule, iterates slots and calls existsConflict for each. Cached with @Cacheable on Redis, 10-minute TTL.
On creation, @CacheEvictworks directly (key comes from the request). On cancellations, the entity is already loaded from the DB and the annotation can’t resolve the key — manual eviction via CacheManager.getCache("slots").evict(key). In tests, cache is disabled via spring.cache.type=none and @Profile("!test") on RedisConfig.
API · Scheduler that completes expired reservations
A reservation must become COMPLETED once its time slot ends — but no request happens at that exact moment. Relying on the front to trigger the transition is fragile (the user may never come back), and computing it at read time leaves the actual state inconsistent in the database.
A scheduled job with @Scheduled(cron = "0 0 */4 * * *") runs every 4h and performs a bulk UPDATE (markExpiredAsCompleted) inside @Transactional, completing all overdue reservations at once — no N+1 and no external cron, using Spring's own @EnableScheduling, logging the affected count.
Front · Zustand for client state, TanStack Query for server state
Zustand handles client state that persists across pages (JWT token, role, theme, sidebar). TanStack Query handles everything from the API — cache, automatic refetch, in-flight dedup, placeholderData: keepPreviousData for smooth pagination.
Components consume hooks (useProviderReservations, useReservationStats), and the matching mutation invalidates related queries.
Front · Narrow cache invalidation by context
The first version had queryClient.invalidateQueries(["slots"]) on every booking mutation — any create or cancel knocked out every cached slot, of every resource, on every date.
The fix was passing context through the mutation. On cancel, the mutation receives { id, resourceId, date } and invalidates exactly ["slots", resourceId, date] plus the ["reservations"] prefix. Slots of other resources stay intact. Query keys hierarchical from the start: ["reservations", "provider", page, size, status], ["slots", resourceId, date].