Scala の不動産プラットフォームのアーキテクチャ
などの最新の不動産プラットフォーム ジロウ, 理想主義者, イモビリアレ.it e 右に移動 何百万もの広告を管理し、 毎月数十億件の検索と代理店、MLS からのリアルタイム データ ストリーム (複数リストサービス)および個人所有者。持続可能なシステムを設計する この負荷には、スタックの各層で正確なアーキテクチャ上の決定が必要です。 データモデリングから地理空間研究、マルチメディアパイプラインからシステムまで リアルタイムの通知。
この記事では、PropTech プラットフォームの完全なアーキテクチャを大規模に構築します。 コード例を使用して各コンポーネントを分析する TypeScript e パイソン、生産現場で実証されたテクノロジーと設計パターンの比較。
何を学ぶか
- ドメイン分割を使用した不動産プラットフォーム用のマイクロサービス アーキテクチャ
- 完全なデータ モデル: プロパティ、リスト、エージェント、トランザクション、メディア
- Elasticsearch、PostGIS、H3 による高度な地理空間検索
- 異種ソースからの広告取り込みパイプライン (MLS/RESO Web API、スクレイピング、XML フィード)
- メディア パイプライン: 画像処理、間取り図、3D バーチャル ツアー
- リアルタイム機能: 通知、メッセージング、価格アラート
- 大規模なパフォーマンス戦略: キャッシュ、CDN、シャーディング、CQRS
- コンプライアンス: GDPR、公正な住宅、地域規制
不動産プラットフォームの展望
アーキテクチャを設計する前に、競争環境と競争環境を理解することが不可欠です。 技術的な選択の指針となるビジネスモデル。各プラットフォームには独自の組み合わせがあります。 インフラストラクチャの決定に直接影響を与える機能。
| プラットフォーム | 市場 | アクティブな広告 | 特徴 | 既知のスタック |
|---|---|---|---|---|
| ジロウ | アメリカ合衆国 | 約 1 億 3,500 万の物件 | Zestimate (ML 評価) | Java、Kafka、Elasticsearch |
| 理想主義者 | EU (ES、IT、PT) | ~180 万の広告 | インタラクティブな地図検索 | Java、Solr、PostgreSQL |
| イモビリアレ.it | イタリア | ~120 万の広告 | 自動評価、ヒートマップ | PHP/Go、Elasticsearch |
| 右に移動 | UK | ~100 万の広告 | Draw-a-search(エリア描画) | .NET、SQLサーバー、Azure |
| レッドフィン | アメリカ合衆国 | ~1億件の物件 | 統合エージェント、3D ツアー | Java、React、カフカ |
収益化モデルと技術的影響
ビジネス モデルはアーキテクチャに直接影響します。プラットフォーム スポンサー付きリスティングによるフリーミアム 広告配信エンジンの必要性、A/B インプレッションのテストと追跡。モデル 鉛ベースの ルーティングが必要です エージェントへのインテリジェントなコンタクトと統合された CRM。モデル トランザクション的な (iBuying) には、ML 評価パイプライン、管理が含まれます 財務および高度な規制コンプライアンス。
- スポンサー付きリスティング: 広告ランキング エンジン、入札システム、ROI の分析が必要
- 代理店のサブスクリプション: 階層管理、定期請求、エージェントダッシュボード
- 見込み客の発掘: リードスコアリング、インテリジェントなルーティング、属性追跡
- トランザクション (iBuying): ML 評価モデル、オファー管理、法的パイプライン
- 代理店向け SaaS: CRM統合用のマルチテナント、ホワイトラベル、API
高レベルのアーキテクチャ
はしご型不動産プラットフォームが採用したアーキテクチャ ドメイン指向のマイクロサービス、 各サービスは独自のデータベースを持ち、非同期イベントを通じて通信します。このアプローチ より大きな負荷の下でコンポーネントを個別にスケーリングできます (通常は検索と API) パブリック)、あまり求められていないサービスには影響を与えません。
基本原則: ストラングラー フィグ パターン
ほとんどの不動産プラットフォームはモノリスとして始まります。マイクロサービスへの移行 ~を通じて徐々に起こる ストラングラーフィグパターン: サービスが最初に抽出されます 高負荷 (研究、メディア) で、その後徐々に残りの負荷をかけて、モノリスの機能を維持します。 移行全体を通して。
マイクロサービスへの分解
| サービス | 責任 | データベース | パターン |
|---|---|---|---|
| リスティングサービス | CRUD 広告、検証、公開ワークフロー | PostgreSQL + PostGIS | CQRS、イベントソーシング |
| 検索サービス | 全文検索、フィルター、地理空間、ファセット | エラスティックサーチ/オープンサーチ | 読み取りモデル (CQRS) |
| ユーザーサービス | 認証、プロファイル、設定、保存済み | PostgreSQL | OAuth 2.0 / OIDC |
| エージェントサービス | エージェントのプロフィール、代理店、評価、空き状況 | PostgreSQL | ドメインサービス |
| メディアサービス | アップロード、処理、最適化、CDN | S3 + DynamoDB (メタデータ) | 非同期パイプライン |
| メッセージングサービス | エージェントとユーザーのチャット、情報リクエスト、通知 | MongoDB / カサンドラ | WebSocket + イベント駆動型 |
| 通知サービス | 電子メール、プッシュ、SMS、価格アラート、新しい広告 | Redis + PostgreSQL | ファンアウト、テンプレート エンジン |
| 分析サービス | エージェントの訪問、ヒートマップ、レポートの追跡 | クリックハウス / BigQuery | イベントストリーミング |
| インジェストサービス | MLS、XML フィード、外部 API からのインポート | ステージング DB + キュー | ETLパイプライン |
| 査定サービス | ML 価格推定、比較対象、市場動向 | フィーチャー ストア + モデル レジストリ | ML パイプライン |
アーキテクチャ図
+------------------+
| API Gateway |
| (Kong / Envoy) |
+--------+---------+
|
+--------------------+--------------------+
| | |
+--------v------+ +--------v------+ +--------v------+
| Listing | | Search | | User |
| Service | | Service | | Service |
| (PostgreSQL) | | (Elasticsearch)| | (PostgreSQL) |
+--------+------+ +---------------+ +--------+------+
| |
| Events (Kafka / NATS) |
+--------------------+-------------------+
| | |
+--------v------+ +--------v------+ +--------v------+
| Media | | Messaging | | Notification |
| Service | | Service | | Service |
| (S3 + CDN) | | (MongoDB) | | (Redis) |
+---------------+ +---------------+ +---------------+
|
+--------v------+ +---------------+
| Ingestion | | Valuation |
| Service | | Service |
| (ETL Pipeline)| | (ML Models) |
+---------------+ +---------------+
データ モデル: プラットフォームの中心
不動産プラットフォームのデータ モデルは驚くほど複雑です。シングル 財産 物理学は倍数を持つことができます 広告 時間が経つにつれて、によって管理されるようになる 違う エージェント、上がってください トランザクション そして何百もの マルチメディア資産。これらの関係を正しく設計し、 パフォーマンスとデータの一貫性。
メインエンティティスキーマ
// Entità base: Proprietà fisica
interface Property {
readonly id: string;
readonly externalId?: string; // ID da MLS/fonte esterna
readonly source: DataSource;
readonly propertyType: PropertyType;
readonly address: Address;
readonly location: GeoPoint; // lat/lng per ricerca spaziale
readonly h3Index: string; // H3 cell index (risoluzione 9)
readonly characteristics: PropertyCharacteristics;
readonly amenities: ReadonlyArray<Amenity>;
readonly energyRating?: EnergyRating;
readonly cadastralRef?: string; // Riferimento catastale (IT)
readonly createdAt: Date;
readonly updatedAt: Date;
}
type PropertyType =
| 'apartment' | 'house' | 'villa' | 'penthouse'
| 'studio' | 'loft' | 'commercial' | 'land'
| 'garage' | 'office' | 'warehouse';
interface Address {
readonly street: string;
readonly streetNumber: string;
readonly city: string;
readonly province: string;
readonly region: string;
readonly postalCode: string;
readonly country: string;
readonly formattedAddress: string;
readonly neighborhood?: string;
}
interface GeoPoint {
readonly lat: number;
readonly lng: number;
}
interface PropertyCharacteristics {
readonly sqMeters: number;
readonly rooms: number;
readonly bedrooms: number;
readonly bathrooms: number;
readonly floor?: number;
readonly totalFloors?: number;
readonly hasElevator?: boolean;
readonly hasBalcony?: boolean;
readonly hasTerrace?: boolean;
readonly hasGarden?: boolean;
readonly gardenSqMeters?: number;
readonly yearBuilt?: number;
readonly condition: PropertyCondition;
readonly heatingType?: HeatingType;
readonly orientation?: ReadonlyArray<Orientation>;
}
// Annuncio: un'istanza di vendita/affitto
interface Listing {
readonly id: string;
readonly propertyId: string;
readonly agentId: string;
readonly agencyId?: string;
readonly listingType: 'sale' | 'rent' | 'auction';
readonly status: ListingStatus;
readonly price: Money;
readonly pricePerSqMeter: Money;
readonly condominiumFees?: Money;
readonly description: LocalizedText;
readonly media: ReadonlyArray<MediaAsset>;
readonly virtualTourUrl?: string;
readonly availableFrom?: Date;
readonly publishedAt?: Date;
readonly expiresAt?: Date;
readonly viewCount: number;
readonly favoriteCount: number;
readonly contactCount: number;
}
interface Money {
readonly amount: number;
readonly currency: CurrencyCode;
}
type CurrencyCode = 'EUR' | 'USD' | 'GBP' | 'CHF';
interface LocalizedText {
readonly [locale: string]: string; // { it: "...", en: "..." }
}
type ListingStatus =
| 'draft' | 'pending_review' | 'active'
| 'under_offer' | 'sold' | 'rented'
| 'expired' | 'withdrawn';
このモデルの重要な側面は次のとおりです。
- プロパティ/リストの分離: 物理的性質は独立して存在する 広告から。同じアパートを売りに出し、その後撤退し、その後賃貸することができます。 同じエンティティにリンクされた個別のリストを生成します。
-
不変性: すべてのインターフェイスが使用する
readonly防ぐために 偶然の突然変異。状態遷移により新しいオブジェクトが生成されます。 - H3 インデックス: 挿入時に事前に計算され、地理空間検索が可能になります 六方晶系集合体により効率的。
-
複数通貨と複数言語: によるネイティブサポート
MoneyeLocalizedText国際市場向け。
広告取り込みパイプライン
不動産プラットフォームの心臓部とデータ取り込みパイプライン。広告 それらは異種ソース、つまりフィードから取得されます。 RESO Web API (米国/国際規格)、 独自の XML フィード、代理店 API、手動アップロード、パートナー ポータルのスクレイピング。あらゆるソース 独自のフォーマット、更新レート、データ品質レベルを持っています。
RESO Web API: 業界標準
Il RESO (不動産標準化機構) Web API そして現代のスタンダード JSON ペイロードを含む REST/OData に基づく MLS データ交換用。古い RETS を置き換えます (現在は非推奨です)。データ ディクショナリ 2.x では、フィールドの標準名 (ListingId、 StandardStatus、ListPrice、LivingArea) により、システム間の相互運用性が確保されます。
ETL パイプライン アーキテクチャ
import { EventEmitter } from 'events';
// Interfaccia generica per sorgenti dati
interface ListingSource {
readonly sourceId: string;
readonly sourceType: 'reso_api' | 'xml_feed' | 'manual' | 'scraper';
fetch(since: Date): Promise<ReadonlyArray<RawListing>>;
}
// Dati grezzi da qualsiasi fonte
interface RawListing {
readonly externalId: string;
readonly source: string;
readonly rawData: Record<string, unknown>;
readonly fetchedAt: Date;
}
// Pipeline di ingestione con step chiari
class ListingIngestionPipeline {
private readonly events = new EventEmitter();
constructor(
private readonly sources: ReadonlyArray<ListingSource>,
private readonly normalizer: ListingNormalizer,
private readonly validator: ListingValidator,
private readonly deduplicator: ListingDeduplicator,
private readonly enricher: ListingEnricher,
private readonly repository: ListingRepository,
private readonly searchIndex: SearchIndexer,
) {}
async ingest(source: ListingSource): Promise<IngestionResult> {
const startTime = Date.now();
const result: IngestionResult = {
sourceId: source.sourceId,
processed: 0,
created: 0,
updated: 0,
skipped: 0,
errors: [],
};
// Step 1: Fetch dati grezzi
const rawListings = await source.fetch(
await this.getLastSyncTime(source.sourceId)
);
for (const raw of rawListings) {
try {
// Step 2: Normalizzazione (formato sorgente -> formato interno)
const normalized = this.normalizer.normalize(raw);
// Step 3: Validazione (campi obbligatori, range, formato)
const validation = this.validator.validate(normalized);
if (!validation.isValid) {
result.skipped++;
result.errors.push({
externalId: raw.externalId,
errors: validation.errors,
});
continue;
}
// Step 4: Deduplicazione (match per indirizzo, coordinate, ID esterno)
const existingId = await this.deduplicator.findDuplicate(normalized);
// Step 5: Arricchimento (geocoding, H3 index, neighborhood)
const enriched = await this.enricher.enrich(normalized);
// Step 6: Persistenza
if (existingId) {
await this.repository.update(existingId, enriched);
result.updated++;
} else {
await this.repository.create(enriched);
result.created++;
}
// Step 7: Indicizzazione per la ricerca (asincrona)
this.events.emit('listing:upserted', enriched);
result.processed++;
} catch (error) {
result.errors.push({
externalId: raw.externalId,
errors: [String(error)],
});
}
}
// Aggiorna timestamp ultima sync
await this.updateLastSyncTime(source.sourceId, new Date());
this.events.emit('ingestion:completed', {
...result,
durationMs: Date.now() - startTime,
});
return result;
}
private async getLastSyncTime(sourceId: string): Promise<Date> {
// Recupera da DB il timestamp dell'ultima sincronizzazione
return new Date(Date.now() - 24 * 60 * 60 * 1000); // fallback: 24h fa
}
private async updateLastSyncTime(sourceId: string, time: Date): Promise<void> {
// Persiste il timestamp per la prossima esecuzione
}
}
interface IngestionResult {
readonly sourceId: string;
processed: number;
created: number;
updated: number;
skipped: number;
errors: Array<{ externalId: string; errors: string[] }>;
}
正規化: RETURN から内部フォーマットへ
正規化はパイプラインで最も重要なステップです。すべてのデータソースには形式があります 異なるため、正規化では異種フィールドを均一なモデルにマッピングする必要があります。ここにあります RESO 形式のノーマライザーの例:
class ResoListingNormalizer implements ListingNormalizer {
normalize(raw: RawListing): NormalizedListing {
const data = raw.rawData as ResoPropertyData;
return {
externalId: String(data.ListingId),
source: raw.source,
propertyType: this.mapPropertyType(data.PropertyType),
listingType: this.mapListingType(data.TransactionType),
status: this.mapStatus(data.StandardStatus),
price: {
amount: data.ListPrice,
currency: data.CurrencyCode ?? 'USD',
},
address: {
street: data.StreetName,
streetNumber: data.StreetNumber,
city: data.City,
province: data.StateOrProvince,
postalCode: data.PostalCode,
country: data.Country ?? 'US',
formattedAddress: this.buildFormattedAddress(data),
},
location: data.Latitude && data.Longitude
? { lat: data.Latitude, lng: data.Longitude }
: undefined,
characteristics: {
sqMeters: this.sqFeetToSqMeters(data.LivingArea),
rooms: data.RoomsTotal ?? 0,
bedrooms: data.BedroomsTotal ?? 0,
bathrooms: data.BathroomsTotalInteger ?? 0,
yearBuilt: data.YearBuilt,
},
description: {
en: data.PublicRemarks ?? '',
},
photos: (data.Media ?? []).map((m: ResoMedia) => ({
url: m.MediaURL,
order: m.Order,
caption: m.ShortDescription,
})),
rawData: raw.rawData,
fetchedAt: raw.fetchedAt,
};
}
private mapPropertyType(resoType: string): PropertyType {
const mapping: Record<string, PropertyType> = {
'Residential': 'house',
'Condominium': 'apartment',
'Townhouse': 'house',
'Land': 'land',
'Commercial': 'commercial',
};
return mapping[resoType] ?? 'apartment';
}
private mapStatus(resoStatus: string): ListingStatus {
const mapping: Record<string, ListingStatus> = {
'Active': 'active',
'Pending': 'under_offer',
'Closed': 'sold',
'Withdrawn': 'withdrawn',
'Expired': 'expired',
};
return mapping[resoStatus] ?? 'draft';
}
private sqFeetToSqMeters(sqFeet?: number): number {
return sqFeet ? Math.round(sqFeet * 0.092903 * 100) / 100 : 0;
}
private mapListingType(txType: string): 'sale' | 'rent' {
return txType === 'Lease' ? 'rent' : 'sale';
}
private buildFormattedAddress(data: ResoPropertyData): string {
return [data.StreetNumber, data.StreetName, data.City, data.StateOrProvince]
.filter(Boolean)
.join(', ');
}
}
検索アーキテクチャ: Elasticsearch と地理空間
不動産プラットフォームの中で最も重要な検索と機能。ユーザーはそうする 即時の結果、組み合わせ可能なフィルター、関連性/価格/距離による並べ替えを期待します リアルタイム更新による地図検索。 エラスティックサーチ (またはそのフォーク オープンサーチ) サポートのおかげで業界で最も有力な選択肢 全文検索、地理空間検索、およびファセット検索にネイティブです。
広告の Elasticsearch インデックス
{
"mappings": {
"properties": {
"listingId": { "type": "keyword" },
"propertyType": { "type": "keyword" },
"listingType": { "type": "keyword" },
"status": { "type": "keyword" },
"price": { "type": "long" },
"pricePerSqm": { "type": "float" },
"currency": { "type": "keyword" },
"location": { "type": "geo_point" },
"geoShape": { "type": "geo_shape" },
"h3Index": { "type": "keyword" },
"h3Res7": { "type": "keyword" },
"city": { "type": "keyword" },
"neighborhood": { "type": "keyword" },
"province": { "type": "keyword" },
"postalCode": { "type": "keyword" },
"sqMeters": { "type": "integer" },
"rooms": { "type": "integer" },
"bedrooms": { "type": "integer" },
"bathrooms": { "type": "integer" },
"floor": { "type": "integer" },
"yearBuilt": { "type": "integer" },
"hasElevator": { "type": "boolean" },
"hasBalcony": { "type": "boolean" },
"hasGarden": { "type": "boolean" },
"energyRating": { "type": "keyword" },
"amenities": { "type": "keyword" },
"description": { "type": "text", "analyzer": "multilingual_analyzer" },
"title": { "type": "text", "analyzer": "multilingual_analyzer",
"fields": { "keyword": { "type": "keyword" } } },
"agentId": { "type": "keyword" },
"agencyId": { "type": "keyword" },
"publishedAt": { "type": "date" },
"updatedAt": { "type": "date" },
"viewCount": { "type": "integer" },
"photoCount": { "type": "integer" },
"hasVirtualTour": { "type": "boolean" }
}
},
"settings": {
"number_of_shards": 5,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"multilingual_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "stop_it", "stop_en"]
}
},
"filter": {
"stop_it": { "type": "stop", "stopwords": "_italian_" },
"stop_en": { "type": "stop", "stopwords": "_english_" }
}
}
}
}
地理空間フィルターを使用した検索 API
検索クエリは、ブール フィルター、数値範囲、全文検索、および制約を組み合わせます。 地理空間。次の 3 つの地理検索モードがサポートされています。 地理距離 (点からの半径)、 geo_bounding_box (地図上の四角形) e 地理的形状 (Rightmove の「Draw-a-search」のような、ユーザーが描画したポリゴン)。
interface PropertySearchParams {
readonly query?: string;
readonly listingType: 'sale' | 'rent';
readonly propertyTypes?: ReadonlyArray<PropertyType>;
readonly priceMin?: number;
readonly priceMax?: number;
readonly sqMetersMin?: number;
readonly sqMetersMax?: number;
readonly bedroomsMin?: number;
readonly bathroomsMin?: number;
readonly amenities?: ReadonlyArray<string>;
readonly geoFilter?: GeoFilter;
readonly sortBy?: 'relevance' | 'price_asc' | 'price_desc' | 'newest' | 'distance';
readonly page?: number;
readonly pageSize?: number;
}
type GeoFilter =
| { type: 'radius'; center: GeoPoint; radiusKm: number }
| { type: 'bbox'; topLeft: GeoPoint; bottomRight: GeoPoint }
| { type: 'polygon'; points: ReadonlyArray<GeoPoint> };
class PropertySearchService {
constructor(private readonly esClient: ElasticsearchClient) {}
async search(params: PropertySearchParams): Promise<SearchResult> {
const must: any[] = [];
const filter: any[] = [];
// Filtro obbligatorio: tipo annuncio e stato attivo
filter.push({ term: { listingType: params.listingType } });
filter.push({ term: { status: 'active' } });
// Full-text search (titolo + descrizione)
if (params.query) {
must.push({
multi_match: {
query: params.query,
fields: ['title^3', 'description', 'city^2', 'neighborhood^2'],
type: 'best_fields',
fuzziness: 'AUTO',
},
});
}
// Filtri range prezzo
if (params.priceMin || params.priceMax) {
filter.push({
range: {
price: {
...(params.priceMin ? { gte: params.priceMin } : {}),
...(params.priceMax ? { lte: params.priceMax } : {}),
},
},
});
}
// Filtri superficie
if (params.sqMetersMin || params.sqMetersMax) {
filter.push({
range: {
sqMeters: {
...(params.sqMetersMin ? { gte: params.sqMetersMin } : {}),
...(params.sqMetersMax ? { lte: params.sqMetersMax } : {}),
},
},
});
}
// Tipo proprietà
if (params.propertyTypes?.length) {
filter.push({ terms: { propertyType: params.propertyTypes } });
}
// Camere minime
if (params.bedroomsMin) {
filter.push({ range: { bedrooms: { gte: params.bedroomsMin } } });
}
// Amenities (AND logic)
if (params.amenities?.length) {
for (const amenity of params.amenities) {
filter.push({ term: { amenities: amenity } });
}
}
// Filtro geospaziale
if (params.geoFilter) {
filter.push(this.buildGeoFilter(params.geoFilter));
}
const page = params.page ?? 0;
const pageSize = params.pageSize ?? 20;
const response = await this.esClient.search({
index: 'listings',
body: {
query: {
bool: {
must: must.length ? must : [{ match_all: {} }],
filter,
},
},
sort: this.buildSort(params.sortBy, params.geoFilter),
from: page * pageSize,
size: pageSize,
aggs: this.buildAggregations(),
},
});
return this.mapResponse(response, page, pageSize);
}
private buildGeoFilter(geo: GeoFilter): Record<string, unknown> {
switch (geo.type) {
case 'radius':
return {
geo_distance: {
distance: `${geo.radiusKm}km`,
location: { lat: geo.center.lat, lon: geo.center.lng },
},
};
case 'bbox':
return {
geo_bounding_box: {
location: {
top_left: { lat: geo.topLeft.lat, lon: geo.topLeft.lng },
bottom_right: { lat: geo.bottomRight.lat, lon: geo.bottomRight.lng },
},
},
};
case 'polygon':
return {
geo_shape: {
geoShape: {
shape: {
type: 'polygon',
coordinates: [
geo.points.map(p => [p.lng, p.lat]),
],
},
relation: 'within',
},
},
};
}
}
private buildAggregations(): Record<string, unknown> {
return {
price_stats: { stats: { field: 'price' } },
property_types: { terms: { field: 'propertyType', size: 15 } },
bedrooms: { terms: { field: 'bedrooms', size: 10 } },
price_ranges: {
range: {
field: 'price',
ranges: [
{ key: 'under_100k', to: 100000 },
{ key: '100k_200k', from: 100000, to: 200000 },
{ key: '200k_350k', from: 200000, to: 350000 },
{ key: '350k_500k', from: 350000, to: 500000 },
{ key: 'over_500k', from: 500000 },
],
},
},
neighborhoods: { terms: { field: 'neighborhood', size: 30 } },
energy_ratings: { terms: { field: 'energyRating', size: 10 } },
};
}
private buildSort(
sortBy?: string,
geoFilter?: GeoFilter
): Array<Record<string, unknown>> {
switch (sortBy) {
case 'price_asc':
return [{ price: 'asc' }];
case 'price_desc':
return [{ price: 'desc' }];
case 'newest':
return [{ publishedAt: 'desc' }];
case 'distance':
if (geoFilter?.type === 'radius') {
return [{
_geo_distance: {
location: { lat: geoFilter.center.lat, lon: geoFilter.center.lng },
order: 'asc',
unit: 'km',
},
}];
}
return [{ _score: 'desc' }];
default:
return [{ _score: 'desc' }, { publishedAt: 'desc' }];
}
}
private mapResponse(
response: any,
page: number,
pageSize: number
): SearchResult {
return {
listings: response.hits.hits.map((hit: any) => ({
...hit._source,
score: hit._score,
distance: hit.sort?.[0],
})),
total: response.hits.total.value,
page,
pageSize,
facets: {
priceStats: response.aggregations?.price_stats,
propertyTypes: response.aggregations?.property_types?.buckets,
bedrooms: response.aggregations?.bedrooms?.buckets,
priceRanges: response.aggregations?.price_ranges?.buckets,
neighborhoods: response.aggregations?.neighborhoods?.buckets,
energyRatings: response.aggregations?.energy_ratings?.buckets,
},
};
}
}
地理空間インデックス: PostGIS、H3、および S2
Elasticsearch に加えて、プライマリ永続化レイヤーには地理空間機能が必要です 正確な距離の計算、ゾーンごとの集計、および 市場分析。 3 つのテクノロジーがこの分野を支配しています。
| テクノロジー | タイプ | 主な用途 | 利点 | 限界 |
|---|---|---|---|---|
| ポストGIS | PostgreSQL 拡張機能 | SQL 空間クエリ、複雑なジオメトリ | 標準 OGC、成熟した、リレーショナル データとの結合 | 垂直方向のスケーラビリティ、ネイティブ クラスタリングなし |
| H3 (ウーバー) | 階層的な六角形グリッド | エリア、近接性、分析による集計 | 均一、階層的な O(1) ルックアップ | エッジの五角形、境界近似 |
| S2 ジオメトリ (グーグル) | 階層的な球状セル | リージョンカバレッジ、範囲クエリ、シャーディング | BigQuery/Spanner で使用される正確なカバレッジ | 不均一な四角形セル、複雑な API |
PostGIS を使用した地理空間クエリ
-- Ricerca per raggio: proprietà entro 5km da un punto
SELECT
l.id,
l.title,
l.price,
p.property_type,
ST_Distance(
p.location::geography,
ST_MakePoint(12.4964, 41.9028)::geography
) AS distance_meters
FROM listings l
JOIN properties p ON l.property_id = p.id
WHERE l.status = 'active'
AND l.listing_type = 'sale'
AND ST_DWithin(
p.location::geography,
ST_MakePoint(12.4964, 41.9028)::geography, -- Roma centro
5000 -- 5km in metri
)
AND l.price BETWEEN 150000 AND 400000
ORDER BY distance_meters ASC
LIMIT 50;
-- Ricerca per bounding box (viewport della mappa)
SELECT l.id, l.title, l.price,
ST_X(p.location) AS lng, ST_Y(p.location) AS lat
FROM listings l
JOIN properties p ON l.property_id = p.id
WHERE l.status = 'active'
AND p.location && ST_MakeEnvelope(
12.45, 41.87, -- bottom-left (lng, lat)
12.55, 41.93, -- top-right (lng, lat)
4326 -- SRID WGS84
)
ORDER BY l.published_at DESC
LIMIT 200;
-- Clustering per H3: conteggio annunci per cella esagonale
SELECT
h3_lat_lng_to_cell(p.location, 7) AS h3_cell,
COUNT(*) AS listing_count,
AVG(l.price) AS avg_price,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l.price) AS median_price
FROM listings l
JOIN properties p ON l.property_id = p.id
WHERE l.status = 'active'
AND l.listing_type = 'sale'
AND p.city = 'Milano'
GROUP BY h3_cell
HAVING COUNT(*) >= 3
ORDER BY avg_price DESC;
なぜ PropTech に H3 を採用するのか?
H3 は、効率的な空間集約という根本的な問題を解決します。計算する代わりに
地図上のリアルタイムの広告クラスターで、各施設の H3 インデックスを事前計算します
挿入時。解像度 7 (~5.16 km²) は街の景色に最適で、解像度 9
(~0.105 km²) 近隣レベルの場合。集計クエリをシンプルに
GROUP BY h3_index、幾何学的演算よりも桁違いに高速です
リアルタイムで。
メディア パイプライン: 画像、フロア プラン、バーチャル ツアー
画像は、ユーザーが広告をクリックする決定に最も影響を与える要素です。 スケール プラットフォームは、1 日に複数の形式で数百万枚の画像を処理します。 決議。平均的なパイプラインは次のようにする必要があります。 非同期, 弾力性のある に最適化されています 配送速度 CDN経由。
メディア処理の流れ
- アップロード: エージェントは署名付き URL 経由で元の写真を S3 にアップロードします
- 検証: フォーマット、サイズ、コンテンツを制御 NSFW (ML)
- 処理: バリアント生成 (サムネイル 300px、中 800px、大 1600px、WebP/AVIF)
- 最適化: インテリジェントな圧縮、機密性の高い EXIF メタデータの削除、ウォーターマーク
- ML エンリッチメント: 部屋分類(キッチン、バスルーム、リビングルーム)、品質スコア、間取り調査
- CDN ディストリビューション: CDN エッジ ノードにプッシュして、グローバルに 50 ミリ秒未満の配信を実現
- インデックス作成: 検索インデックスのメタデータの更新
interface ImageVariant {
readonly suffix: string;
readonly width: number;
readonly quality: number;
readonly format: 'webp' | 'avif' | 'jpeg';
}
const IMAGE_VARIANTS: ReadonlyArray<ImageVariant> = [
{ suffix: 'thumb', width: 300, quality: 75, format: 'webp' },
{ suffix: 'medium', width: 800, quality: 80, format: 'webp' },
{ suffix: 'large', width: 1600, quality: 85, format: 'webp' },
{ suffix: 'avif-medium', width: 800, quality: 70, format: 'avif' },
{ suffix: 'original', width: 0, quality: 90, format: 'jpeg' },
] as const;
class ImageProcessingService {
constructor(
private readonly storage: ObjectStorage,
private readonly sharp: SharpInstance,
private readonly classifier: RoomClassifier,
private readonly queue: MessageQueue,
) {}
async processUploadedImage(event: ImageUploadEvent): Promise<ProcessedImage> {
const { listingId, imageKey, uploadedBy } = event;
// 1. Scarica l'originale
const originalBuffer = await this.storage.download(imageKey);
// 2. Valida (dimensione, formato, contenuto)
const metadata = await this.sharp(originalBuffer).metadata();
if (!metadata.width || metadata.width < 600) {
throw new ImageValidationError('Risoluzione minima: 600px di larghezza');
}
// 3. Genera tutte le varianti
const variants = await Promise.all(
IMAGE_VARIANTS.map(variant =>
this.generateVariant(originalBuffer, imageKey, variant)
)
);
// 4. Classifica la stanza (ML asincrono)
const classification = await this.classifier.classify(originalBuffer);
// 5. Rimuovi EXIF sensibili, mantieni orientamento
const sanitizedMetadata = this.sanitizeExif(metadata);
const processedImage: ProcessedImage = {
listingId,
originalKey: imageKey,
variants: variants.map(v => ({
key: v.key,
url: this.storage.getPublicUrl(v.key),
width: v.width,
height: v.height,
format: v.format,
sizeBytes: v.sizeBytes,
})),
classification: {
roomType: classification.label,
confidence: classification.score,
},
metadata: sanitizedMetadata,
processedAt: new Date(),
};
// 6. Invalida cache CDN per l'annuncio
await this.queue.publish('cdn:invalidate', {
patterns: [`/listings/${listingId}/images/*`],
});
return processedImage;
}
private async generateVariant(
buffer: Buffer,
originalKey: string,
variant: ImageVariant
): Promise<GeneratedVariant> {
let pipeline = this.sharp(buffer);
if (variant.width > 0) {
pipeline = pipeline.resize(variant.width, undefined, {
fit: 'inside',
withoutEnlargement: true,
});
}
const outputBuffer = await pipeline
.toFormat(variant.format, { quality: variant.quality })
.toBuffer({ resolveWithObject: true });
const variantKey = originalKey.replace(
/\.[^.]+$/, `-${variant.suffix}.${variant.format}`
);
await this.storage.upload(variantKey, outputBuffer.data, {
contentType: `image/${variant.format}`,
cacheControl: 'public, max-age=31536000, immutable',
});
return {
key: variantKey,
width: outputBuffer.info.width,
height: outputBuffer.info.height,
format: variant.format,
sizeBytes: outputBuffer.info.size,
};
}
private sanitizeExif(metadata: any): Record<string, unknown> {
// Mantieni solo dati non sensibili
return {
width: metadata.width,
height: metadata.height,
orientation: metadata.orientation,
format: metadata.format,
};
}
}
リアルタイム機能
最新の不動産プラットフォームには、次のようなリアルタイム機能が必要です。 簡単なページ更新。ユーザーはそれを期待している プッシュ通知 興味のあるエリアの物件が掲載されると、 メッセージング インスタント エージェントたちと 価格アラート 不動産のとき 保存されたものは変更されます。
警報および通知システム
interface SavedSearch {
readonly userId: string;
readonly searchParams: PropertySearchParams;
readonly notifyVia: ReadonlyArray<'push' | 'email' | 'sms'>;
readonly frequency: 'instant' | 'daily' | 'weekly';
readonly createdAt: Date;
readonly lastNotifiedAt?: Date;
}
interface PriceAlert {
readonly userId: string;
readonly listingId: string;
readonly thresholdType: 'any_change' | 'decrease' | 'below_amount';
readonly thresholdAmount?: number;
readonly notifyVia: ReadonlyArray<'push' | 'email'>;
}
class PriceAlertService {
constructor(
private readonly alertRepo: PriceAlertRepository,
private readonly listingRepo: ListingRepository,
private readonly notifier: NotificationService,
private readonly searchService: PropertySearchService,
) {}
// Invocato dall'event bus quando un listing cambia prezzo
async onPriceChanged(event: ListingPriceChangedEvent): Promise<void> {
const { listingId, oldPrice, newPrice } = event;
// Trova tutti gli alert per questo listing
const alerts = await this.alertRepo.findByListingId(listingId);
const notifications = alerts
.filter(alert => this.shouldNotify(alert, oldPrice, newPrice))
.map(alert => ({
userId: alert.userId,
channels: alert.notifyVia,
template: 'price_change',
data: {
listingId,
oldPrice: oldPrice.amount,
newPrice: newPrice.amount,
changePercent: ((newPrice.amount - oldPrice.amount) / oldPrice.amount * 100).toFixed(1),
currency: newPrice.currency,
direction: newPrice.amount < oldPrice.amount ? 'decrease' : 'increase',
},
}));
await Promise.all(
notifications.map(n => this.notifier.send(n))
);
}
// Invocato su schedule per "nuovi annunci nelle ricerche salvate"
async processNewListingAlerts(): Promise<void> {
const searches = await this.getSearchesToProcess();
for (const savedSearch of searches) {
const results = await this.searchService.search({
...savedSearch.searchParams,
publishedAfter: savedSearch.lastNotifiedAt,
pageSize: 10,
});
if (results.total > 0) {
await this.notifier.send({
userId: savedSearch.userId,
channels: savedSearch.notifyVia,
template: 'new_listings',
data: {
count: results.total,
topListings: results.listings.slice(0, 3),
searchUrl: this.buildSearchUrl(savedSearch.searchParams),
},
});
await this.alertRepo.updateLastNotified(savedSearch.userId, new Date());
}
}
}
private shouldNotify(
alert: PriceAlert,
oldPrice: Money,
newPrice: Money
): boolean {
switch (alert.thresholdType) {
case 'any_change':
return oldPrice.amount !== newPrice.amount;
case 'decrease':
return newPrice.amount < oldPrice.amount;
case 'below_amount':
return newPrice.amount <= (alert.thresholdAmount ?? 0);
default:
return false;
}
}
private async getSearchesToProcess(): Promise<ReadonlyArray<SavedSearch>> {
// Recupera le ricerche salvate che necessitano di notifica
// basandosi sulla frequenza configurata
return this.alertRepo.findSearchesReadyForNotification();
}
private buildSearchUrl(params: PropertySearchParams): string {
const qs = new URLSearchParams();
if (params.listingType) qs.set('type', params.listingType);
if (params.priceMin) qs.set('price_min', String(params.priceMin));
if (params.priceMax) qs.set('price_max', String(params.priceMax));
return `/search?${qs.toString()}`;
}
}
API 設計: RESTful エンドポイント
プラットフォームのパブリック API は、パスのバージョン管理、ページネーションなどの REST 原則に従います。
カーソルベースの結果とヘッダーによる多言語サポート Accept-Language。
ここでは、ドメインごとに整理された主要なエンドポイントを示します。
| 方法 | エンドポイント | 説明 | 認証 |
|---|---|---|---|
GET |
/api/v1/listings |
フィルタを使用して広告を検索する | 公共 |
GET |
/api/v1/listings/{id} |
単一の広告の詳細 | 公共 |
POST |
/api/v1/listings |
新しい広告を作成する | エージェント |
PATCH |
/api/v1/listings/{id} |
広告を更新 (価格、ステータス、写真) | 代理店(オーナー) |
POST |
/api/v1/search |
地域フィルターによる高度な検索 | 公共 |
POST |
/api/v1/search/map |
マップ ビューごとのクラスター (H3 集約) | 公共 |
GET |
/api/v1/search/suggest |
場所と近隣地域のオートコンプリート | 公共 |
POST |
/api/v1/users/{id}/favorites |
広告をお気に入りに保存する | ユーザー |
POST |
/api/v1/users/{id}/saved-searches |
アラートの検索を保存 | ユーザー |
POST |
/api/v1/messages |
エージェントにメッセージを送信する | ユーザー |
GET |
/api/v1/agents/{id} |
エージェントのプロフィールと評価 | 公共 |
POST |
/api/v1/media/upload-url |
アップロード用の署名付き URL を生成する | エージェント |
GET |
/api/v1/valuations/estimate |
市場価格の推定 | 公共 |
カーソルベースのページネーション
大規模なデータセットの場合、オフセットベースのページネーション (page/pageSize) が低下します。 すぐに。採用 カーソルベースのページネーション 高トラフィック API の場合、 ページの深さに関係なく、一定のパフォーマンスを保証します。
{
"data": [
{
"id": "lst_abc123",
"title": "Trilocale luminoso zona Brera",
"price": { "amount": 385000, "currency": "EUR" },
"propertyType": "apartment",
"bedrooms": 2,
"sqMeters": 95,
"location": { "lat": 45.4726, "lng": 9.1860 },
"thumbnail": "https://cdn.platform.com/lst_abc123/thumb.webp"
}
],
"pagination": {
"cursor": "eyJzIjoiMjAyNi0wMy0wOFQxMDozMDowMFoiLCJpIjoibHN0X2FiYzEyMyJ9",
"hasMore": true,
"totalEstimate": 1247
},
"facets": {
"propertyTypes": [
{ "key": "apartment", "count": 832 },
{ "key": "house", "count": 215 }
],
"priceStats": { "min": 85000, "max": 2500000, "avg": 342000 }
}
}
スカラのパフォーマンス
数百万件の物件を扱う不動産プラットフォームは、大幅なトラフィックの急増に対処する必要があります。 日曜の夜や季節の変わり目には、検索数が 300 ~ 400% 増加します。戦略 パフォーマンスはアーキテクチャのあらゆるレベルで機能する必要があります。
マルチレベルキャッシュ
| レベル | テクノロジー | TTL | 使用 | 典型的なヒット率 |
|---|---|---|---|---|
| L1 - エッジ/CDN | CloudFront / ファストリー | 5~60分 | 画像、CSS/JS、静的ページ | 90-95% |
| L2 - API ゲートウェイ | ワニス/コングキャッシュ | 1~5分 | GET API レスポンス (詳細リスト、検索結果) | 60-75% |
| L3 - アプリケーション | Redis クラスター | 30秒~15分 | セッション、ファセット数、ジオコーディング、H3 集計 | 80-90% |
| L4 - クエリ | Elasticsearchのレプリケーション | ほぼリアルタイム | 検索の場合は返信を読み取り、提案の場合は別のインデックスを作成します | N/A (読み取りスケーリング) |
| L5 - データベース | PostgreSQL + pgbouncer | 該当なし | 接続プーリング、レポートごとのリードレプリカ | 該当なし |
リージョン別のデータベースシャーディング
地理的に分散された数百万のプロパティ、地域および戦略ごとのシャーディング 不動産プラットフォームではより自然です。各シャードにはマクロ領域のデータが含まれており、 水平方向に拡張しながら同時に規制に準拠できるようになります データ常駐 (GDPR)。
- シャードキー: 地理的地域 (例:
country:region=IT:lombardia) - ルーティング: ゲートウェイ API はクエリの場所に基づいてシャードを決定します。
- クロスシャードクエリ: 複数領域検索用のスキャッター/ギャザー (頻度は低い)
- リバランス: リージョンがしきい値を超えると分割されます (例: ミラノは専用シャードになります)
読み取り/書き込み分離用の CQRS パターン
パターン CQRS (コマンドクエリ責任分離) そして特に 読み取り/書き込み比率が通常 100:1 である不動産プラットフォームに効果的です。 の 経典 (広告の作成、価格の更新) リスティングサービスを経由します PostgreSQL を信頼できる情報源として使用します。の 測定値 (リサーチ、リストの詳細) 役に立ちました Elasticsearch および Redis から、イベントを通じて非同期的に更新されます。このデカップリングは 2 つのパスを個別に最適化できます。
多言語と多通貨
国際市場で動作するプラットフォーム (スペイン、イタリア、ポルトガルの Idealista など) 複数の言語でコンテンツを管理し、さまざまな通貨で価格を設定する必要があります。拠点によって戦略が異なる コンテンツの種類に応じて。
コンテンツタイプ戦略
- 静的 UI: フロントエンドからロードされた翻訳ファイル (i18n)、1 つの言語 バンドルごと (Angular i18n、React i18next)
-
広告の説明: 分野
LocalizedTextDB内、翻訳 「自動翻訳」フラグを使用した DeepL/Google 翻訳による自動 - 住所: 翻訳されていません(現地の言語のままです)が、市は翻訳することができます。 バリエーションあり (ミラノ/ミラノ、ローマ/ローマ)
- 価格: 元の通貨で保存され、表示のためにオンザフライ変換されます API によって更新されたレート (ECB、オープン為替レート)
-
SEO: ローカライズされた URL (
/it/vendita/milanovs/en/sale/milan) タグ付きhreflang重複したコンテンツを避けるため
コンプライアンス: GDPR と公正な住宅
不動産プラットフォームは住宅の好み、機能などの機密性の高いデータを処理します 財務、研究履歴。コンプライアンスはオプションではなく、アーキテクチャ上の要件です これはあらゆるレベルのシステム設計に影響を与えます。
GDPR: アーキテクチャへの影響
-
データの最小化: 厳密に必要なデータのみを収集します。
フィールドにサービスを提供しない
viewCount公的反応では、位置を追跡しないでください 明示的な同意のないユーザーの GPS。 -
消去する権利: 埋め込む
DELETE /api/v1/users/{id}/dataこれにより、プロファイル、保存された検索、メッセージが削除され、ログが 30 日以内に匿名化されます。 -
データのポータビリティ:
GET /api/v1/users/{id}/export生成する すべてのユーザー データを含む JSON/CSV アーカイブ。 - 同意管理: 各トラッカー(GA4、ヒートマップ、リマーケティング)のみが有効化されています 明示的な同意の後、カテゴリごとに細分化されます。
- データの所在地: リージョンごとのシャーディングにより要件への準拠が容易になります EU のユーザー データは EU のデータ センターに残るということです。
公正な住宅と差別
米国では、 公正住宅法 販売およびレンタルにおける差別を禁止します 人種、肌の色、宗教、性別、障害、家族状況または出身地に基づく不動産の譲渡 国民的な。技術的な意味:
- 検索フィルター: プロキシとして機能するフィルターを決して提供しないでください。 保護される特性 (例: 「近隣の人口構成」)
- 広告のターゲティング: スポンサー付き広告のターゲティングでは使用しないでください 保護された人口統計基準 (Facebook は 2022 年にこのために 500 万ドルを支払った)
- ML モデル: 評価モデルの偏りに対する定期的な監査 推奨事項。近隣の不動産を体系的に過小評価するモデル 少数多数であり、不公平であるだけでなく、違法です。
- 広告の説明: 差別用語自動フィルター 説明文(例:「子供のいないカップルに最適」は公正住宅法に違反します)
警告: 評価モデルのバイアス
不動産評価 ML モデル (「Zestimate」など) は、過去のバイアスを増幅する可能性があります。 トレーニング データが市場における数十年にわたる差別 (レッドライニング) を反映している場合、 モデルはこれらの不平等を再現します。実装することが不可欠です 公平性 監査 人口動態別の株式指標と継続的なモニタリングを使用 エリアごとの予測。
結論とリファレンス アーキテクチャ
大規模な不動産プラットフォームの構築と実践 システム設計 これは、複雑なドメイン モデリングからソフトウェア エンジニアリングのほぼすべての領域に影響します。 マルチメディア パイプラインの管理から規制への準拠まで、地理空間研究まで 厳しい。
この分析から明らかになった重要な原則は次のとおりです。
- ドメインの分解: リスティング、検索、メディア、 専用データベースを使用した独立したサービスのユーザーと通知
- 基本パターンとしての CQRS: 読み取り/書き込み比が 100:1 の場合、 最適化ではなく、読み取り (Elasticsearch) パスと書き込み (PostgreSQL) パスを分離します。 時期尚早であり、建築上の必要性
- 第一級市民としての地理空間: 集計の場合は H3、集計の場合は PostGIS 精度、フルスタック検索用の Elasticsearch geo_point
- 非同期パイプライン: 広告の取り込み、メディアの処理、通知 ユーザー エクスペリエンスを低下させることなくスパイクを処理するには、イベント駆動型である必要があります。
- 設計によるコンプライアンス: GDPR と公正な住宅は追加する機能ではありません 後で、しかし初日から選択を導くアーキテクチャ上の制約
シリーズの次の記事では、 評価モデル 機械学習に基づく不動産、Zillow、Redfin、および 欧州のプラットフォームは自動評価モデル (AVM) を使用して市場価格を推定します。 この機能にはどのような技術的および倫理的課題が伴うのか。
リソースと洞察
- ウェブ API を返す: reso.org - MLS データ交換の標準
- H3: h3geo.org - Uber の六角形地理空間インデックス システム
- Elasticsearch 地理空間: elastic.co/docs - 地理クエリのドキュメント
- ポストGIS: postgis.net - PostgreSQL の地理空間拡張機能
- PropTech の GDPR: プロファイリングと自動化された意思決定に関する EDPB ガイドライン
- 公正住宅法: hud.gov - 米国差別禁止法







