Architectuur van een vastgoedplatform in Scala
Moderne vastgoedplatforms zoals Zillow, Idealist, Immobiliare.it e Rechtse beweging beheer miljoenen advertenties, miljarden maandelijkse zoekopdrachten en realtime datastromen van bureaus, MLS's (Multiple Listing Service) en particuliere eigenaren. Ontwerp een systeem dat dit kan volhouden deze belasting vereist nauwkeurige architecturale beslissingen op elke laag van de stapel: van datamodellering tot georuimtelijk onderzoek, van multimediapijplijn tot systeem realtime meldingen.
In dit artikel zullen we de volledige architectuur van een PropTech-platform op schaal bouwen, het analyseren van elke component met codevoorbeelden erin Typescript e Python, vergelijkingen tussen technologieën en ontwerppatronen die zich in de productie hebben bewezen.
Wat je gaat leren
- Microservices-architectuur voor vastgoedplatforms met domeindecompositie
- Compleet datamodel: eigendommen, lijsten, agenten, transacties, media
- Geavanceerd georuimtelijk zoeken met Elasticsearch, PostGIS en H3
- Pijplijn voor advertentie-opname vanuit heterogene bronnen (MLS/RESO Web API, scraping, XML-feed)
- Mediapijplijn: beeldverwerking, plattegronden, virtuele 3D-rondleidingen
- Realtime functionaliteit: meldingen, berichten, prijswaarschuwingen
- Prestatiestrategieën op schaal: caching, CDN, sharding, CQRS
- Naleving: AVG, eerlijke huisvesting, regionale regelgeving
Het landschap van vastgoedplatforms
Voordat de architectuur wordt ontworpen, is het essentieel om het concurrentielandschap en de concurrentie te begrijpen bedrijfsmodellen die technische keuzes sturen. Elk platform heeft een unieke mix van functionaliteit die rechtstreeks van invloed is op infrastructuurbeslissingen.
| Platform | Markt | Actieve advertenties | Onderscheidend kenmerk | Bekende stapel |
|---|---|---|---|---|
| Zillow | VS | ~135 miljoen eigendommen | Z estimate (ML-beoordeling) | Java, Kafka, Elasticsearch |
| Idealist | EU (ES, IT, PT) | ~1,8 miljoen advertenties | Interactieve kaartzoekopdracht | Java, Solr, PostgreSQL |
| Immobiliare.it | Italië | ~1,2 miljoen advertenties | Automatische evaluatie, heatmap | PHP/Go, Elasticsearch |
| Rechtse beweging | UK | ~1 miljoen advertenties | Draw-a-search (gebied tekenen) | .NET, SQL Server, Azure |
| Roodvin | VS | ~100 miljoen eigendommen | Geïntegreerde agenten, 3D-rondleidingen | Java, React, Kafka |
Monetisatiemodellen en technische impact
Het businessmodel heeft rechtstreeks invloed op de architectuur. Een platform freemium met gesponsorde vermeldingen behoefte aan een advertentieweergave-engine, A/B testen en bijhouden van vertoningen. Een model op lood gebaseerd vereist routering intelligente contacten met agenten en geïntegreerde CRM. Een model transactioneel (iBuying) omvat de evaluatiepijplijn en het beheer van ML financiële en geavanceerde naleving van de regelgeving.
- Gesponsorde vermeldingen: vereist een advertentierangschikkingsengine, biedsysteem en analyses voor ROI
- Agentschap abonnement: niveaubeheer, terugkerende facturering, agentendashboard
- Leadgeneratie: leadscores, intelligente routing, attributietracking
- Transactioneel (iBuying): ML-evaluatiemodellen, aanbiedingsbeheer, juridische pijplijn
- SaaS voor bureaus: multi-tenancy, white-label, API voor CRM-integraties
Architectuur op hoog niveau
Een laddervastgoedplatform hanteert een architectuur Domeingerichte microservices, waarbij elke service zijn eigen database heeft en communiceert via asynchrone gebeurtenissen. Deze aanpak Hiermee kunt u componenten onafhankelijk schalen onder grotere belasting (meestal zoeken en API's). publiek) zonder gevolgen voor de minder gevraagde diensten.
Leidend principe: Strangler Fig-patroon
De meeste vastgoedplatforms beginnen als monolieten. De migratie naar microservices gebeurt geleidelijk via de Strangler Fig-patroon: services worden eerst geëxtraheerd bij hoge belasting (onderzoek, media) en vervolgens geleidelijk aan de overige, waarbij de monoliet blijft functioneren gedurende de gehele transitie.
Ontleding in microservices
| Dienst | Verantwoordelijkheid | Databases | Patronen |
|---|---|---|---|
| Lijstservice | CRUD-advertenties, validatie, publicatieworkflow | PostgreSQL + PostGIS | CQRS, evenementensourcing |
| Zoekservice | Zoeken in volledige tekst, filters, georuimtelijk, gefacetteerd | Elasticsearch/OpenSearch | Model lezen (CQRS) |
| Gebruikersservice | Authenticatie, profielen, voorkeuren, opgeslagen | PostgreSQL | OAuth 2.0 / OIDC |
| Agentendienst | Agentprofielen, agentschappen, beoordelingen, beschikbaarheid | PostgreSQL | Domeinservice |
| Mediadienst | Uploaden, verwerken, optimaliseren, CDN | S3 + DynamoDB (metagegevens) | Asynchrone pijplijn |
| Berichtenservice | Chat tussen agent en gebruiker, informatieverzoeken, meldingen | MongoDB / Cassandra | WebSocket + gebeurtenisgestuurd |
| Meldingsservice | E-mail, push, sms, prijsalerts, nieuwe advertenties | Redis + PostgreSQL | Fan-out, sjabloonengine |
| Analyseservice | Bezoeken bijhouden, heatmaps, rapporten voor agenten | ClickHouse/BigQuery | Gebeurtenisstreaming |
| Innameservice | Importeren vanuit MLS, XML-feeds, externe API's | Staging DB + wachtrij | ETL-pijplijn |
| Taxatiedienst | ML-prijsschatting, vergelijkingen, markttrends | Feature Store + Modelregister | ML-pijplijn |
Architectuurdiagram
+------------------+
| 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) |
+---------------+ +---------------+
Datamodel: het hart van het platform
Het datamodel van een vastgoedplatform is verrassend complex. Een enkele eigendom natuurkunde kan veelvouden hebben advertenties na verloop van tijd worden beheerd door anders agenten, ga naar boven transacties en er honderden genereren multimedia-middelen. Ontwerp deze relaties correct en kritisch prestaties en gegevensconsistentie.
Hoofdentiteitsschema
// 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';
Enkele belangrijke aspecten van dit model:
- Scheiding eigendom/advertentie: een fysieke eigenschap bestaat onafhankelijk uit de advertenties. Hetzelfde appartement kan te koop worden aangeboden, vervolgens worden ingetrokken en vervolgens worden verhuurd, het genereren van afzonderlijke vermeldingen die aan dezelfde entiteit zijn gekoppeld.
-
Onveranderlijkheid: alle interfaces gebruiken
readonlyvoorkomen toevallige mutaties. Toestandsovergangen produceren nieuwe objecten. - H3-index: vooraf berekend bij het invoegen om geospatiale zoekopdrachten mogelijk te maken efficiënt via hexagonale aggregatie.
-
Meerdere valuta en meerdere talen: native ondersteuning via
MoneyeLocalizedTextvoor internationale markten.
Pijplijn voor advertentie-opname
Het kloppende hart van een vastgoedplatform en de data-innamepijplijn. De advertenties ze komen uit heterogene bronnen: feeds RESO Web-API (Amerikaanse/internationale standaard), Eigen XML-feeds, API's van bureaus, handmatige uploads en scraping van partnerportals. Elke bron het heeft zijn eigen formaat, updatesnelheid en gegevenskwaliteitsniveau.
RESO Web API: de industriestandaard
Il RESO (Real Estate Standards Organization) web-API en de moderne standaard voor MLS-gegevensuitwisseling, gebaseerd op REST/OData met JSON-payload. Vervangt de oude RETS (nu verouderd). Data Dictionary 2.x definieert standaardnamen voor velden (ListingId, StandardStatus, ListPrice, LivingArea) zorgen voor interoperabiliteit tussen systemen.
ETL-pijplijnarchitectuur
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[] }>;
}
Normalisatie: van RETURN naar intern formaat
Normalisatie is de meest kritische stap in de pijplijn. Elke gegevensbron heeft een formaat verschillend en de normalisatie moet heterogene velden in een uniform model in kaart brengen. Hier is een voorbeeld van normalisatie voor het RESO-formaat:
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(', ');
}
}
Zoekarchitectuur: Elasticsearch en Geospatial
De meest kritische zoek- en functionaliteit van elk vastgoedplatform. Gebruikers wel verwacht onmiddellijke resultaten, combineerbare filters, sortering op relevantie/prijs/afstand en kaarten zoeken met realtime updates. Elastischzoeken (of zijn vork OpenZoeken) en de dominante keuze in de branche dankzij ondersteuning native voor volledige tekst-, geospatiale en gefacetteerde zoekopdrachten.
Elasticsearch-index voor advertenties
{
"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_" }
}
}
}
}
Zoek-API met georuimtelijke filters
De zoekopdracht combineert Booleaanse filters, numerieke bereiken, zoeken in volledige tekst en beperkingen georuimtelijk. We ondersteunen drie geografische zoekmodi: geo_afstand (straal vanaf een punt), geo_bounding_box (rechthoek op de kaart) e geo_vorm (door de gebruiker getekende polygoon, zoals Rightmove's "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,
},
};
}
}
Georuimtelijke indexering: PostGIS, H3 en S2
Naast Elasticsearch vereist de primaire persistentielaag georuimtelijke mogelijkheden geavanceerd voor bewerkingen zoals het berekenen van precieze afstanden, aggregeren per zone en marktanalyse. Drie technologieën domineren deze ruimte.
| Technologie | Type | Primair gebruik | Voordelen | Grenzen |
|---|---|---|---|---|
| PostGIS | PostgreSQL-extensie | SQL ruimtelijke queries, complexe geometrieën | Standaard OGC, volwassen, gecombineerd met relationele gegevens | Verticale schaalbaarheid, geen native clustering |
| H3 (Uber) | Hiërarchisch zeshoekig raster | Aggregatie per gebied, nabijheid, analyse | Uniform, hiërarchisch, O(1) opzoeken | Vijfhoeken aan de randen, grensbenadering |
| S2-geometrie (Google) | Hiërarchische bolvormige cellen | Regiodekking, bereikquery, sharding | Exacte dekking, gebruikt in BigQuery/Spanner | Niet-uniforme vierzijdige cellen, complexe API |
Georuimtelijke zoekopdrachten met 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;
waarom H3 voor PropTech?
H3 lost een fundamenteel probleem op: efficiënte ruimtelijke aggregatie. In plaats van berekenen
in een realtime cluster van advertenties op de kaart berekent het vooraf de H3-index van elke woning
bij het inbrengen. Resolutie 7 (~5,16 km²) is ideaal voor stadsgezichten, resolutie 9
(~0,105 km²) voor het buurtniveau. Geaggregeerde zoekopdrachten zijn eenvoudig gemaakt
GROUP BY h3_index, ordes van grootte sneller dan geometrische bewerkingen
in realtime.
Mediapijplijn: afbeeldingen, plattegronden en virtuele rondleidingen
Afbeeldingen zijn de meest invloedrijke factor bij de beslissing van een gebruiker om op een advertentie te klikken. Een schaalplatform verwerkt miljoenen afbeeldingen per dag, elk in meerdere formaten en resoluties. De gemiddelde pijpleiding moet dat zijn asynchroon, veerkrachtig en geoptimaliseerd voor levering snelheid via CDN.
Mediaverwerkingsstroom
- Uploads: de agent uploadt de originele foto's via een vooraf ondertekende URL naar S3
- Geldigmaking: controleformaat, grootte, inhoud NSFW (ML)
- Verwerking: variantgeneratie (miniatuur 300px, medium 800px, groot 1600px, WebP/AVIF)
- Optimalisatie: intelligente compressie, gevoelige verwijdering van EXIF-metagegevens, watermerk
- ML-verrijking: kamerindeling (keuken, badkamer, woonkamer), kwaliteitsscore, plattegrondoverzicht
- CDN-distributie: push naar CDN edge-nodes voor levering van minder dan 50 ms wereldwijd
- Indexering: metadata-update in zoekindex
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,
};
}
}
Realtime functionaliteit
Moderne vastgoedplatforms vereisen realtime mogelijkheden die verder gaan eenvoudige pagina-update. Gebruikers verwachten het pushmeldingen wanneer een woning in hun interessegebied wordt gepubliceerd, berichtenuitwisseling onmiddellijk met de agenten en prijs alert wanneer een eigendom opgeslagen ondergaat veranderingen.
Waarschuwings- en meldingssysteem
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-ontwerp: RESTful-eindpunten
De openbare API van het platform volgt de REST-principes met versiebeheer in pad en paginering
cursorgebaseerde resultaten en meertalige ondersteuning via header Accept-Language.
Hier vindt u de belangrijkste eindpunten, gerangschikt per domein.
| Methode | Eindpunten | Beschrijving | Aut |
|---|---|---|---|
GET |
/api/v1/listings |
Zoek advertenties met filters | Openbaar |
GET |
/api/v1/listings/{id} |
Eén advertentiedetail | Openbaar |
POST |
/api/v1/listings |
Nieuwe advertentie maken | Tussenpersoon |
PATCH |
/api/v1/listings/{id} |
Advertentie bijwerken (prijs, status, foto) | Makelaar (eigenaar) |
POST |
/api/v1/search |
Geavanceerd zoeken met geofilters | Openbaar |
POST |
/api/v1/search/map |
Cluster per kaartweergave (H3-aggregatie) | Openbaar |
GET |
/api/v1/search/suggest |
Locaties en buurten automatisch aanvullen | Openbaar |
POST |
/api/v1/users/{id}/favorites |
Advertentie opslaan in favorieten | Gebruiker |
POST |
/api/v1/users/{id}/saved-searches |
Zoekopdracht opslaan voor waarschuwing | Gebruiker |
POST |
/api/v1/messages |
Stuur een bericht naar een agent | Gebruiker |
GET |
/api/v1/agents/{id} |
Agentprofiel met beoordelingen | Openbaar |
POST |
/api/v1/media/upload-url |
Genereer een vooraf ondertekende URL voor uploaden | Tussenpersoon |
GET |
/api/v1/valuations/estimate |
Schatting van de marktprijs | Openbaar |
Cursorgebaseerde paginering
Voor grote datasets verslechtert de op offset gebaseerde paginering (page/pageSize). snel. Wij adopteren cursorgebaseerde paginering voor API's met veel verkeer, garanderen constante prestaties, ongeacht de diepte van de pagina.
{
"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-prestaties
Een vastgoedplatform met miljoenen vermeldingen moet aanzienlijke verkeerspieken kunnen verwerken: zoekopdrachten stijgen met 300-400% op zondagavond en tijdens seizoenswisselingen. De strategieën prestaties moeten op elk niveau van de architectuur werken.
Caching op meerdere niveaus
| Niveau | Technologie | TTL | Gebruik | Typische hitratio |
|---|---|---|---|---|
| L1 - Rand/CDN | CloudFront / Snel | 5-60 minuten | Afbeeldingen, CSS/JS, statische pagina's | 90-95% |
| L2 - API-gateway | Vernis / Kong-cache | 1-5 minuten | GET API-reacties (lijstdetails, zoekresultaten) | 60-75% |
| L3 - Toepassing | Redis-cluster | 30s-15 minuten | Sessie, aantal facetten, geocodering, H3-aggregaties | 80-90% |
| L4 - Zoekopdrachten | Elasticsearch-replica's | Bijna realtime | Lees antwoorden voor zoekopdrachten, aparte index voor suggesties | N.v.t. (lees schaling) |
| L5 - Databases | PostgreSQL + pgbouncer | N.v.t | Verbindingspooling, lees replica's per rapport | N.v.t |
Database Sharding per regio
Met miljoenen eigendommen geografisch verspreid, onderverdeeld per regio en strategie natuurlijker voor vastgoedplatforms. Elke Shard bevat de gegevens van een macroregio, waardoor u horizontaal kunt schalen en tegelijkertijd aan de regelgeving kunt voldoen gegevensresidentie (AVG).
- Shard-sleutel: geografische regio (bijv.
country:region=IT:lombardia) - Routering: de gateway-API bepaalt de Shard op basis van de locatie van de query
- Cross-shard-query's: verspreid verzamelen voor zoekopdrachten in meerdere regio's (niet vaak)
- Herbalanceren: wanneer een regio de drempel overschrijdt, splitst deze zich (Milaan wordt bijvoorbeeld een speciale scherf)
CQRS-patroon voor scheiding tussen lezen en schrijven
Het patroon CQRS (Command Query Verantwoordelijkheid Segregatie) en vooral effectief voor vastgoedplatforms waar de lees-/schrijfverhouding doorgaans 100:1 is. De geschriften (aanmaken van advertenties, prijsupdates) via de Listing Service met PostgreSQL als bron van waarheid. De lezingen (onderzoek, details van de lijst) waren nuttig van Elasticsearch en Redis, asynchroon bijgewerkt via evenementen. Deze ontkoppeling Hiermee kunt u de twee paden onafhankelijk optimaliseren.
Meertalig en meerdere valuta
Platforms die opereren op internationale markten (zoals Idealista in Spanje, Italië en Portugal) ze moeten inhoud in meerdere talen beheren en prijzen in verschillende valuta's. De strategie verschilt per basis aan het type inhoud.
Strategie voor inhoudstypen
- Statische gebruikersinterface: vertaalbestanden (i18n) geladen vanaf de frontend, één taal per bundel (Angular i18n, React i18next)
-
Advertentiebeschrijvingen: veld
LocalizedTextin de DB, vertaling automatisch via DeepL/Google Translate met de vlag "automatisch vertaald". - Adressen: niet vertaald (ze blijven in de lokale taal), maar de stad kan dat wel hebben variaties (Milaan/Milaan, Rome/Rome)
- Prijzen: opgeslagen in de originele valuta, on-the-fly conversie voor weergave met tarieven bijgewerkt door API (ECB, open wisselkoersen)
-
SEO: Gelokaliseerde URL's (
/it/vendita/milanovs/en/sale/milan) met labelshreflangom dubbele inhoud te voorkomen
Naleving: AVG en eerlijke huisvesting
Vastgoedplatforms verwerken zeer gevoelige gegevens: woonvoorkeuren, mogelijkheden financiële, onderzoeksgeschiedenis. Compliance is geen optie, het is een architectonische vereiste die het systeemontwerp op elk niveau beïnvloedt.
AVG: impact op architectuur
-
Dataminimalisatie: alleen de gegevens verzamelen die strikt noodzakelijk zijn.
Serveer geen veld
viewCountvolg in de publieke reactie de locatie niet GPS van de gebruiker zonder uitdrukkelijke toestemming. -
Recht om te wissen: implementeren
DELETE /api/v1/users/{id}/datadie profielen, opgeslagen zoekopdrachten, berichten verwijdert en logs binnen 30 dagen anonimiseert. -
Gegevensportabiliteit:
GET /api/v1/users/{id}/exportgenereren een JSON/CSV-archief met alle gebruikersgegevens. - Toestemmingsbeheer: elke tracker (GA4, heatmap, remarketing) wordt alleen geactiveerd na expliciete toestemming, met granulariteit per categorie.
- Gegevens woonplaats: Sharding per regio vergemakkelijkt de naleving van de vereiste dat EU-gebruikersgegevens in EU-datacentra blijven.
Eerlijke huisvesting en discriminatie
Negli USA il Fair Housing Act vieta la discriminazione nella vendita e affitto di immobili basata su razza, colore, religione, sesso, handicap, stato familiare o origine nazionale. Implicazioni tecniche:
- Filtri di ricerca: non offrire mai filtri che possano fungere da proxy per caratteristiche protette (es. "composizione demografica del quartiere")
- Ad targeting: il targeting degli annunci sponsorizzati non deve usare criteri demografici protetti (Facebook ha pagato 5M di dollari nel 2022 per questo)
- Modelli ML: audit regolari per bias nei modelli di valutazione e raccomandazione. Un modello che sistematicamente sottovaluta proprietà in quartieri a maggioranza minoritaria e non solo ingiusto, e illegale.
- Descrizioni annunci: filtro automatico per linguaggio discriminatorio nelle descrizioni (es. "ideale per coppie senza figli" viola il Fair Housing Act)
Attenzione: Bias nei Modelli di Valutazione
I modelli ML di valutazione immobiliare (tipo "Zestimate") possono amplificare bias storici. Se i dati di training riflettono decenni di discriminazione nel mercato (redlining), il modello riprodurra quelle disuguaglianze. E essenziale implementare fairness audits con metriche di equita per gruppo demografico e monitoraggio continuo delle predizioni per zona.
Conclusioni e Architettura di Riferimento
Costruire una piattaforma immobiliare a scala e un esercizio di system design che tocca quasi ogni area dell'ingegneria del software: dalla modellazione di domini complessi alla ricerca geospaziale, dalla gestione di pipeline multimediali al rispetto di normative stringenti.
I principi chiave emersi da questa analisi sono:
- Decomposizione per dominio: separare chiaramente Listing, Search, Media, User e Notification in servizi indipendenti con database dedicati
- CQRS come pattern fondamentale: con un rapporto letture/scritture di 100:1, separare i percorsi di lettura (Elasticsearch) e scrittura (PostgreSQL) e non un'ottimizzazione prematura, e una necessità architettuale
- Geospaziale come first-class citizen: H3 per aggregazione, PostGIS per precisione, Elasticsearch geo_point per ricerca full-stack
- Pipeline asincrone: ingestione annunci, elaborazione media e notifiche devono essere event-driven per gestire i picchi senza degradare l'esperienza utente
- Compliance by design: GDPR e fair housing non sono feature da aggiungere dopo, ma vincoli architetturali che guidano le scelte dal primo giorno
Nel prossimo articolo della serie esploreremo in dettaglio i modelli di valutazione immobiliare basati su Machine Learning, analizzando come Zillow, Redfin e le piattaforme europee stimano i prezzi di mercato con Automated Valuation Models (AVM) e quali sfide tecniche e etiche comporta questa capacità.
Risorse e Approfondimenti
- RESO Web API: reso.org - Standard per lo scambio dati MLS
- H3: h3geo.org - Sistema di indicizzazione geospaziale esagonale di Uber
- Elasticsearch Geospatial: elastic.co/docs - Documentazione query geo
- PostGIS: postgis.net - Estensione geospaziale per PostgreSQL
- GDPR per PropTech: linee guida EDPB su profilazione e automated decision-making
- Fair Housing Act: hud.gov - Normativa anti-discriminazione USA







