ADR-0001 ships an in-memory catalogue
behind a HotelRepository interface precisely so a real database can be dropped in later
without touching domain or route code. This guide documents exactly how that swap goes —
the design intent made concrete, even though only the in-memory implementation ships.
Everything depends on one contract, src/repository/hotel-repository.ts:
export interface HotelRepository {
findAll(): Property[];
findById(id: string): Property | undefined;
}Routes read app.repository (decorated in src/app.ts); the domain
layer takes plain Property[] and never touches a data source at all. So a migration is
scoped to:
- Add a new implementation of
HotelRepository(e.g.postgres-hotel-repository.ts). - Change one line in
app.tsto construct it instead of the in-memory one.
No route, schema, or domain file changes. The propertySchema in
src/domain/hotel.ts stays the single source of truth for a
Property's shape.
The in-memory methods are synchronous. A real database is I/O, so the interface would become async:
export interface HotelRepository {
findAll(filters?: HotelFilters): Promise<Property[]>;
findById(id: string): Promise<Property | undefined>;
}The route handlers already run inside async Fastify callbacks, so this is a mechanical
change: const property = app.repository.findById(id) becomes
const property = await app.repository.findById(id). The in-memory implementation would
wrap its returns in Promise.resolve(...) to keep satisfying the same interface, so the
tests that inject a fixture repository keep working unchanged.
Today filterHotels and priceFrom (in src/domain/search.ts)
run in application code over the full array. That was a deliberate choice for a 40-row
seed, and the functions are written so the move into SQL is mechanical: each predicate
maps to a WHERE clause. With a real store you'd pass the filters into the repository and
let the database do the work:
| Domain predicate | SQL push-down |
|---|---|
city (case-insensitive exact) |
WHERE lower(city) = lower($1) |
star_rating (exact) |
AND star_rating = $2 |
min_price / max_price over price_from |
AND price_from BETWEEN $3 AND $4 (see the price_from note below) |
| (future) pagination | LIMIT / OFFSET — the seam is where this would land, per ASSUMPTIONS.md |
price_from is a derived value (the min room rate), so in a relational schema it's either
a correlated sub-select, a JOIN … GROUP BY MIN(price_per_night), or a maintained column
/ materialized view if read volume warrants it. The pure priceFrom function stays useful
as the canonical definition and for any in-app fallback.
A Property has nested address, contact, policies, amenities[], and rooms[]
(with available_dates[]). Two shapes fit:
- Relational (Postgres/SQLite):
properties,rooms(FK → property), and either aroom_available_datestable (room_id,night DATE) or adate[]column on Postgres. Address/contact/policies can be columns onpropertiesor1:1tables. Availability then becomes a query — a room is available when the count of its open nights intersecting the requested window equals the number of nights requested — but the repository can still return fully-hydratedPropertyobjects so the domain code is unaffected. - Document/JSONB: store each
Propertyas a JSONB blob mirroringpropertySchema. Closest to the current seed; least migration effort; filters use JSONB operators.
Whichever shape, the repository's job is to rehydrate rows back into Property objects
that satisfy propertySchema — validate on the way out just as loadCatalogue does
today, so a drifted database fails loudly at the boundary rather than deep in a request.
SQLite (e.g. via better-sqlite3) is the lightest step up and can stay synchronous,
so the interface wouldn't even need to go async — the swap really is just a new
HotelRepository implementation plus one line in app.ts. Good for a single-node
deployment or embedding the catalogue as a file artifact.
For Postgres (e.g. pg or an ORM), add a connection pool, run migrations to create the
schema, and seed from data/hotels.json once. Wire the pool's lifecycle into Fastify
(register as a plugin; close it in onClose) so connections are cleaned up on shutdown.
The async interface above applies.
- New
*-hotel-repository.tsimplementingHotelRepository. - Validate rows back through
propertySchemaon read. - (Postgres) make the interface async;
awaitthe three call sites inhotels.ts(onefindAll, twofindById). - Push
HotelFiltersinto the query; keeppriceFromas the canonical definition. - Swap the constructor in
app.ts; leave routes, schemas, and domain untouched. - Point the injected test repository at the same interface — existing tests should pass.