Проектирование маркетплейса для краткосрочной аренды жилья -- комплексная система, объединяющая поиск, бронирование, платежи, отзывы и коммуникацию.
Шаг 1: Требования
Функциональные требования
- Для гостей: поиск жилья по локации, датам, гостям, фильтрам; бронирование; оплата; отзывы
- Для хостов: создание листинга; управление календарём; ценообразование; приём/отклонение бронирований
- Для обоих: обмен сообщениями; уведомления; профили и верификация
- Платёжная система: выплаты хостам, удержание комиссии, возвраты
Нефункциональные требования
- Latency поиска < 500ms
- Consistency бронирований (no double booking)
- 5M+ листингов, 50M+ пользователей
- Доступность 99.99%
- Multi-region deployment
Шаг 2: High-Level архитектура
┌──────────┐ ┌───────────────┐ ┌──────────────────────────────────────┐
│ Client │───>│ API Gateway │───>│ Microservices │
│ (Web/ │ │ + CDN │ │ │
│ App) │ └───────────────┘ │ ┌──────────┐ ┌───────────────────┐ │
└──────────┘ │ │ Search │ │ Booking │ │
│ │ Service │ │ Service │ │
│ └────┬─────┘ └────────┬──────────┘ │
│ │ │ │
│ ┌────▼─────┐ ┌───────▼──────────┐ │
│ │ Listing │ │ Payment │ │
│ │ Service │ │ Service │ │
│ └──────────┘ └──────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ User │ │ Messaging │ │
│ │ Service │ │ Service │ │
│ └──────────┘ └──────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ Review │ │ Notification │ │
│ │ Service │ │ Service │ │
│ └──────────┘ └──────────────────┘ │
└──────────────────────────────────────┘
Шаг 3: Ключевые компоненты
3.1 Listing Service
<?php
declare(strict_types=1);
final class ListingService
{
public function __construct(
private readonly ListingRepository $repository,
private readonly SearchIndexer $indexer,
private readonly ImageService $images,
private readonly GeocodingService $geocoding,
) {}
public function createListing(CreateListingRequest $request): Listing
{
// 1. Geocode the address
$coordinates = $this->geocoding->geocode($request->address);
// 2. Process and upload images
$imageUrls = [];
foreach ($request->images as $image) {
$imageUrls[] = $this->images->processAndUpload(
$image,
sizes: ['thumbnail', 'medium', 'large'],
);
}
// 3. Create listing
$listing = $this->repository->create(
hostId: $request->hostId,
title: $request->title,
description: $request->description,
propertyType: $request->propertyType,
roomType: $request->roomType,
maxGuests: $request->maxGuests,
bedrooms: $request->bedrooms,
bathrooms: $request->bathrooms,
amenities: $request->amenities,
address: $request->address,
latitude: $coordinates->lat,
longitude: $coordinates->lng,
pricePerNight: $request->pricePerNight,
images: $imageUrls,
houseRules: $request->houseRules,
);
// 4. Index for search
$this->indexer->indexListing($listing);
return $listing;
}
public function updateCalendar(
string $listingId,
string $hostId,
array $dateUpdates,
): void {
$listing = $this->repository->findByIdAndHost($listingId, $hostId);
foreach ($dateUpdates as $update) {
$this->repository->upsertCalendarDate(
listingId: $listingId,
date: new \DateTimeImmutable($update['date']),
available: $update['available'],
price: $update['price'] ?? null,
minNights: $update['min_nights'] ?? null,
);
}
// Update search index with new availability
$this->indexer->updateAvailability($listingId);
}
}
3.2 Search Service
<?php
declare(strict_types=1);
final class ListingSearchService
{
public function __construct(
private readonly ElasticsearchClient $elastic,
private readonly \Redis $redis,
private readonly AvailabilityChecker $availability,
) {}
public function search(SearchRequest $request): SearchResults
{
// 1. Build Elasticsearch query
$query = $this->buildQuery($request);
// 2. Execute search
$esResults = $this->elastic->search([
'index' => 'listings',
'body' => $query,
]);
// 3. Filter by availability (post-search)
$listings = [];
foreach ($esResults['hits']['hits'] as $hit) {
$listingId = $hit['_id'];
// Check availability in PostgreSQL
$isAvailable = $this->availability->isAvailable(
$listingId,
$request->checkIn,
$request->checkOut,
);
if ($isAvailable) {
$priceBreakdown = $this->availability->calculatePrice(
$listingId,
$request->checkIn,
$request->checkOut,
);
$listings[] = array_merge($hit['_source'], [
'total_price' => $priceBreakdown->total,
'price_per_night' => $priceBreakdown->avgPerNight,
'distance_km' => $hit['sort'][0] ?? null,
]);
}
}
return new SearchResults(
listings: $listings,
total: $esResults['hits']['total']['value'],
filters: $this->extractFilters($esResults),
);
}
private function buildQuery(SearchRequest $request): array
{
$must = [];
$filter = [];
// Location (geo search)
$filter[] = [
'geo_distance' => [
'distance' => ($request->radiusKm ?? 25) . 'km',
'location' => [
'lat' => $request->latitude,
'lon' => $request->longitude,
],
],
];
// Guests
$filter[] = ['range' => ['max_guests' => ['gte' => $request->guests]]];
// Property type
if ($request->propertyType) {
$filter[] = ['term' => ['property_type' => $request->propertyType]];
}
// Price range
if ($request->minPrice || $request->maxPrice) {
$priceFilter = [];
if ($request->minPrice) $priceFilter['gte'] = $request->minPrice;
if ($request->maxPrice) $priceFilter['lte'] = $request->maxPrice;
$filter[] = ['range' => ['price_per_night' => $priceFilter]];
}
// Amenities
if ($request->amenities) {
$filter[] = ['terms' => ['amenities' => $request->amenities]];
}
// Star rating / Superhost
if ($request->superhostOnly) {
$filter[] = ['term' => ['is_superhost' => true]];
}
return [
'query' => [
'bool' => [
'must' => $must ?: [['match_all' => new \stdClass()]],
'filter' => $filter,
],
],
'sort' => $this->getSortOrder($request->sortBy, $request->latitude, $request->longitude),
'size' => $request->limit,
'from' => $request->offset,
'aggs' => [
'price_ranges' => [
'range' => [
'field' => 'price_per_night',
'ranges' => [
['to' => 50],
['from' => 50, 'to' => 100],
['from' => 100, 'to' => 200],
['from' => 200],
],
],
],
'property_types' => [
'terms' => ['field' => 'property_type'],
],
],
];
}
}
3.3 Booking Service
<?php
declare(strict_types=1);
final class AirbnbBookingService
{
public function __construct(
private readonly AvailabilityChecker $availability,
private readonly BookingRepository $bookings,
private readonly PaymentService $payment,
private readonly MessagingService $messaging,
private readonly NotificationService $notifications,
private readonly \Redis $redis,
) {}
/**
* Request a booking (may require host approval)
*/
public function requestBooking(BookingRequest $request): BookingResult
{
// 1. Verify availability
if (!$this->availability->isAvailable(
$request->listingId,
$request->checkIn,
$request->checkOut,
)) {
throw new UnavailableException('Dates not available');
}
// 2. Calculate price
$price = $this->availability->calculatePrice(
$request->listingId,
$request->checkIn,
$request->checkOut,
);
// Service fee (guest pays ~14%, host pays ~3%)
$guestFee = round($price->total * 0.14, 2);
$hostFee = round($price->total * 0.03, 2);
$totalCharge = $price->total + $guestFee;
// 3. Pre-authorize payment (hold, not charge yet)
$authResult = $this->payment->authorize(
amount: $totalCharge,
userId: $request->guestId,
);
if (!$authResult->success) {
throw new PaymentException('Payment authorization failed');
}
// 4. Lock dates temporarily
$locked = $this->availability->lockDates(
$request->listingId,
$request->checkIn,
$request->checkOut,
);
if (!$locked) {
$this->payment->cancelAuthorization($authResult->id);
throw new UnavailableException('Dates were just taken');
}
// 5. Create booking record
$booking = $this->bookings->create(
guestId: $request->guestId,
listingId: $request->listingId,
hostId: $request->hostId,
checkIn: $request->checkIn,
checkOut: $request->checkOut,
guests: $request->guests,
subtotal: $price->total,
guestFee: $guestFee,
hostFee: $hostFee,
totalCharge: $totalCharge,
hostPayout: $price->total - $hostFee,
paymentAuthId: $authResult->id,
status: $request->instantBook ? 'confirmed' : 'pending',
);
if ($request->instantBook) {
// Capture payment immediately
$this->payment->capture($authResult->id);
$this->availability->confirmDates($request->listingId, $request->checkIn, $request->checkOut);
} else {
// Notify host for approval (24h timeout)
$this->notifications->send(new NotificationRequest(
userId: $request->hostId,
templateCode: 'booking_request',
channel: 'push',
variables: ['booking_id' => $booking->id],
));
// Set 24h timeout for host response
$this->redis->setex("booking_timeout:{$booking->id}", 86400, '1');
}
return new BookingResult(
bookingId: $booking->id,
status: $booking->status,
totalCharge: $totalCharge,
);
}
/**
* Host approves or declines booking request
*/
public function hostResponse(string $bookingId, string $hostId, bool $approved): void
{
$booking = $this->bookings->findByIdAndHost($bookingId, $hostId);
if ($booking->status !== 'pending') {
throw new InvalidStateException('Booking is not pending');
}
if ($approved) {
$this->payment->capture($booking->paymentAuthId);
$this->bookings->updateStatus($bookingId, 'confirmed');
$this->availability->confirmDates(
$booking->listingId,
$booking->checkIn,
$booking->checkOut,
);
$this->notifications->send(new NotificationRequest(
userId: $booking->guestId,
templateCode: 'booking_confirmed',
channel: 'push',
));
} else {
$this->payment->cancelAuthorization($booking->paymentAuthId);
$this->bookings->updateStatus($bookingId, 'declined');
$this->availability->unlockDates(
$booking->listingId,
$booking->checkIn,
$booking->checkOut,
);
$this->notifications->send(new NotificationRequest(
userId: $booking->guestId,
templateCode: 'booking_declined',
channel: 'push',
));
}
}
}
3.4 Review System
<?php
declare(strict_types=1);
final class ReviewService
{
private const REVIEW_WINDOW_DAYS = 14; // 14 days after checkout
public function __construct(
private readonly ReviewRepository $reviews,
private readonly BookingRepository $bookings,
private readonly ListingRepository $listings,
private readonly SearchIndexer $indexer,
) {}
/**
* Both guest and host can leave reviews
* Reviews are revealed simultaneously (double-blind)
*/
public function submitReview(SubmitReviewRequest $request): Review
{
$booking = $this->bookings->find($request->bookingId);
// Validate review window
$checkOut = new \DateTimeImmutable($booking->checkOut);
$deadline = $checkOut->modify(sprintf('+%d days', self::REVIEW_WINDOW_DAYS));
if (new \DateTimeImmutable() > $deadline) {
throw new ReviewWindowExpiredException('Review window has passed');
}
// Create review (but don't reveal yet)
$review = $this->reviews->create(
bookingId: $request->bookingId,
reviewerId: $request->reviewerId,
revieweeId: $request->revieweeId,
listingId: $booking->listingId,
overallRating: $request->overallRating,
ratings: $request->detailedRatings, // cleanliness, accuracy, etc.
comment: $request->comment,
isPublic: false, // Hidden until both parties review
);
// Check if both parties have reviewed
$bothReviewed = $this->reviews->bothPartiesReviewed($request->bookingId);
if ($bothReviewed) {
// Reveal both reviews simultaneously
$this->reviews->publishReviews($request->bookingId);
// Update listing average rating
$this->updateListingRating($booking->listingId);
}
return $review;
}
private function updateListingRating(string $listingId): void
{
$stats = $this->reviews->getListingStats($listingId);
$this->listings->updateRating(
$listingId,
$stats['average_rating'],
$stats['total_reviews'],
);
// Update search index
$this->indexer->updateRating($listingId, $stats);
}
}
Шаг 4: Payment Flow
Booking Request:
1. Guest -> Platform: Pre-authorize $X
2. Host approves (or auto-approve)
3. Platform captures authorization
After Checkout:
4. Platform holds money for 24h (dispute window)
5. Platform transfers to host: $X - 3% commission
6. If dispute: hold for investigation
Cancellation:
- Flexible: full refund if > 24h before check-in
- Moderate: 50% refund if > 5 days
- Strict: 50% refund if > 14 days
Шаг 5: Масштабирование
| Компонент | Технология | Стратегия |
|---|---|---|
| Search | Elasticsearch | Geo-sharded clusters |
| Listings | PostgreSQL | Sharded by region |
| Bookings | PostgreSQL | Sharded by listing_id |
| Calendar | Redis + PostgreSQL | Redis for availability cache |
| Images | CDN + S3 | Multi-size, lazy resize |
| Messaging | Cassandra | Partitioned by conversation |
Возможные вопросы интервьюера
-
Instant Book vs Request to Book?
- Instant: автоматическое подтверждение (лучше UX)
- Request: хост одобряет (24h timeout, лучше для хоста)
- Instant увеличивает конверсию на ~20%
-
Как обрабатывать конфликты календаря?
- Optimistic locking на calendar dates
- Race condition: первый booking побеждает
- iCal sync для внешних платформ
-
Как предотвратить fraud?
- Верификация документов (ID, phone)
- ML для обнаружения подозрительных листингов
- Escrow: деньги удерживаются до checkout
- Reviews system для trust
-
Как масштабировать поиск с availability check?
- Pre-computed availability bitmap в Redis
- Search -> filter by availability -> paginate
- Периодическая синхронизация bitmap