Reservvo
Plataforma de agendamento online — API Spring Boot 4 + front Next.js 16, com fila Redis Streams e cache coordenado.
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 para maior qualidade.
Contexto
Reservvo é uma plataforma de agendamento online para prestadores de serviço — barbearias, clínicas, estúdios, quadras. O sistema cobre cadastro de recursos, disponibilidade por dia da semana, link público para clientes agendarem e painel para o prestador gerenciar reservas.
O back-end (Spring Boot 4 + Java 25 + Hibernate 7 + Postgres 17 + Redis 7) implementa paginação com eager loading, cache com invalidação coordenada, fila assíncrona (Redis Streams) com retry e DLQ, autenticação stateless por JWT, detecção de conflito via SQL, e emails transacionais via AWS SES.
O front-end (Next.js 16 + React 19) implementa paginação com filtro, invalidação de cache por contexto via TanStack Query, fluxos com múltiplos papéis (cliente, prestador, ambos), e separação explícita entre estado de cliente (Zustand) e estado de servidor (TanStack Query).
Decisões técnicas
API · Redis Streams + DLQ no lugar de ApplicationEvent
Primeira versão usava ApplicationEventPublisher do Spring para disparar email após salvar reserva. Eventos vivem em memória — se o processo cai entre o save() e o envio, a notificação se perde. Sem retry e sem visibilidade.
Migração para Redis Streams com consumer group. Producer publica no stream após persistir; consumer @Async envia via SES e dá XACK. Em falha, republica com attempt + 1 até MAX_RETRY_ATTEMPTS = 2. Estourou, vai para Dead Letter Queue (reservvo:notifications:dlq) com timestamp e razão da falha.
API · Paginação com JOIN FETCH + countQuery explícito
Listar reservas paginadas carrega Reservation → Resource → Provider → User. Sem JOIN FETCH, Hibernate gera N+1 — 1 query para Reservation e N para cada associação.
JOIN FETCH em todas as associações + DISTINCT para desduplicar. Isso quebra o count automático do Spring Data em queries paginadas (count com fetch join falha). Solução: countQuery separado no @Query, ignorando os joins e contando só pelos filtros. Resultado: 2 queries totais (data + count) com zero N+1.
API · Cache de slots disponíveis com eviction coordenada
GET /api/reservations/slots é o endpoint mais chamado — cliente vê horários antes de reservar. A consulta busca AvailabilityRule, itera slots e chama existsConflict para cada um. Cache com @Cacheable no Redis, TTL 10 min.
Na criação, @CacheEvict resolve direto (key vem do request). Em cancelamentos, a entidade já está carregada do banco e a annotation não resolve a key — eviction manual via CacheManager.getCache("slots").evict(key). Em testes, cache é desabilitado via spring.cache.type=none e @Profile("!test") no RedisConfig.
API · Scheduler que finaliza reservas expiradas
Uma reserva precisa virar COMPLETED quando seu horário termina — mas nenhum request acontece nesse instante. Depender do front pra disparar a transição é frágil (o usuário pode nunca voltar), e calcular no momento da leitura deixa o estado real inconsistente no banco.
Job agendado com @Scheduled(cron = "0 0 */4 * * *") roda a cada 4h e faz um UPDATE em lote (markExpiredAsCompleted) dentro de @Transactional, finalizando todas as reservas vencidas de uma vez — sem N+1 e sem cron externo, usando o @EnableScheduling do próprio Spring, com log da quantidade afetada.
Front · Zustand pra estado de cliente, TanStack Query pra servidor
Zustand cuida de estado de cliente que precisa persistir entre páginas (token JWT, role, tema, sidebar). TanStack Query cuida de tudo que vem da API — cache, refetch automático, dedup de requests em flight, placeholderData: keepPreviousData para paginação suave.
Componentes consomem hooks (useProviderReservations, useReservationStats), e a mutation correspondente invalida as queries relacionadas.
Front · Invalidação de cache narrow por contexto
Primeira versão tinha queryClient.invalidateQueries(["slots"]) em todas as mutations de reserva — qualquer criação ou cancelamento derrubava todos os slots cacheados, de qualquer recurso, em qualquer data.
A correção foi passar contexto pela mutation. Ao cancelar, a mutation recebe { id, resourceId, date } e invalida exatamente ["slots", resourceId, date] além do prefixo ["reservations"]. Slots de outros recursos ficam intactos. Query keys hierárquicas desde o início: ["reservations", "provider", page, size, status], ["slots", resourceId, date].