architecture

Designing a hotel reservation API from scratch

What I learned while designing a hotel reservation API for ofertadeldia.com, especially around availability, reservation states, and the parts I got wrong the first time.

When I started building the API service for ofertadeldia.com, I thought the hard part would be writing the endpoints.

It turned out the endpoints were the easy part.

The hard part was understanding the booking domain well enough to design an API that would not fall apart as soon as real reservations started going through it. A hotel reservation system looks simple from the outside. Search for a room, show a price, make a booking, allow a cancellation. But once I started designing the API, each of those actions had more rules hiding underneath than I expected.

This was the first time I had to design a REST API from scratch for a booking use case. I was not extending an existing system. I had to decide what the resources were, what the request and response shapes should look like, how availability should be checked, and what state a reservation should move through before it was considered final.

I made a few good decisions, a few weak ones, and a few that only looked fine until I tried to connect the whole flow together.

The domain model: rooms, availability, pricing, reservations

The first useful thing I did was stop thinking in terms of pages and start thinking in terms of entities.

At the beginning I was designing the API the same way I would think about the frontend:

  • search page
  • room details page
  • booking page
  • cancellation page

That was not a very good way to model the backend. Once I switched to the domain itself, the shape became much clearer. The basic entities were:

  • hotel
  • room
  • availability
  • pricing
  • reservation

The problem was that these were not independent. A room without availability is not bookable. Availability without pricing is not useful. A reservation is really a snapshot of both availability and pricing at the moment a guest commits to it.

That changed how I approached the API. Instead of trying to expose everything as flat CRUD, I started separating read concerns from booking concerns.

A search request needed to answer a very specific question:

For this hotel, for this date range, how many rooms are available, and at what price?

A booking request needed to answer a different one:

Can I still reserve this exact room selection for this guest, and if yes, can I create a reservation record safely?

That distinction sounds obvious now, but I did not start there.

My early version exposed too many low-level details. I was close to making the API feel like a thin wrapper over database tables. That would have been easy for me to build, but not very useful for the actual booking flow.

The better shape was something closer to this:

GET /hotels/42/availability?check_in=2014-07-10&check_out=2014-07-13&guests=2
GET /reservations/9812
POST /reservations
POST /reservations/9812/cancel

That gave me a cleaner mental model. Availability is queried. Reservations are created and changed. Pricing is returned as part of availability and then stored as part of the reservation snapshot.

REST vs something else: why REST made sense

I spent a little time wondering whether I needed something more custom than REST, mainly because booking flows do not always fit simple create-read-update-delete nicely.

In the end I stayed with REST for a practical reason: the operations were still understandable as resources and state changes, and the clients consuming the API did not need anything more complicated.

The important part was not whether I used the word REST perfectly. The important part was whether the contract was predictable.

For example, this felt clear:

POST /reservations

{
  "hotel_id": 42,
  "room_id": 8,
  "check_in": "2014-07-10",
  "check_out": "2014-07-13",
  "guests": 2,
  "customer": {
    "name": "Arun Kumar",
    "email": "[email protected]"
  }
}

And the response could tell the client exactly what happened:

{
  "id": 9812,
  "status": "pending",
  "hotel_id": 42,
  "room_id": 8,
  "check_in": "2014-07-10",
  "check_out": "2014-07-13",
  "total_price": 240.00,
  "currency": "EUR"
}

That was easier to reason about than building one large endpoint that tried to search, validate, price, and reserve all in a single shape with too many flags.

REST also forced me to think about nouns and transitions. That was useful because hotel booking is full of state transitions, and once I started thinking that way, the design got better.

What I did have to be careful about was pretending that everything was a simple resource update. Some actions are really actions. Cancellation is one of them. I could have used PUT /reservations/:id with a new status, but POST /reservations/:id/cancel was much clearer to me and to anyone reading the API.

At least for this system, clarity mattered more than elegance.

Handling availability: the date range problem

This was the part that taught me the most.

A room is not just available or unavailable. It is available for a date range. And that means every availability query is really an overlap query.

The first version I wrote was too naive. I was thinking in single dates, not ranges. That worked for simple checks but broke as soon as I tried to answer the real business question:

Is this room available from check-in to check-out without any conflict?

The actual problem is not “does a record exist for July 10?” The actual problem is “does any existing reservation overlap this requested stay?”

The logic I ended up relying on was basically this:

SELECT *
FROM reservations
WHERE room_id = 8
  AND status IN ('pending', 'confirmed')
  AND check_in < '2014-07-13'
  AND check_out > '2014-07-10'

If that query returned rows, the requested stay overlapped an active reservation.

That one condition taught me more about booking systems than a lot of other code I wrote around it. The tricky part is that date ranges are easy to misunderstand in conversation and easy to get subtly wrong in code.

I also had to be consistent about what check_out meant. In practice, I treated it as the boundary at which the guest leaves, not as a night they still occupy. That matters because a stay ending on July 13 should not block a new stay starting on July 13.

Once that rule was clear, the overlap logic became much less confusing.

Pricing made this harder. Availability by itself is not enough. The API also has to answer:

  • what price applies for those nights?
  • is the price stable enough to book?
  • what do I store on the reservation if pricing later changes?

I learned quickly that I should not calculate the total again later from current pricing rules and assume it matches what the customer saw. The safer design was to calculate the total during booking and store that amount directly on the reservation.

That way the reservation becomes a record of what was agreed to, not a pointer to a price that might have changed afterward.

In PHP, my availability check started looking more like business logic than a thin query wrapper:

function isRoomAvailable($roomId, $checkIn, $checkOut)
{
    $sql = "
        SELECT COUNT(*) AS total
        FROM reservations
        WHERE room_id = :room_id
          AND status IN ('pending', 'confirmed')
          AND check_in < :check_out
          AND check_out > :check_in
    ";

    $result = db_fetch_one($sql, array(
        ':room_id' => $roomId,
        ':check_in' => $checkIn,
        ':check_out' => $checkOut,
    ));

    return (int) $result['total'] === 0;
}

Simple code, but it forced a precise decision about what “available” actually means.

Booking states and idempotency

The biggest design improvement came when I stopped treating a reservation as either existing or not existing.

That was too binary for a real booking flow.

A reservation has a lifecycle. For ofertadeldia.com, the smallest useful state machine I could see was:

  • pending
  • confirmed
  • cancelled

That already solved a few problems.

When a booking request first comes in, the system can create a reservation as pending. That gives me a record to work with before I decide the booking is final. Once the validation and any downstream confirmation step succeeds, the reservation moves to confirmed. If the user cancels or the booking cannot be completed, it moves to cancelled.

This looked roughly like:

function createReservation($data)
{
    if (!isRoomAvailable($data['room_id'], $data['check_in'], $data['check_out'])) {
        throw new Exception('Room not available');
    }

    $reservationId = insertReservation(array(
        'room_id' => $data['room_id'],
        'check_in' => $data['check_in'],
        'check_out' => $data['check_out'],
        'status' => 'pending',
        'total_price' => $data['total_price'],
    ));

    if (confirmReservation($reservationId)) {
        updateReservationStatus($reservationId, 'confirmed');
    }

    return getReservation($reservationId);
}

This was still rough, but it was better than writing directly into confirmed and hoping nothing failed in the middle.

The other issue I hit was duplicate requests.

This one is easy to miss when you first design an API. A client sends a booking request. The server starts processing it. The network is slow, or the response gets lost, or the client times out. The user clicks again. Now I have two booking requests for the same room, same dates, same guest.

If the API has no protection, I can create duplicate reservations very easily.

I did not think enough about this on the first pass.

What I started doing afterward was treating booking creation as something that needed an idempotency key or at least some client-generated request token. If the same logical request arrives twice, the API should not create two reservations. It should return the result of the first one.

Even a simple PHP approach like this felt much safer:

function createReservationWithRequestId($requestId, $data)
{
    $existing = findReservationByRequestId($requestId);

    if ($existing) {
        return $existing;
    }

    return createReservation(array_merge($data, array(
        'request_id' => $requestId,
    )));
}

I would not call that a perfect solution, but it was the moment I started realizing that API correctness is not only about clean URLs. It is also about surviving retries and half-failed requests.

What I got wrong the first time

The clearest mistake was designing too close to the database instead of the booking flow. I was exposing things because they existed in my schema, not because they were meaningful to the client. That made the API harder to consume and harder to evolve. When you design endpoints as thin wrappers over tables, the API reflects your storage rather than your domain, and the domain is what the client actually needs to interact with.

The second mistake was underestimating how much complexity hides inside availability. I thought of it as a lookup problem: does a room record exist. It is really a time-range validation problem tied to active booking state. That difference matters. One is a SELECT. The other is an overlap query against confirmed reservations, which requires knowing exactly what “active” means and what “overlap” means in a date context.

The third mistake was not making the reservation lifecycle explicit enough from the beginning. I had reservations in the database without a clear model of what states they could be in and what transitions were allowed. That led to some awkward cleanup later. If I had started with pending, confirmed, and cancelled as first-class ideas before writing the first endpoint, the rest would have been cleaner.

And the biggest mistake, trusting the happy path. When I designed the booking endpoint, I was imagining the clean version: client sends request, server validates, reservation created, response returned. That is the path that works in development. The real path has retries after network timeouts, availability that changes between search and booking, parts of the flow that fail after the first write. If the API doesn’t account for those, the bugs aren’t obvious during development but they show up quickly in production. I spent too much time worrying about whether the API looked clean and not enough time asking whether it behaved safely when things went wrong.

What changed after this project

If I restarted this API today, today being 2014, not some hypothetical future where I have years more experience, I would define the reservation state machine before writing the endpoints. That sounds obvious in retrospect, but I had to learn it by shipping something that didn’t have a clear state model and then cleaning it up.

I would also define the availability rule in exactly one place and make every booking path use that same function. The rule I ended up with, check in before check out, check out after check in, no overlap with confirmed or pending reservations, isn’t complicated. But having it scattered or duplicated is how subtle inconsistencies creep in.

The thing that changed most wasn’t a specific technique. It was that I started thinking about an API as a boundary around business rules rather than a way to expose backend functionality over HTTP. Those sound similar but they lead to different decisions. A boundary has contracts. It has state it’s responsible for. It has failure modes it needs to handle. When I started thinking that way, the endpoint design got better almost automatically.

This was the first backend project where I genuinely had to think about what the domain model should be before I could write the code. In Magento work, the domain was already given to me, products, orders, carts. Here I had to figure out what the booking domain meant and then build an API that reflected that meaning. That was harder than I expected, and more useful.