Architektura realitní platformy ve Scale
Moderní realitní platformy jako Zillow, Idealista, Immobiliare.it e Pohyb vpravo spravovat miliony reklam, miliardy měsíčních vyhledávání a datových toků v reálném čase od agentur, MLS (Služba vícenásobného výpisu) a soukromí vlastníci. Navrhněte systém schopný udržet toto zatížení vyžaduje přesná architektonická rozhodnutí o každé vrstvě stohu: od datové modelování po geoprostorový výzkum, od multimediálního potrubí po systém upozornění v reálném čase.
V tomto článku vytvoříme kompletní architekturu platformy PropTech ve velkém měřítku, analyzovat každou komponentu s příklady kódu v TypeScript e Krajta, srovnání technologií a designových vzorů ověřených ve výrobě.
Co se naučíte
- Architektura mikroslužeb pro realitní platformy s dekompozicí domén
- Kompletní datový model: vlastnosti, výpisy, agenti, transakce, média
- Pokročilé geoprostorové vyhledávání s Elasticsearch, PostGIS a H3
- Průběh příjmu reklam z heterogenních zdrojů (MLS/RESO Web API, scraping, XML feed)
- Media pipeline: zpracování obrazu, půdorysy, 3D virtuální prohlídky
- Funkce v reálném čase: upozornění, zprávy, upozornění na cenu
- Výkonové strategie v měřítku: ukládání do mezipaměti, CDN, sharding, CQRS
- Soulad: GDPR, spravedlivé bydlení, regionální předpisy
Krajina realitních platforem
Před návrhem architektury je nezbytné porozumět konkurenčnímu prostředí obchodní modely, které řídí technické volby. Každá platforma má jedinečnou kombinaci funkčnost, která přímo ovlivňuje rozhodování o infrastruktuře.
| Platforma | Trh | Aktivní reklamy | Výrazná vlastnost | Známý zásobník |
|---|---|---|---|---|
| Zillow | USA | ~135 milionů nemovitostí | Zestimate (hodnocení ML) | Java, Kafka, Elasticsearch |
| Idealista | EU (ES, IT, PT) | ~1,8 milionu reklam | Interaktivní vyhledávání v mapě | Java, Solr, PostgreSQL |
| Immobiliare.it | Itálie | ~1,2 milionu reklam | Automatické vyhodnocení, teplotní mapa | PHP/Go, Elasticsearch |
| Pohyb vpravo | UK | ~1 milion reklam | Draw-a-search (kresba oblasti) | .NET, SQL Server, Azure |
| Redfin | USA | ~100 milionů nemovitostí | Integrovaní agenti, 3D prohlídky | Java, React, Kafka |
Monetizační modely a technický dopad
Obchodní model přímo ovlivňuje architekturu. Platforma freemium se sponzorovanými výpisy potřeba motoru pro zobrazování reklam, A/B testování a sledování zobrazení. Modelka na bázi olova vyžaduje směrování inteligentní kontakty na agenty a integrované CRM. Modelka transakční (iBuying) zahrnuje proces hodnocení ML, správu finanční a pokročilé dodržování předpisů.
- Sponzorované záznamy: vyžaduje nástroj pro hodnocení reklam, systém nabídek a analýzu návratnosti investic
- Agenturní předplatné: správa úrovní, opakované účtování, řídicí panel agenta
- Generování potenciálních zákazníků: hodnocení potenciálních zákazníků, inteligentní směrování, sledování atribuce
- Transakční (iBuying): Modely hodnocení ML, řízení nabídky, právní potrubí
- SaaS pro agentury: multi-tenancy, white-label, API pro integrace CRM
Architektura na vysoké úrovni
Žebříková realitní platforma přijímá architekturu Doménově orientované mikroslužby, kde každá služba má svou vlastní databázi a komunikuje prostřednictvím asynchronních událostí. Tento přístup umožňuje nezávisle škálovat komponenty při větším zatížení (typicky vyhledávání a API veřejné), aniž by to mělo dopad na méně žádané služby.
Hlavní princip: Vzor fíků Strangler
Většina realitních platforem začíná jako monolity. Migrace na mikroslužby dochází postupně přes Vzor Strangler Fig: služby jsou extrahovány jako první při vysoké zátěži (výzkum, média) a pak postupně ty zbývající, při zachování funkčnosti monolitu po celou dobu přechodu.
Rozklad na mikroslužby
| Servis | Odpovědnost | Databáze | Vzory |
|---|---|---|---|
| Výpisová služba | CRUD reklamy, ověřování, publikování workflow | PostgreSQL + PostGIS | CQRS, Event Sourcing |
| Vyhledávací služba | Fulltextové vyhledávání, filtry, geoprostorové, fasetové | Elasticsearch/OpenSearch | Číst model (CQRS) |
| Uživatelská služba | Autentizace, profily, předvolby, uloženo | PostgreSQL | OAuth 2.0 / OIDC |
| Agent Service | Profily agentů, agentury, hodnocení, dostupnost | PostgreSQL | Doménová služba |
| Mediální služba | Upload, zpracování, optimalizace, CDN | S3 + DynamoDB (metadata) | Asynchronní potrubí |
| Služba zasílání zpráv | Chat agent-uživatel, žádosti o informace, upozornění | MongoDB / Cassandra | WebSocket + řízené událostmi |
| Notifikační služba | Email, push, SMS, cenová upozornění, nové inzeráty | Redis + PostgreSQL | Fan-out, Template Engine |
| Analytická služba | Sledování návštěv, teplotní mapy, zprávy pro agenty | ClickHouse / BigQuery | Streamování událostí |
| Služba požití | Import z MLS, XML feedů, externích API | Staging DB + fronta | Potrubí ETL |
| Oceňovací služba | ML odhad ceny, srovnatelnosti, trendy na trhu | Obchod s funkcemi + registr modelů | ML potrubí |
Schéma architektury
+------------------+
| 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) |
+---------------+ +---------------+
Datový model: Srdce platformy
Datový model realitní platformy je překvapivě složitý. Singl vlastnictví fyzika může mít násobky reklamy v průběhu času spravovat jiný agenti, jdi nahoru transakce a generovat stovky multimediální aktiva. Navrhněte tyto vztahy správně a kriticky výkon a konzistence dat.
Schéma hlavní entity
// 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';
Některé klíčové aspekty tohoto modelu:
- Oddělení majetku/záznamu: fyzická vlastnost existuje nezávisle z reklam. Tentýž byt lze nabídnout k prodeji, poté stáhnout, poté pronajmout, generování odlišných výpisů spojených se stejnou entitou.
-
Neměnnost: používají všechna rozhraní
readonlyzabránit náhodné mutace. State transitions produce new objects. - Index H3: předem vypočítané při vložení, aby bylo možné geoprostorové vyhledávání efficient via hexagonal aggregation.
-
Více měn a více jazyků: nativní podpora prostřednictvím
MoneyeLocalizedTextpro mezinárodní trhy.
Potrubí pro příjem reklam
Bijící srdce realitní platformy a kanálu pro příjem dat. Reklamy pocházejí z heterogenních zdrojů: krmiv RESO Web API (US/mezinárodní standard), Vlastní zdroje XML, rozhraní API pro agentury, ruční nahrávání a stírání partnerského portálu. Každý zdroj má svůj vlastní formát, rychlost aktualizace a úroveň kvality dat.
RESO Web API: Průmyslový standard
Il Web API RESO (Real Estate Standards Organization). a moderní standard pro výměnu dat MLS, založenou na REST/OData s JSON payload. Nahrazuje staré RETS (nyní zastaralé). Datový slovník 2.x definuje standardní názvy polí (ListingId, StandardStatus, ListPrice, LivingArea) zajišťující interoperabilitu mezi systémy.
Architektura potrubí 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[] }>;
}
Normalizace: Z RETURN do interního formátu
Normalizace je nejkritičtějším krokem v procesu. Každý zdroj dat má svůj formát různé a normalizace musí mapovat heterogenní pole do jednotného modelu. Zde je a příklad normalizátoru pro formát 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(', ');
}
}
Architektura vyhledávání: Elasticsearch a Geospatial
Nejdůležitější vyhledávání a funkčnost jakékoli realitní platformy. Uživatelé ano očekávejte okamžité výsledky, kombinovatelné filtry, řazení podle relevance/ceny/vzdálenosti a vyhledávání map s aktualizací v reálném čase. Elastické vyhledávání (nebo jeho vidlice OpenSearch) a dominantní volbou v oboru díky podpoře nativní pro fulltextové, geoprostorové a fasetové vyhledávání.
Index elastického vyhledávání pro reklamy
{
"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_" }
}
}
}
}
Search API s geoprostorovými filtry
Vyhledávací dotaz kombinuje booleovské filtry, číselné rozsahy, fulltextové vyhledávání a omezení geoprostorové. Podporujeme tři režimy geografického vyhledávání: geografická_vzdálenost (poloměr z bodu), geo_bounding_box (obdélník na mapě) e geo_shape (uživatelem nakreslený polygon, jako "Draw-a-search" od Rightmove).
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,
},
};
}
}
Geoprostorové indexování: PostGIS, H3 a S2
Kromě Elasticsearch vyžaduje primární perzistentní vrstva geoprostorové schopnosti pokročilé pro operace, jako je výpočet přesných vzdáleností, agregace podle zón a analýza trhu. V tomto prostoru dominují tři technologie.
| Technologie | Typ | Primární použití | Výhody | Limity |
|---|---|---|---|---|
| PostGIS | Rozšíření PostgreSQL | SQL prostorové dotazy, složité geometrie | Standardní OGC, vyzrálé, spojení s relačními daty | Vertikální škálovatelnost, žádné nativní shlukování |
| H3 (Uber) | Hierarchická šestiúhelníková mřížka | Agregace podle oblasti, blízkosti, analytiky | Jednotné, hierarchické, O(1) vyhledávání | Pětiúhelníky na okrajích, přiblížení hranic |
| Geometrie S2 (Google) | Hierarchické kulovité buňky | Oblast pokrytí, dotaz na rozsah, sharding | Přesné pokrytí, používané v BigQuery/Spanner | Nejednotné čtyřúhelníkové buňky, komplexní API |
Geoprostorové dotazy s 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;
proč H3 pro PropTech?
H3 řeší zásadní problém: efektivní prostorovou agregaci. Místo počítání
v shluku reklam na mapě v reálném čase předem vypočítá index H3 každé nemovitosti
při vložení. Rozlišení 7 (~5,16 km²) je ideální pro výhled na město, rozlišení 9
(~0,105 km²) na úrovni sousedství. Souhrnné dotazy jsou jednoduché
GROUP BY h3_index, řádově rychlejší než geometrické operace
v reálném čase.
Media Pipeline: Obrázky, půdorysy a virtuální prohlídky
Obrázky jsou nejvlivnějším faktorem při rozhodování uživatele kliknout na reklamu. Škálovací platforma zpracovává miliony obrázků denně, každý ve více formátech a usnesení. Průměrné potrubí musí být asynchronní, pružný a optimalizované pro rychlost dodání přes CDN.
Tok zpracování médií
- Nahrání: agent nahraje originální fotografie přes předem podepsanou URL do S3
- Ověření: ovládací formát, velikost, obsah NSFW (ML)
- Zpracování: generace varianty (miniatura 300px, střední 800px, velká 1600px, WebP/AVIF)
- Optimalizace: inteligentní komprese, citlivé odstranění EXIF metadat, vodoznak
- Obohacení ML: klasifikace místností (kuchyň, koupelna, obývací pokoj), skóre kvality, průzkum půdorysu
- Distribuce CDN: push to CDN okrajové uzly pro doručení <50 ms globálně
- Indexování: aktualizace metadat v indexu vyhledávání
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,
};
}
}
Funkce v reálném čase
Moderní realitní platformy vyžadují schopnosti v reálném čase, které přesahují jednoduchá aktualizace stránky. Uživatelé to očekávají push notifikace při zveřejnění nemovitosti v oblasti jejich zájmu, zasílání zpráv okamžitě s agenty a upozornění na cenu když nemovitost uložený prochází změnami.
Systém upozornění a upozornění
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()}`;
}
}
Návrh API: RESTful Endpoints
Veřejné API platformy se řídí principy REST s verzováním v cestě, stránkování
výsledky založené na kurzoru a podpora více jazyků prostřednictvím záhlaví Accept-Language.
Zde jsou hlavní koncové body uspořádané podle domény.
| Metoda | Koncové body | Popis | Auth |
|---|---|---|---|
GET |
/api/v1/listings |
Hledejte reklamy pomocí filtrů | Veřejnost |
GET |
/api/v1/listings/{id} |
Detail jedné reklamy | Veřejnost |
POST |
/api/v1/listings |
Vytvořit novou reklamu | Činidlo |
PATCH |
/api/v1/listings/{id} |
Aktualizovat inzerát (cena, stav, foto) | Agent (majitel) |
POST |
/api/v1/search |
Pokročilé vyhledávání s geo-filtry | Veřejnost |
POST |
/api/v1/search/map |
Cluster na zobrazení mapy (agregace H3) | Veřejnost |
GET |
/api/v1/search/suggest |
Automatické doplňování míst a čtvrtí | Veřejnost |
POST |
/api/v1/users/{id}/favorites |
Uložit reklamu do oblíbených | Uživatel |
POST |
/api/v1/users/{id}/saved-searches |
Uložit vyhledávání pro upozornění | Uživatel |
POST |
/api/v1/messages |
Odeslat zprávu agentovi | Uživatel |
GET |
/api/v1/agents/{id} |
Profil agenta s hodnocením | Veřejnost |
POST |
/api/v1/media/upload-url |
Vygenerujte předepsanou adresu URL pro nahrání | Činidlo |
GET |
/api/v1/valuations/estimate |
Odhad tržní ceny | Veřejnost |
Stránkování na základě kurzoru
U velkých datových sad se stránkování založené na posunu (page/pageSize) zhoršuje rychle. Adoptujeme stránkování založené na kurzoru pro rozhraní API s vysokou návštěvností, zaručující konstantní výkon bez ohledu na hloubku stránky.
{
"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 }
}
}
Představení Scala
Realitní platforma s miliony záznamů musí zvládat výrazné nárůsty návštěvnosti: vyhledávání se zvýší o 300–400 % v neděli večer a během období změny sezóny. Strategie výkon musí fungovat na všech úrovních architektury.
Víceúrovňové ukládání do mezipaměti
| Úroveň | Technologie | TTL | Použití | Typická míra návštěvnosti |
|---|---|---|---|---|
| L1 - Edge/CDN | CloudFront / Rychle | 5-60 min | Obrázky, CSS/JS, statické stránky | 90–95 % |
| L2 - API Gateway | Lak / Kong cache | 1-5 min | GET API odpovědi (podrobnosti výpisu, výsledky vyhledávání) | 60–75 % |
| L3 - Aplikace | Cluster Redis | 30s-15 min | Session, faset counts, geocoding, H3 agregations | 80–90 % |
| L4 - Dotazy | Elasticsearch se replikuje | Téměř v reálném čase | Přečtěte si odpovědi na vyhledávání, samostatný index pro návrhy | N/A (přečíst měřítko) |
| L5 - Databáze | PostgreSQL + pgbouncer | N/A | Sdružování připojení, čtení replik na sestavu | N/A |
Sdílení databáze podle regionu
S miliony geograficky rozmístěných nemovitostí, rozdělených podle regionu a strategie přirozenější pro realitní platformy. Každý fragment obsahuje data makroregionu, umožňující horizontální měřítko a zároveň dodržovat předpisy datová rezidence (GDPR).
- Shard Key: zeměpisná oblast (např.
country:region=IT:lombardia) - Směrování: rozhraní API brány určuje fragment na základě umístění dotazu
- Křížové dotazy: scatter-gather pro vyhledávání ve více oblastech (zřídka)
- Vyvažování: když region překročí práh, rozdělí se (např. z Milána se stane vyhrazený úlomek)
Vzor CQRS pro oddělení čtení/zápisu
Vzor CQRS (oddělení odpovědnosti za příkazový dotaz) a zvláště efektivní pro realitní platformy, kde je poměr čtení/zápis typicky 100:1. The písma (tvorba inzerátů, aktualizace cen) procházejí Službou Katalog s PostgreSQL jako zdrojem pravdy. The čtení (výzkum, detail výpisu) byly užitečné z Elasticsearch a Redis, aktualizované asynchronně prostřednictvím událostí. Toto oddělení umožňuje optimalizovat obě cesty nezávisle.
Vícejazyčnost a více měn
Platformy, které působí na mezinárodních trzích (jako Idealista ve Španělsku, Itálii a Portugalsku) potřebují spravovat obsah ve více jazycích a ceny v různých měnách. Strategie se liší podle základny na typ obsahu.
Strategie typu obsahu
- Statické uživatelské rozhraní: překladové soubory (i18n) načtené z frontendu, jeden jazyk za balíček (Angular i18n, React i18next)
-
Popisy reklam: pole
LocalizedTextv DB, překlad automaticky přes DeepL/Google Translate s příznakem „automaticky přeloženo“. - adresy: nepřeloženy (zůstávají v místním jazyce), ale město může mají variace (Milán/Milán, Řím/Řím)
- Ceny: uloženy v původní měně, převod za běhu pro zobrazení se sazbami aktualizovanými pomocí API (ECB, otevřené směnné kurzy)
-
SEO: Lokalizované adresy URL (
/it/vendita/milanovs/en/sale/milan) se značkamihreflangaby se předešlo duplicitnímu obsahu
Soulad: GDPR a Fair Housing
Realitní platformy zpracovávají vysoce citlivá data: preference bydlení, možnosti finanční, výzkumná historie. Soulad není možnost, je to architektonický požadavek který ovlivňuje návrh systému na všech úrovních.
GDPR: Dopad na architekturu
-
Minimalizace dat: shromažďovat pouze údaje nezbytně nutné.
Neobsluhovat pole
viewCountve veřejné odpovědi nesledujte polohu GPS uživatele bez výslovného souhlasu. -
Právo na vymazání: nářadí
DELETE /api/v1/users/{id}/datakterá do 30 dnů smaže profily, uložená vyhledávání, zprávy a anonymizuje protokoly. -
Přenositelnost dat:
GET /api/v1/users/{id}/exportgenerovat archiv JSON/CSV se všemi uživatelskými daty. - Správa souhlasu: pouze každý sledovač (GA4, heatmap, remarketing) aktivován po výslovném souhlasu s podrobnostmi podle kategorií.
- Sídlo údajů: sharding podle regionů usnadňuje splnění požadavku že data uživatelů z EU zůstávají v datových centrech EU.
Spravedlivé bydlení a diskriminace
V USA se Zákon o spravedlivém bydlení zakazuje diskriminaci při prodeji a pronájmu nemovitostí na základě rasy, barvy pleti, náboženství, pohlaví, handicapu, rodinného stavu nebo původu národní. Technické důsledky:
- Filtry vyhledávání: nikdy nenabízejte filtry, které mohou fungovat jako proxy chráněné charakteristiky (např. „demografické složení okolí“)
- Cílení reklam: Sponzorované cílení reklam byste neměli používat chráněná demografická kritéria (Facebook za to zaplatil 5 milionů dolarů v roce 2022)
- ML modely: e. pravidelné audity zkreslení modelů oceňování doporučení. Model, který systematicky podhodnocuje nemovitosti v sousedství a menšinovou většinou a nejen nespravedlivé a nezákonné.
- Popisy reklam: automatický filtr pro diskriminační jazyk v popisech (např. „ideální pro páry bez dětí“ porušuje zákon o spravedlivém bydlení)
Varování: Předpojatost v modelech hodnocení
Modely ML oceňování nemovitostí (jako je „Zestimate“) mohou zesílit historické předsudky. Pokud tréninková data odrážejí desetiletí diskriminace na trhu (redlining), model bude reprodukovat tyto nerovnosti. Je nezbytné implementovat spravedlnost audity s metrikami spravedlnosti pomocí demografického a průběžného sledování předpovědí podle oblastí.
Závěry a referenční architektura
Vybudování rozsáhlé realitní platformy a její realizace návrh systému který se dotýká téměř každé oblasti softwarového inženýrství: od komplexního modelování domén po geoprostorový výzkum, od správy multimediálních kanálů až po dodržování předpisů přísné.
Klíčové principy, které vyplynuly z této analýzy, jsou:
- Dekompozice domény: jasně oddělený záznam, vyhledávání, média, Uživatel a upozornění v nezávislých službách s vyhrazenými databázemi
- CQRS jako základní vzorec: s poměrem čtení/zápis 100:1, samostatné cesty pro čtení (Elasticsearch) a zápis (PostgreSQL), nikoli optimalizace předčasná a architektonická nutnost
- Geoprostor jako prvotřídní občan: H3 pro agregaci, PostGIS pro přesnost, Elasticsearch geo_point pro full-stack vyhledávání
- Asynchronní potrubí: příjem reklam, zpracování médií a upozornění musí být řízeny událostmi, aby zvládly špičky bez zhoršení uživatelské zkušenosti
- Shoda podle návrhu: GDPR a spravedlivé bydlení nejsou funkce, které by bylo třeba přidat později, ale architektonická omezení, která vedou volby od prvního dne
V dalším článku série se podrobně podíváme na hodnotící modely nemovitosti založené na strojovém učení, analyzující, jak Zillow, Redfin a Evropské platformy odhadují tržní ceny pomocí Automated Valuation Models (AVM). jaké technické a etické výzvy tato schopnost přináší.
Zdroje a statistiky
- RETURN Web API: reso.org – standard pro výměnu dat MLS
- H3: h3geo.org – hexagonální geoprostorový indexovací systém Uberu
- Elasticsearch Geospatial: elastic.co/docs – dokumentace geografických dotazů
- PostGIS: postgis.net - Geoprostorové rozšíření pro PostgreSQL
- GDPR pro PropTech: Pokyny EDPB o profilování a automatizovaném rozhodování
- Zákon o spravedlivém bydlení: hud.gov - americký antidiskriminační zákon







