Portfolio

Service booking backend

Bookwise

Bookwise is the API behind a home-services marketplace: a customer books a service like a deep clean or AC repair, picks a time, and the platform assigns an available professional to the job. The customer never browses or hand-picks a provider. They choose a service and a slot, and the system automatically assigns a professional. I built it to spend my time on the parts of a booking system that are genuinely hard.

  • Spring Boot 4
  • Java 21
  • PostgreSQL
  • JWT
  • Caffeine

What it is

A personal project, built deliberately

This is a solo project I built to go deep on backend engineering with Spring Boot, and its modern practices. I treated that as a license to be deliberate: build a focused set of capabilities properly instead of a wide pile of half-finished features.

The domain is a service marketplace inspired by the Urban Company model. Customers book offerings, the platform auto-assigns a professional who can do that offering and is free at the requested time, and the booking moves through a lifecycle from requested to completed. Professionals publish availability windows; admins manage the catalog. The whole thing is live on Render with a Postgres database on Neon, and the API is browsable through Swagger.

Role
Solo build
Type
Backend REST API
Status
Live on Render
Focus
A few hard problems, deeply

Architecture

One application, one database

The Spring Boot app is organized in clear layers: an API layer that handles HTTP, authentication and validation; a service layer that owns the domain logic and transaction boundaries; and a persistence layer over Spring Data JPA. The cache is Caffeine, running in-process inside the JVM. Observability runs across all of it through Spring Actuator, structured JSON logs, and a correlation id on every request.

Bookwise container view: API clients talk over HTTP and JWT to the Bookwise Spring Boot app, which is split into API, service, persistence and Caffeine cache layers over Postgres on Neon.
Fig 1. Container view

The core problem

The booking problem: double booking under concurrency

The naive approach checks whether a professional is free, then inserts the booking. Between those two steps, a second request can run the same check and see the same professional as free. Both pass, both insert, and now one professional is double-booked. It is the oldest kind of race condition, and you usually do not see it until real traffic hits.

Booking-race sequence: two customers POST bookings concurrently; the booking service runs findAssignable, Postgres returns and row-locks professional P for the first request, the second request finds P skipped and either claims another professional or returns 409.
Scroll horizontally to view.
Fig 2. Booking-race sequence.

Bookwise closes the gap with a single query that does the work atomically. findAssignable finds a professional who can do the offering, has an availability window covering the slot, and has no conflicting booking, then locks that professional’s row with FOR NO KEY UPDATE before the booking is inserted. A second request running at the same time hits the locked row, SKIP LOCKED steps over it, and that request either claims a different free professional or comes back empty and returns a 409.

The reason this holds is two mechanisms working together. When the requests are sequential, the overlap filter does the job: the second request sees the first booking already committed and excludes that professional. When the requests are genuinely concurrent, the overlap filter cannot help, because the first booking is not committed yet and is invisible to the second query. In that case the row lock is what saves it. The guarantee is that no single professional is ever double-booked, with fan-out to other pros when the pool has room and a 409 only when it is genuinely exhausted.

Design decisions

The choices that mattered

Decision

Pessimistic locking for the claim, optimistic for the lifecycle

Problem. Concurrent bookings race between checking a professional is free and inserting the booking.

Options.
  • Optimistic locking with a version column and retries
  • A pessimistic row lock
  • A Postgres exclusion constraint enforcing non-overlap in the database

Decision. A pessimistic write lock on the professional row at claim time, paired with the overlap query. Booking status changes use optimistic locking with a version column instead.

Why. When many requests contend for one popular professional, optimistic retries thrash: every loser re-reads and collides again. A pessimistic lock serializes that hot path once and deterministically. Status transitions are the opposite case. They rarely collide, so a version column is the lighter, lock-free fit. The exclusion constraint is the strongest database-level answer and the move at real scale, but I kept the guarantee in the application to stay portable across databases.

Decision

Auto-assignment by least-loaded, claimed with SKIP LOCKED

Problem. Someone has to choose which professional gets a booking.

Options.
  • Let the client send a professional id
  • Round-robin assignment
  • Assign the least-loaded eligible professional

Decision. The platform assigns. findAssignable filters to eligible, free professionals and orders them by fewest active bookings, then claims the top one with FOR NO KEY UPDATE SKIP LOCKED.

Why. Least-loaded spreads work fairly instead of piling it on whoever sorts first. SKIP LOCKED is what makes a pool useful under concurrency: if the best professional is mid-claim by another request, the next request skips them and takes the next-best rather than blocking. The same mechanism gives both outcomes, a clean fan-out when the pool has room and a fast 409 when it is genuinely exhausted.

Decision

Ownership in the query, not in an annotation

Problem. A customer should only see and act on their own bookings, a professional only theirs, an admin everything.

Options.
  • Annotate methods with @PostAuthorize and check ownership after loading
  • Use @PreAuthorize
  • Scope ownership directly in the repository query

Decision. The query carries the ownership predicate. A customer fetch is findByIdAndCustomerId, not a load-then-check.

Why. @PostAuthorize loads the row first and denies afterward, which leaks existence (a 403 tells you the record exists) and quietly does nothing useful for collection endpoints. Scoping in the query means a non-owned row is never loaded in the first place, lists are filtered for free, and there is one consistent rule everywhere. I dropped method-level security entirely once this was the pattern.

Decision

One identity, shared primary key

Problem. A login account and a domain profile are different things. AppUser handles authentication; Customer and Professional hold the domain data. They need to be the same identity without duplicating it.

Options.
  • Bridge them on a shared email string
  • Add a foreign key column
  • Share the primary key with @OneToOne and @MapsId

Decision. Customer and Professional borrow their primary key from the AppUser they belong to via @MapsId.

Why. The profile id is the user id, so there is no separate foreign key to keep in sync and no duplicated email living in two tables. One row is the source of identity, and the relationship is a true one-to-one rather than a convention I have to defend.

What I left out

Knowing where to stop

A lot of the judgment in this project is in what I chose not to build. They are decisions, each with a reason and a clear answer for when they would change.

Redis
The cache is in-process Caffeine. For a single instance that is the right amount of caching. Redis earns its place when you run multiple instances and need a shared cache, which is also the same moment the in-JVM row lock stops being enough and the locking has to move into the database. The two cross that line together.
A Postgres exclusion constraint
The database-native way to guarantee no overlapping bookings, and the honest answer to how this holds at scale. I document it but kept the guarantee at the application level to stay portable rather than tie the schema to Postgres-specific features.

If this were carrying real load, the next tier is the usual one: an outbox for reliable event publishing, a reconciliation job to catch invariant drift, and rate limiting at the edge. I can describe that ceiling, but I did not draw boxes for things the project does not yet need.

Stack

Built with

  • Spring Boot 4.0.6

    Framework

  • Java 21

    Language

  • PostgreSQL (Neon)

    Source of truth

  • Spring Data JPA / Hibernate

    Persistence

  • Spring Security + JWT

    Stateless auth

  • Caffeine

    In-process cache

  • Spring Actuator

    Observability

  • SpringDoc / Swagger

    Live docs

  • Docker

    Containerization

  • Render

    Hosting

Bookwise was built with a deliberate purpose. The point never was to ship a marketplace, it was to take the two or three problems in a booking system that are actually hard and solve them in a way I can go deep on.

Back to projects