Arhitectura unei platforme imobiliare la Scala
Platforme imobiliare moderne precum Zillow, Idealist, Immobiliare.it e Mișcare la dreapta gestionați milioane de anunțuri, miliarde de căutări lunare și fluxuri de date în timp real de la agenții, MLS-uri (Serviciul de listări multiple) și proprietari privați. Proiectați un sistem capabil să susțină această sarcină necesită decizii arhitecturale precise pe fiecare strat al stivei: de la modelarea datelor la cercetarea geospațială, de la conductă multimedia la sistem notificări în timp real.
În acest articol vom construi arhitectura completă a unei platforme PropTech la scară, analizând fiecare componentă cu exemple de cod în TypeScript e Piton, comparații între tehnologii și modele de design dovedite în producție.
Ce vei învăța
- Arhitectură de microservicii pentru platforme imobiliare cu descompunere de domenii
- Model complet de date: proprietăți, listări, agenți, tranzacții, media
- Căutare geospațială avansată cu Elasticsearch, PostGIS și H3
- Conductă de asimilare a anunțurilor din surse eterogene (API Web MLS/RESO, scraping, feed XML)
- Canal media: procesare imagini, planuri, tururi virtuale 3D
- Funcționalitate în timp real: notificări, mesagerie, alerte de preț
- Strategii de performanță la scară: caching, CDN, sharding, CQRS
- Conformitate: GDPR, locuințe echitabile, reglementări regionale
Peisajul platformelor imobiliare
Înainte de a proiecta arhitectura, este esențial să înțelegem peisajul competitiv și modele de afaceri care ghidează alegerile tehnice. Fiecare platformă are un amestec unic de funcționalitate care influențează direct deciziile privind infrastructura.
| Platformă | Piaţă | Reclame active | Caracteristica distinctivă | Stivă cunoscută |
|---|---|---|---|---|
| Zillow | STATELE UNITE ALE AMERICII | ~135M de proprietăți | Zestimate (evaluare ML) | Java, Kafka, Elasticsearch |
| Idealist | UE (ES, IT, PT) | ~1,8 milioane de anunțuri | Căutare interactivă pe hartă | Java, Solr, PostgreSQL |
| Immobiliare.it | Italia | ~1,2 milioane de anunțuri | Evaluare automată, hartă termică | PHP/Go, Elasticsearch |
| Mișcare la dreapta | UK | ~1 milion de anunțuri | Desenați o căutare (desenarea zonei) | .NET, SQL Server, Azure |
| Redfin | STATELE UNITE ALE AMERICII | ~100 de milioane de proprietăți | Agenți integrați, tururi 3D | Java, React, Kafka |
Modele de monetizare și impact tehnic
Modelul de afaceri influențează direct arhitectura. O platformă freemium cu listări sponsorizate nevoie de un motor de difuzare a anunțurilor, A/B testarea și urmărirea afișărilor. Un model pe bază de plumb necesită rutare contacte inteligente cu agenții și CRM integrat. Un model tranzacționale (iBuying) implică pipeline de evaluare ML, management conformitate financiară și avansată a reglementărilor.
- Listări sponsorizate: necesită un motor de clasare a anunțurilor, un sistem de licitare și analize pentru rentabilitatea investiției
- Abonament la agentie: managementul nivelurilor, facturarea recurentă, tabloul de bord pentru agenți
- Generarea de lead-uri: scorul potențial, rutarea inteligentă, urmărirea atribuirii
- Tranzacțional (iBuying): Modele de evaluare ML, managementul ofertei, pipeline legal
- SaaS pentru agenții: multi-tenancy, etichetă albă, API pentru integrări CRM
Arhitectură la nivel înalt
O platformă imobiliară cu scară adoptă o arhitectură Microservicii orientate pe domenii, unde fiecare serviciu are propria sa bază de date și comunică prin evenimente asincrone. Această abordare vă permite să scalați în mod independent componentele cu o sarcină mai mare (de obicei, căutare și API-uri public) fără a afecta serviciile mai puțin solicitate.
Principiul călăuzitor: modelul smochinului strangler
Majoritatea platformelor imobiliare încep ca monoliți. Migrarea la microservicii apare treptat prin Model Smochin Strangler: serviciile sunt extrase mai întâi la sarcina mare (cercetare, media) si apoi progresiv cele ramase, pastrand monolitul in functiune pe toată durata tranziției.
Descompunerea în microservicii
| Serviciu | Responsabilitate | Baze de date | Modele |
|---|---|---|---|
| Serviciul de listare | Reclame CRUD, validare, flux de lucru de publicare | PostgreSQL + PostGIS | CQRS, Event Sourcing |
| Serviciu de căutare | Căutare full-text, filtre, geospațial, fațetate | Elasticsearch/OpenSearch | Citiți modelul (CQRS) |
| Serviciul pentru utilizatori | Autentificare, profiluri, preferințe, salvate | PostgreSQL | OAuth 2.0 / OIDC |
| Serviciu agent | Profile de agenți, agenții, rating, disponibilitate | PostgreSQL | Serviciul de domeniu |
| Serviciul Media | Încărcare, procesare, optimizare, CDN | S3 + DynamoDB (metadate) | Conductă asincronă |
| Serviciul de mesagerie | Chat agent-utilizator, solicitări de informații, notificări | MongoDB / Cassandra | WebSocket + Event Driven |
| Serviciul de notificare | E-mail, push, SMS, alerte de preț, anunțuri noi | Redis + PostgreSQL | Fan-out, Template Engine |
| Serviciul de analiză | Urmărirea vizitelor, hărți termice, rapoarte pentru agenți | ClickHouse / BigQuery | Streaming evenimente |
| Serviciul de ingestie | Import din MLS, fluxuri XML, API-uri externe | Staging DB + coadă | Conducta ETL |
| Serviciul de evaluare | Estimarea prețului ML, comparabile, tendințele pieței | Magazin de caracteristici + Registrul de modele | ML Pipeline |
Diagrama arhitecturii
+------------------+
| 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) |
+---------------+ +---------------+
Model de date: inima platformei
Modelul de date al unei platforme imobiliare este surprinzător de complex. Un singur proprietate fizica poate avea multipli reclame în timp, să fie gestionat de diferite agenţi, urcă tranzacții și generează sute de active multimedia. Proiectați aceste relații corect și critic pentru performanța și consistența datelor.
Schema entității principale
// 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';
Câteva aspecte cheie ale acestui model:
- Separare proprietate/lista: o proprietate fizică există independent din reclame. Același apartament poate fi scos la vânzare, apoi retras, apoi închiriat, generând listări distincte legate de aceeași entitate.
-
Imuabilitate: toate interfețele folosesc
readonlya preveni mutații accidentale. Tranzițiile de stare produc obiecte noi. - Index H3: precalculat la inserare pentru a permite căutările geospațiale eficient prin agregare hexagonală.
-
Multi-valută și mai multe limbi: suport nativ prin
MoneyeLocalizedTextpentru piețele internaționale.
Conducta de absorbție a anunțurilor
Inima care bate a unei platforme imobiliare și conducta de absorbție de date. Reclamele provin din surse eterogene: furaje RESO Web API (Standard SUA/International), Fluxuri XML proprietare, API-uri de agenție, încărcări manuale și scraping portal partener. Fiecare sursă are propriul format, rata de actualizare și nivelul de calitate a datelor.
RESO Web API: Standardul industriei
Il API Web RESO (Real Estate Standards Organization). și standardul modern pentru schimbul de date MLS, bazat pe REST/OData cu sarcină utilă JSON. Înlocuiește vechiul RETS (acum depreciat). Dicționarul de date 2.x definește nume standard pentru câmpuri (ListingId, StandardStatus, ListPrice, LivingArea) asigurând interoperabilitatea între sisteme.
Arhitectura conductei 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[] }>;
}
Normalizare: de la RETURN la format intern
Normalizarea este pasul cel mai critic în conductă. Fiecare sursă de date are un format diferite și normalizarea trebuie să mapați câmpuri eterogene într-un model uniform. Iată un exemplu de normalizator pentru formatul 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(', ');
}
}
Arhitectura de căutare: Elasticsearch și Geospatial
Cea mai critică căutare și funcționalitate a oricărei platforme imobiliare. Utilizatorii fac așteptați rezultate instantanee, filtre combinabile, sortare după relevanță/preț/distanță și căutare pe hărți cu actualizare în timp real. Elasticsearch (sau furculița ei OpenSearch) și alegerea dominantă în industrie datorită sprijinului nativ pentru căutări full-text, geospațiale și fațetate.
Elasticsearch Index pentru reclame
{
"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_" }
}
}
}
}
Căutare API cu filtre geospațiale
Interogarea de căutare combină filtre booleene, intervale numerice, căutare full-text și constrângeri geospațială. Acceptăm trei moduri de căutare geografică: distanta_geo (raza de la un punct), geo_bounding_box (dreptunghi pe hartă) e geo_formă (poligon desenat de utilizator, cum ar fi „Draw-a-search” de la 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,
},
};
}
}
Indexare geospațială: PostGIS, H3 și S2
Pe lângă Elasticsearch, stratul de persistență primar necesită capacități geospațiale avansat pentru operațiuni precum calcularea distanțelor precise, agregarea pe zone și analiza pietei. Trei tehnologii domină acest spațiu.
| Tehnologie | Tip | Utilizare primară | Avantaje | Limite |
|---|---|---|---|---|
| PostGIS | Extensia PostgreSQL | Interogări spațiale SQL, geometrii complexe | OGC standard, matur, se unește cu date relaționale | Scalabilitate verticală, fără clustering nativ |
| H3 (Uber) | Grilă hexagonală ierarhică | Agregare pe zonă, proximitate, analiză | Căutare uniformă, ierarhică, O(1). | Pentagoane la margini, aproximare a granițelor |
| S2 Geometrie (Google) | Celule sferice ierarhice | Acoperire regiune, interogare de interval, fragmentare | Acoperire exactă, utilizată în BigQuery/Spanner | Celule patrulatere neuniforme, API complexe |
Interogări geospațiale cu 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;
de ce H3 pentru PropTech?
H3 rezolvă o problemă fundamentală: agregarea spațială eficientă. În loc să calculeze
în grupul de anunțuri în timp real de pe hartă, precalculează indicele H3 al fiecărei proprietăți
la inserare. Rezoluția 7 (~5,16 km²) este ideală pentru vederile orașului, rezoluția 9
(~0,105 km²) pentru nivelul cartierului. Interogări agregate simplificate
GROUP BY h3_index, ordine de mărime mai rapid decât operațiile geometrice
în timp real.
Media Pipeline: imagini, planuri și tururi virtuale
Imaginile sunt cel mai influent factor în decizia unui utilizator de a face clic pe un anunț. O platformă la scară procesează milioane de imagini pe zi, fiecare în mai multe formate și rezoluții. Conducta medie trebuie să fie asincron, rezistent și optimizat pentru viteza de livrare prin CDN.
Fluxul de procesare media
- Încărcări: agentul încarcă fotografiile originale prin adresa URL presemnată pe S3
- Validare: format de control, dimensiune, conținut NSFW (ML)
- Prelucrare: generare de variante (miniatură 300px, medie 800px, mare 1600px, WebP/AVIF)
- Optimizare: compresie inteligentă, eliminare sensibilă a metadatelor EXIF, filigran
- Îmbogățirea ML: clasificare camere (bucatarie, baie, sufragerie), scor calitate, sondaj plan
- Distribuție CDN: push la nodurile de margine CDN pentru livrare <50 ms la nivel global
- Indexare: actualizarea metadatelor în indexul de căutare
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,
};
}
}
Funcționalitate în timp real
Platformele imobiliare moderne necesită capacități în timp real care depășesc actualizare simplă a paginii. Utilizatorii se așteaptă la asta notificări push atunci când o proprietate din zona lor de interes este publicată, mesagerie instant cu agenţii şi alerta de pret când o proprietate salvate suferă modificări.
Sistem de alertă și notificare
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()}`;
}
}
Design API: puncte finale RESTful
API-ul public al platformei urmează principiile REST cu versiunea în cale, paginare
rezultate bazate pe cursor și suport în mai multe limbi prin antet Accept-Language.
Iată principalele puncte finale organizate pe domenii.
| Metodă | Puncte finale | Descriere | Auth |
|---|---|---|---|
GET |
/api/v1/listings |
Căutați anunțuri cu filtre | Public |
GET |
/api/v1/listings/{id} |
Detaliu un singur anunț | Public |
POST |
/api/v1/listings |
Creați un anunț nou | Agent |
PATCH |
/api/v1/listings/{id} |
Actualizați anunț (preț, stare, fotografie) | Agent (proprietar) |
POST |
/api/v1/search |
Căutare avansată cu filtre geografice | Public |
POST |
/api/v1/search/map |
Cluster per vizualizare pe hartă (agregare H3) | Public |
GET |
/api/v1/search/suggest |
Completați automat locațiile și cartierele | Public |
POST |
/api/v1/users/{id}/favorites |
Salvați anunțul la favorite | Utilizator |
POST |
/api/v1/users/{id}/saved-searches |
Salvați căutarea pentru alertă | Utilizator |
POST |
/api/v1/messages |
Trimite mesaj unui agent | Utilizator |
GET |
/api/v1/agents/{id} |
Profil de agent cu evaluări | Public |
POST |
/api/v1/media/upload-url |
Generați adresa URL semnată pentru încărcare | Agent |
GET |
/api/v1/valuations/estimate |
Estimarea pretului de piata | Public |
Paginare bazată pe cursor
Pentru seturi de date mari, paginarea bazată pe offset (page/pageSize) se degradează repede. Adoptăm paginarea bazată pe cursor pentru API-uri cu trafic ridicat, garantând performanță constantă indiferent de adâncimea paginii.
{
"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 }
}
}
Scala Performance
O platformă imobiliară cu milioane de înregistrări trebuie să gestioneze creșteri semnificative de trafic: căutările cresc cu 300-400% duminica seara și în perioadele de schimbare a sezonului. Strategiile performanța trebuie să funcționeze la fiecare nivel al arhitecturii.
Memorarea în cache pe mai multe niveluri
| Nivel | Tehnologie | TTL | Utilizare | Rata de lovituri tipică |
|---|---|---|---|---|
| L1 - Edge/CDN | CloudFront / Rapid | 5-60 min | Imagini, CSS/JS, pagini statice | 90-95% |
| L2 - Gateway API | Lac / cache Kong | 1-5 min | Răspunsuri GET API (detalii privind listarea, rezultatele căutării) | 60-75% |
| L3 - Aplicație | Clusterul Redis | 30s-15 min | Sesiune, numărătoare de fațete, geocodare, agregare H3 | 80-90% |
| L4 - Interogări | Elasticsearch se repetă | Aproape în timp real | Citiți răspunsurile pentru căutări, index separat pentru sugestii | N/A (citiți scalarea) |
| L5 - Baze de date | PostgreSQL + pgbouncer | N / A | Pooling de conexiuni, citiți replici per raport | N / A |
Partajarea bazei de date în funcție de regiune
Cu milioane de proprietăți distribuite geografic, sharding pe regiune și strategie mai natural pentru platformele imobiliare. Fiecare fragment conține datele unei macroregiuni, permițându-vă să scalați orizontal și, în același timp, să respectați reglementările rezidența datelor (GDPR).
- Cheie fragment: regiune geografică (de ex.
country:region=IT:lombardia) - rutare: API-ul gateway determină fragmentul pe baza locației interogării
- Interogări încrucișate: scatter-gather pentru căutări în mai multe regiuni (rar)
- Reechilibrare: când o regiune depășește pragul, aceasta se împarte (de exemplu, Milano devine un ciob dedicat)
Model CQRS pentru separarea citire/scriere
Modelul CQRS (Segregarea responsabilității interogărilor de comandă) si mai ales eficient pentru platformele imobiliare unde raportul citire/scriere este de obicei 100:1. The scripturi (crearea de reclame, actualizări de preț) trec prin Serviciul de Listare cu PostgreSQL ca sursă de adevăr. The lecturi (cercetare, detaliu de listare) au fost utile de la Elasticsearch și Redis, actualizate asincron prin evenimente. Această decuplare vă permite să optimizați cele două căi în mod independent.
Multi-limbi și mai multe valute
Platforme care operează pe piețele internaționale (cum ar fi Idealista în Spania, Italia și Portugalia) trebuie să gestioneze conținutul în mai multe limbi și să stabilească prețuri în diferite valute. Strategia variază în funcție de bază la tipul de conținut.
Strategia tipului de conținut
- UI statică: fișiere de traducere (i18n) încărcate de pe front-end, o singură limbă per pachet (Angular i18n, React i18next)
-
Descrieri de anunțuri: domeniu
LocalizedTextîn DB, traducere automat prin DeepL/Google Translate cu steag „tradus automat”. - Adrese: nu traduse (răman în limba locală), dar orașul poate au variații (Milano/Milano, Roma/Roma)
- Preturi: stocate în moneda originală, conversie din mers pentru afișare cu rate actualizate de API (ECB, Rate de schimb deschise)
-
SEO: Adrese URL localizate (
/it/vendita/milanovs/en/sale/milan) cu etichetehreflangpentru a evita conținutul duplicat
Conformitate: GDPR și locuințe echitabile
Platformele imobiliare procesează date extrem de sensibile: preferințe de locuințe, capacități financiar, istorie de cercetare. Conformitatea nu este o opțiune, este o cerință arhitecturală care influențează proiectarea sistemului la fiecare nivel.
GDPR: Impact asupra arhitecturii
-
Minimizarea datelor: colectează doar datele strict necesare.
Nu servi câmpul
viewCountîn răspunsul public, nu urmăriți locația GPS-ul utilizatorului fără consimțământul explicit. -
Dreptul la ștergere: implementează
DELETE /api/v1/users/{id}/datacare șterge profilurile, căutările salvate, mesajele și anonimizează jurnalele în decurs de 30 de zile. -
Portabilitatea datelor:
GET /api/v1/users/{id}/exportgenera o arhivă JSON/CSV cu toate datele utilizatorului. - Gestionarea consimțământului: fiecare tracker (GA4, heatmap, remarketing) activat numai după consimțământul explicit, cu granularitate pe categorii.
- Reședința datelor: sharding pe regiune facilitează conformitatea cu cerința că datele utilizatorilor din UE rămân în centrele de date ale UE.
Locuințe echitabile și discriminare
În SUA, Legea privind locuințele echitabile interzice discriminarea în vânzări și închirieri de bunuri imobiliare bazate pe rasă, culoare, religie, sex, handicap, statut familial sau origine naţională. Implicații tehnice:
- Filtre de căutare: nu oferi niciodată filtre care pot acționa ca un proxy pentru caracteristici protejate (de exemplu, „compoziția demografică a cartierului”)
- Direcționarea anunțurilor: Direcționarea anunțurilor sponsorizate nu ar trebui să fie utilizată criterii demografice protejate (Facebook a plătit 5 milioane USD în 2022 pentru asta)
- Modele ML: audituri regulate pentru părtinire în modelele de evaluare e recomandare. Un model care subevaluează sistematic proprietățile din cartiere a majoritar minoritar si nu numai nedrept, si ilegal.
- Descrieri de anunțuri: filtru automat pentru limbaj discriminatoriu în descrieri (de exemplu, „ideal pentru cupluri fără copii” încalcă Legea privind locuințele echitabile)
Avertisment: părtinire în modelele de evaluare
Modelele ML de evaluare imobiliară (cum ar fi „Zestimate”) pot amplifica părtinirile istorice. Dacă datele de instruire reflectă zeci de ani de discriminare pe piață (redlining), modelul va reproduce aceste inegalități. Este esențial de implementat corectitudine audituri cu valori de echitate prin monitorizare demografică și continuă de previziuni pe zone.
Concluzii și arhitectură de referință
Construirea unei platforme imobiliare la scară și un exercițiu de proiectarea sistemului care atinge aproape fiecare domeniu al ingineriei software: de la modelarea domeniilor complexe la cercetarea geospațială, de la gestionarea conductelor multimedia până la respectarea reglementărilor stringente.
Principiile cheie care au reieșit din această analiză sunt:
- Descompunerea domeniului: separați în mod clar Listare, Căutare, Media, Utilizator și Notificare în servicii independente cu baze de date dedicate
- CQRS ca model fundamental: cu un raport de citire/scriere de 100:1, căi separate de citire (Elasticsearch) și scriere (PostgreSQL) și nu o optimizare prematură și o necesitate arhitecturală
- Geospațial ca cetățean de primă clasă: H3 pentru agregare, PostGIS pentru precizie, Elasticsearch geo_point pentru căutare completă
- Conducte asincrone: asimilare de reclame, procesare media și notificări acestea trebuie să fie determinate de evenimente pentru a gestiona vârfurile fără a degrada experiența utilizatorului
- Conformitate prin proiectare: GDPR și locuința echitabilă nu sunt caracteristici de adăugat mai târziu, dar constrângeri arhitecturale care ghidează alegerile din prima zi
În următorul articol din serie vom explora în detaliu modele de evaluare imobiliare bazate pe Machine Learning, analizând modul în care Zillow, Redfin și Platformele europene estimează prețurile pieței cu modele automatizate de evaluare (AVM) e ce provocări tehnice și etice implică această capacitate.
Resurse și perspective
- API-ul web RETURN: reso.org - Standard pentru schimbul de date MLS
- H3: h3geo.org - Sistemul de indexare geospațială hexagonală al Uber
- Elasticsearch Geospatial: elastic.co/docs - Documentație de interogare geografică
- PostGIS: postgis.net - Extensie geospațială pentru PostgreSQL
- GDPR pentru PropTech: Orientări ale EDPB privind profilarea și procesul decizional automatizat
- Legea privind locuințele echitabile: hud.gov - Legea anti-discriminare din SUA







