llm-driven-system-design / airbnb
CLAUDE.md60 starsChanged 10 months agoMITView on GitHub
A two-sided vacation-rental marketplace: hosts list properties with availability calendars, guests search by location/dates/filters and book, and both sides review each other after a stay. The core hard problems are double-booking prevention (two guests must never confirm the same dates on the same listing) and geographic search with availability filtering (find bookable listings near a point, fast). It is deliberately a single-database design where PostgreSQL + PostGIS carries both the relational and the geo workload. Learning goals: efficient availability-calendar storage,…
Files
Design Airbnb — Development with Claude
Project Context
A two-sided vacation-rental marketplace: hosts list properties with availability calendars, guests search by location/dates/filters and book, and both sides review each other after a stay. The core hard problems are double-booking prevention (two guests must never confirm the same dates on the same listing) and geographic search with availability filtering (find bookable listings near a point, fast). It is deliberately a single-database design where PostgreSQL + PostGIS carries both the relational and the geo workload.
Learning goals: efficient availability-calendar storage, PostGIS radius search, concurrent-booking correctness under row locks, mutual-reveal two-sided reviews, and cache-aside + async-queue patterns around a marketplace core.
Architecture at a Glance (what actually runs)
Three backing services (see docker-compose.yml), each for a distinct access pattern:
| Store | Client lib | Role | Why this one |
PostgreSQL + PostGIS (postgis/postgis:16-3.4) | pg | Source of truth: users, listings, availability_blocks, bookings, reviews, messages, sessions, audit_logs | ACID for booking correctness; GEOGRAPHY(POINT,4326) + GIST index gives ST_DWithin radius search in the same DB — no separate search index to keep in sync |
Redis / Valkey (valkey/valkey:7-alpine) | redis (node-redis v4) | Session cache, cache-aside for listing/availability/search, and queue-consumer idempotency (processed:<eventId> keys, 7-day TTL) | Sub-ms reads on the hot search/listing path; auto-expiring keys make dedup cheap |
RabbitMQ (rabbitmq:3-management) | amqplib | Topic exchange airbnb.events → background workers (booking, notification, analytics), with a dead-letter exchange | Decouples booking latency from notification/analytics work; DLQ + at-least-once + idempotent consumers give reliable async delivery |
Photos are uploaded via multer to local disk (./uploads, served statically) — there is no MinIO/S3 locally. Auth is session-based (bcryptjs hashes, Redis-cached sessions with a PostgreSQL sessions backup), not JWT. The backend is a single Express monolith with route-based separation (auth, listings, search, bookings, reviews, messages); the frontend is React 19 + TanStack Router + Zustand + Tailwind (no charting/map libraries).
Key Design Decisions
1. Date-range availability_blocks, not day-by-day rows
Availability is stored as (start_date, end_date, status) ranges and queried with the PostgreSQL OVERLAPS operator. Day-by-day rows would be ~10M listings × 365 = billions of rows; ranges cut that ~18×. Trade-off given up: updating a partial range (host blocks 3 nights inside an open month) requires split/merge logic in listings.ts rather than a trivial per-day flip.
2. Pessimistic locking (SELECT … FOR UPDATE) for booking correctness
POST /api/bookings opens a transaction, locks the listing row with FOR UPDATE, re-checks for booked/blocked overlaps inside the lock, then inserts the booking and its availability block atomically. This makes the check-then-write race impossible: a concurrent second booking blocks on the row lock and then sees the conflict. Trade-off given up: all booking attempts for one hot listing serialize through its row lock — acceptable because contention per single listing is low (unlike a flash sale on one SKU), and it buys a dead-simple correctness proof versus optimistic-locking retry loops.
3. PostGIS as the primary search engine (locally), Elasticsearch only in the production design
Search runs ST_DWithin against the GIST index with faceted filters (price, property/room type, amenities @>, guest count, bed counts) plus an availability NOT IN (overlapping blocks) sub-select, all in one SQL query. Keeping search in Postgres means zero index-sync machinery. Trade-off given up: no real full-text relevance (location autocomplete is ILIKE), and a single Postgres instance caps search throughput — architecture.md documents Elasticsearch + CDC as the production path when that ceiling is hit.
4. Mutual-reveal reviews enforced by a DB trigger
Both a guest and host review are inserted with is_public = FALSE; a trigger flips both to public only once both author_types exist for the booking, and a second trigger recomputes the listing's denormalized rating/review_count. Putting this in the database, not app code, means no request path can accidentally leak one side's review early. Trade-off given up: there is no 14-day auto-publish for a single-sided review (a real Airbnb rule), so a one-sided review stays hidden forever here.
5. Best-effort async, correctness-first sync
The booking commit is the source of truth; publishing the booking.created event to RabbitMQ is wrapped in try/catch so a queue outage never fails a booking. Consumers dedup on Redis processed:<eventId> and nack-to-DLQ after retries. Trade-off given up: notifications/analytics are eventually consistent and can lag or be dropped if the queue is down — deliberately, because a guest's reservation must not hinge on the notification pipeline.
6. Circuit breaker (Opossum) on the search path
Search and availability calls run through Opossum breakers that fail fast and return a fallback (empty results / assume-unavailable) when Postgres is degraded, instead of piling up slow queries. Trade-off given up: during an open breaker the UI shows degraded/empty results rather than accurate ones — chosen so one slow dependency can't cascade into a full outage.
Current State
Implemented end to end: session auth + become-host flow; listing CRUD with multer photo upload and split/merge availability editing; PostGIS faceted search with result caching + circuit breaker; location autocomplete (/api/search/suggest) and popular destinations; booking with FOR UPDATE double-booking prevention, instant-book vs request-to-book, host confirm/decline, cancellation with date release, completion; two-sided mutual-reveal reviews with trigger-driven rating rollups; host↔guest messaging (HTTP polling); RabbitMQ workers (booking/notification/analytics) with DLQ + idempotency; full observability (Prometheus metrics, Pino logs, audit_logs, /health /ready /live /metrics).
Intentionally omitted (documented in architecture.md Layer 2): payments/Stripe (prices computed, never charged), Elasticsearch, S3 + CDN + image pipeline, WebSocket real-time messaging, ML smart pricing, request rate limiting, single-sided review auto-publish window, and an admin UI (the requireAdmin/role plumbing exists but no dashboard).
Iteration & Repair Log
- 2026-07-13 (search silently ignored the destination):
SearchBar.handleSearch()set only the free-text location with no coordinates, but/api/searchfilters geographically onlatitude/longitude. With no coords the backend applied no location predicate, so "San Francisco" returned every listing — the search box looked broken. The backend already had/api/search/suggest(city→lat/lng) that the UI never called. Fixed by wiring the search bar to it: debounced autocomplete, selection sets lat/lng insearchStore, and free text submitted without a pick is geocoded on submit; unresolvable destinations setlocationUnresolvedso the results page says "We couldn't find X" instead of listing everything. Verified: SF search returns 1 SF listing (was: all 5). - 2026-07-13 (screenshot login / seed password): the screenshot config authenticated as
guest1@example.com; that user is seeded (src/seed.ts), but seeded password hashes across the repo were normalized to the sharedpassword123. Confirmed the five seeded logins (host1/host2/guest1/guest2/admin@example.com) all usepassword123, matching the README credentials table.
Open Questions
- Pessimistic
FOR UPDATEis correct but serializes per-listing writes — at what booking-concurrency per listing (or once a payment-authorize step is added mid-transaction) does this need to become optimistic locking or a short-lived Redis reservation lock? - Availability is filtered in-query with
NOT IN (overlapping blocks)sub-selects; at what listing/booking volume does this need the Elasticsearch 365-day bitmap the production doc describes, and how would CDC keep it consistent with theFOR UPDATEwrite path? - The mutual-reveal trigger never auto-publishes a single-sided review. Adding the 14-day window means a scheduled job — worker cron, or a Postgres
pg_cron-style task? Which keeps the trigger invariant intact? - Cache invalidation on booking wipes all search-result caches for simplicity. What is the right key granularity (by geo tile? by listing?) before that becomes a thundering-herd problem?
Resources
- PostGIS Documentation —
ST_DWithin, GIST indexing - Airbnb: Avoiding Double Payments in a Distributed Payments System — idempotency the production design would extend into payments
- Listing Search Ranking at Airbnb — the ML ranking layer deliberately out of scope here
In this repository94 other files it gives its agents.
- AGENTS.mdAGENTS.md
- 20forms-20designs/CLAUDE.mdCLAUDE.md
- ad-click-aggregator/CLAUDE.mdCLAUDE.md
- ai-code-assistant/CLAUDE.mdCLAUDE.md
- airtag/CLAUDE.mdCLAUDE.md
- amazon/CLAUDE.mdCLAUDE.md
- apns/CLAUDE.mdCLAUDE.md
- apple-maps/CLAUDE.mdCLAUDE.md
- apple-music/CLAUDE.mdCLAUDE.md
- apple-pay/CLAUDE.mdCLAUDE.md
- apple-tv/CLAUDE.mdCLAUDE.md
- app-store/CLAUDE.mdCLAUDE.md
- bitly/CLAUDE.mdCLAUDE.md
- calendly/CLAUDE.mdCLAUDE.md
- CLAUDE.mdCLAUDE.md
- coinbase/CLAUDE.mdCLAUDE.md
- collaborative-editor/CLAUDE.mdCLAUDE.md
- confluence/CLAUDE.mdCLAUDE.md
- dashboarding/CLAUDE.mdCLAUDE.md
- discord/CLAUDE.mdCLAUDE.md
- distributed-cache/CLAUDE.mdCLAUDE.md
- docusign/CLAUDE.mdCLAUDE.md
- doordash/CLAUDE.mdCLAUDE.md
- dropbox/CLAUDE.mdCLAUDE.md
- etsy/CLAUDE.mdCLAUDE.md
- excalidraw/CLAUDE.mdCLAUDE.md
- facetime/CLAUDE.mdCLAUDE.md
- fb-live-comments/CLAUDE.mdCLAUDE.md
- fb-news-feed/CLAUDE.mdCLAUDE.md
- fb-post-search/CLAUDE.mdCLAUDE.md
- figma/CLAUDE.mdCLAUDE.md
- gallery/CLAUDE.mdCLAUDE.md
- github/CLAUDE.mdCLAUDE.md
- gmail/CLAUDE.mdCLAUDE.md
- google-calendar/CLAUDE.mdCLAUDE.md
- google-docs/CLAUDE.mdCLAUDE.md
- google-search/CLAUDE.mdCLAUDE.md
- google-sheets/CLAUDE.mdCLAUDE.md
- health-data-pipeline/CLAUDE.mdCLAUDE.md
- hotel-booking/CLAUDE.mdCLAUDE.md
- icloud/CLAUDE.mdCLAUDE.md
- imessage/CLAUDE.mdCLAUDE.md
- instagram/CLAUDE.mdCLAUDE.md
- jira/CLAUDE.mdCLAUDE.md
- job-scheduler/CLAUDE.mdCLAUDE.md
- kindle-highlights/CLAUDE.mdCLAUDE.md
- leetcode/CLAUDE.mdCLAUDE.md
- linkedin/CLAUDE.mdCLAUDE.md
- local-delivery/CLAUDE.mdCLAUDE.md
- loom/CLAUDE.mdCLAUDE.md
- mcplator/CLAUDE.mdCLAUDE.md
- mdreader/CLAUDE.mdCLAUDE.md
- microsoft-teams/CLAUDE.mdCLAUDE.md
- netflix/CLAUDE.mdCLAUDE.md
- news-aggregator/CLAUDE.mdCLAUDE.md
- notification-system/CLAUDE.mdCLAUDE.md
- notion/CLAUDE.mdCLAUDE.md
- online-auction/CLAUDE.mdCLAUDE.md
- payment-system/CLAUDE.mdCLAUDE.md
- paypal/CLAUDE.mdCLAUDE.md
- The first 60 shown.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

