A lightweight, production-ready RESTful API for finding and exploring hotels: search a catalogue of properties, view a property's full detail, and check which rooms are open for a set of travel dates. Built with Fastify, TypeScript, and Zod.
- Domain glossary:
CONTEXT.md— the ubiquitous language (Property, Room, Stay window, Night, Availability,price_from, …) the code and API share. - Decisions & rationale:
ASSUMPTIONS.mdand the ADRs.
| Choice | Rationale |
|---|---|
| Fastify | Fast, minimal HTTP framework with first-class schema validation and a plugin model that keeps routes, error handling, and docs cleanly separated. |
| TypeScript | Types are the contract. The domain models are inferred from the same schemas that validate data and responses, so drift between model, wire, and docs can't happen. |
| Zod + type-provider | One schema per shape drives validation, response serialization, and the OpenAPI/Swagger docs — a single source of truth instead of three that rot apart. |
| In-memory data store | The catalogue is 40 read-only properties in a JSON seed; a database would add setup cost with no payoff at this scale. It sits behind a repository interface so a real store is a one-file swap — see ADR-0001 and the migration guide. |
| Vitest | Fast unit + integration runner; the app is built un-listened so integration tests drive it via app.inject() without binding a port. |
| Method | Path | Description | Query / path params |
|---|---|---|---|
| GET | /health |
Liveness probe | — |
| GET | /hotels |
Search & filter properties (summary projection) | city, star_rating (1–5), min_price, max_price — all optional, AND-combined |
| GET | /hotels/:id |
Property detail | id (path) |
| GET | /hotels/:id/rooms |
Room availability & pricing for a stay window | id (path); check_in, check_out (YYYY-MM-DD, required, half-open) |
| GET | /docs |
Swagger UI (OpenAPI generated from the schemas) | — |
Full, always-current request/response schemas live at /docs
once the server is running.
# Four-star hotels in London from $200/night
curl 'http://localhost:3000/hotels?city=London&star_rating=4&min_price=200'
# One property's full detail
curl 'http://localhost:3000/hotels/hotel-01'
# Rooms available for a two-night stay (nights 07-10 and 07-11)
curl 'http://localhost:3000/hotels/hotel-01/rooms?check_in=2026-07-10&check_out=2026-07-12'Every response is enveloped: successes as { "data": ... } (with meta on the rooms
endpoint), failures as { "error": { "code", "message" } }.
- Node.js 20 (see
.nvmrc)
npm ci
npm run dev # hot-reloading dev server on http://localhost:3000The service reads its catalogue seed from data/hotels.json at
startup and validates it against the domain schema, so a malformed seed fails fast and
legibly rather than surfacing mid-request.
| Script | Purpose |
|---|---|
npm run dev |
Dev server with hot reload |
npm run build |
Compile TypeScript to dist/ |
npm start |
Run the compiled server |
npm run typecheck |
Type-check without emitting |
npm run lint |
ESLint |
npm test |
Run the test suite once (Vitest) |
npm run verify |
typecheck → lint → test, fail-fast (the quality gate) |
Requests flow through three layers, each with a single job, plus a set of pure functions the layers lean on:
HTTP request
│
▼
routes/ Fastify handlers. Own the wire contract: Zod schemas validate the
│ query/params/body and serialize the response, and map results into
│ the { data } / { error } envelope. No business logic.
▼
domain/ Pure functions over plain domain values — filtering (search.ts),
│ availability & pricing (availability.ts), and the list/availability
│ projections. No I/O, so the rules are exhaustively unit-testable.
▼
repository/ The data-source seam (HotelRepository). Loads & validates the JSON
seed once and serves it from memory. Swapping in Postgres/SQLite
touches only this file (see the migration guide).
Cross-cutting concerns are Fastify plugins: plugins/error-handler.ts
normalizes every failure (Zod validation, thrown AppErrors, framework errors,
unexpected exceptions) into the one { error: { code, message } } envelope, and
plugins/swagger.ts generates the OpenAPI doc from the same
Zod schemas the routes validate against. app.ts composes them and returns
an un-listened instance; server.ts owns listen. The repository is
injected, so tests can supply a fixture catalogue.
The key design move: the domain layer knows nothing about HTTP, and the routes know nothing about the business rules. That keeps the availability logic — the part the brief actually grades — testable in isolation and the data source swappable without touching either.
The catalogue is 40 Properties, each owning one or more Rooms — a single nested aggregate that mirrors the source dataset (no normalization or joins; the Property is the unit of storage and retrieval):
- Property —
id,name,description,star_rating,overall_rating,review_count, nestedaddress/contact/policies,amenities[], androoms[]. - Room —
room_id,type, bedding & occupancy (bed_type,bed_count,max_occupancy,square_footage),price_per_night,room_amenities[], andavailable_dates[]— the discrete open Nights that are the source of truth for availability. - Derived, never stored —
price_from(a Property's cheapest Room rate) for search and list display, plusnights/total_priceon the rooms endpoint. Computing these keeps one authoritative number per fact instead of a denormalized copy that can drift.
The Zod schemas in src/domain/hotel.ts are the single source of
truth for these shapes: they validate the seed at load time (a malformed dataset fails
fast and legibly) and their inferred types are the domain models the rest of the code
programs against — so the model, the wire format, and the OpenAPI docs cannot drift apart.
The wire format stays snake_case end-to-end to match the dataset. The domain vocabulary
(Property, Room, Stay window, Night, price_from, …) is fixed in CONTEXT.md.
npm test runs the suite across two complementary styles:
- Unit tests over the pure domain functions (
availability.test.ts,search.test.ts,hotel-summary.test.ts,repository.test.ts) exercise the rules and their edges directly — nights vs. days, the half-open upper boundary, empty open-dates, per-night set membership vs. range containment, the room-lessprice_from = nullcase — with no HTTP in the loop. - Integration tests (
hotels.test.ts,hotel-detail.test.ts,hotel-rooms.test.ts,health.test.ts,docs.test.ts,error-envelope.test.ts) drive the real app and seed throughapp.inject(), asserting status codes, envelope shapes, and response-schema conformance (responses are parsed back through the Zod schemas), plus the validation boundaries and the standard400/404envelopes.
The seed itself is deliberately built to catch a naive availability reading, so the integration tests double as a check that the real data flows through correctly.
A Husky pre-push hook runs npm run verify (typecheck → lint → test, fail-fast),
so failures surface locally before they reach CI. To bypass it in a pinch:
git push --no-verifyGitHub Actions CI mirrors the same checks on every push and pull request, then builds the Docker image (no registry push).
Multi-stage build producing a slim, dev-dependency-free image that runs as the non-root
node user with a /health HEALTHCHECK:
docker build -t infinite-choice .
docker run --rm -p 3000:3000 infinite-choiceThen hit http://localhost:3000/health.
Known, deliberately-deferred follow-ups — out of scope for this exercise, noted for honesty rather than left as silent debt:
- Extract shared test factories. The
makeRoom/makePropertyfixture builders are currently duplicated acrosstest/availability.test.ts,test/hotel-summary.test.ts, andtest/search.test.ts. Pulling them into a singletest/factories.tswould remove the triplication and stop the defaults from drifting apart as tests grow. A related tidy is folding the repeated unknown-id404guard insrc/routes/hotels.tsbehind a smallgetPropertyOr404helper. - Push filters into the data store. If the catalogue outgrew the in-memory seed, the pure filter predicates would move into SQL and the endpoint would gain pagination — see the migration guide.
This project was built with AI coding-agent assistance (Claude), under human direction, and this section is a transparent account of exactly how — including where AI was not used.
How it was used
- Planning and scaffolding. Work was decomposed into GitHub Issues (foundation, search, detail, availability, hardening, docs), each implemented on its own branch and merged via PR. The agent drafted issues, ADRs, and the domain glossary, which were reviewed and edited before adoption.
- Test-driven implementation. Each endpoint was built test-first: failing unit/integration tests capturing the acceptance criteria, then the implementation to pass them. The availability rule (nights vs. days, half-open window, set membership) was pinned down in tests before any code — see ADR-0002.
- Refactoring and review. Every branch went through an automated code-review pass (standards + spec) before merge; several commits (e.g. "single-source CI gate", "enumerate Stay-window Nights once") are review follow-ups.
- This documentation. The README,
ASSUMPTIONS.md, and the migration guide were drafted from the actual source and then verified against it.
Where judgment stayed human
- The domain interpretations — availability as per-night set membership over a
half-open window,
price_fromas the cheapest room, price filtering independent of dates — are deliberate rulings, recorded as ADRs with their rationale rather than accepted as whatever the happy path produced. - Knowing when not to reach for a tool. Contract testing (e.g. Pact) and a real database were both consciously skipped: there is no second service consuming this API and no persistence requirement, so both would be ceremony without payoff at this scale. The repository seam and schema-driven docs give most of the same guarantees for free. (ASSUMPTIONS.md records these.)
- Every AI-produced change was read, run (
npm run verify), and owned before merge.