Kanonische Architektur, URL-Design, Resolver-Logik, API- & Skalierbarkeitsspezifikation

Illustration
Geo Discovery: Kanonische Architektur, URL-Design, Resolver-Logik, API- & Skalierungs-Spezifikation
Dieses Dokument spezifiziert die geo-basierte Discovery-Oberfläche (Land/Stadt/Radius) über mehrere Portale (Multi-Tenant), ohne sofortiges DB-Refactoring zu erzwingen und ohne Kopplung an das CMS-Routing (Seite/Beitrag). Es hält SEO stabil, bleibt cache-freundlich und lässt Raum für Buchung/Reviews/Karten, ohne die App in einen Blob zu verwandeln.
Constraints und Nicht-Ziele
- Multi-Tenant: dieselbe Codebasis bedient mehrere Portale. Der Tenant beeinflusst den Inhaltsumfang, das Branding und manchmal die Datenquelle.
- Geo-Discovery muss unterstützen: Land, Stadt, Radius-Suche (um einen Punkt).
- Kein sofortiges Datenbank-Refactoring: wir können bestehende Tabellen aktuell nicht zu einem perfekten Geo-Schema umformen.
- Unabhängigkeit vom CMS-Routing: Geo-Seiten sind keine „Posts“ oder „Pages“. Sie dürfen nicht durch CMS-Slug-Konflikte blockiert werden.
- SEO-Stabilität: kanonische URLs dürfen sich nicht ändern, wenn Filter-/Sortieroptionen wechseln.
- Cache-Freundlichkeit: CDN + Server-Cache sollen vorhersagbare Keys haben. Per-User-Variationen vermeiden.
- Strikte Trennung der Verantwortlichkeiten: Discovery, CMS und Tenant-Auflösung sind separate Module mit expliziten Grenzen.
- Nicht-Ziel (vorerst): perfektes Geocoding. Wir akzeptieren einen Geocoder, eine Normalisierungsstrategie und speichern das normalisierte Ergebnis.
Kanonische Architektur
Wir implementieren Geo-Discovery als eigenen Bounded Context mit einer kleinen öffentlichen Oberfläche: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> SEO- + Cache-Metadaten. CMS-Routen rufen niemals Discovery auf. Discovery ruft niemals CMS-Routing auf. Gemeinsam genutzt werden nur Low-Level-Utilities (HTTP, Caching, Tenant-Kontext).
Kernkomponenten
- TenantContext: löst den Tenant über den Host-Header auf (oder über eine explizite Portal-ID bei internen Calls).
- GeoResolver: parst + validiert Geo-URL-Segmente; erzeugt ein normalisiertes GeoQuery.
- GeoIndex (Read Model): eine separate Tabelle/Collection, die entityId -> lat/lng + Tenant-Scope + minimale durchsuchbare Felder abbildet. Das vermeidet Refactoring der Source-DB-Tabellen.
- Data Providers: austauschbare Quellen (z. B. bestehende SQL-Tabellen, WordPress-API, ein anderer Portal-Service). Hinter einem Interface.
- SEO Router: erzeugt kanonische URL und Meta (canonical, hreflang falls nötig, robots-Flags).
- Caching Layer: CDN-Cache-Keys + serverseitiger Cache mit tenant-scopeden Keys und Versionierung.
Ordnerstruktur
apps/
web/
routes/
discovery/ # discovery entry points (not CMS)
pages/
[...cms].vue # CMS catch-all (kept away from /discover)
server/
src/
tenant/
TenantContext.ts
tenantConfig.ts
discovery/
geo/
GeoResolver.ts
GeoQuery.ts
GeoCanonical.ts
GeoController.ts
providers/
GeoProvider.ts
SqlGeoProvider.ts
RemoteGeoProvider.ts
index/
GeoIndexRepository.ts
migrations/
cache/
Cache.ts
cacheKeys.ts
http/
errors.ts
requestContext.ts
packages/
shared/
src/
geo/
normalize.ts
haversine.ts
validation/
zod.ts
URL-Design (SEO Canonical)
Wir halten die kanonische URL rein hierarchisch und gut lesbar. Filter/Sortierung bleiben im Querystring, beeinflussen den Canonical jedoch NICHT. Die Radius-Suche nutzt eine stabile „near“-Seite mit einem Canonical, der auf einer gerundeten Koordinaten-Zelle basiert, nicht auf rohem lat/lng. Das verhindert unendliche URL-Varianten und beugt Cache-Explosionen vor. Es ist ein bewusstes Trade-off.
- Country-Landing: /discover/{countryCode} (Beispiel: /discover/de)
- City-Landing: /discover/{countryCode}/{citySlug} (Beispiel: /discover/de/munich)
- Near (Radius): /discover/{countryCode}/near/{cellId} (Beispiel: /discover/de/near/u281z) wobei cellId ein kurzer geohash-artiger Identifier ist
- Optionale Query-Parameter (nicht kanonisch): ?q=shih-tzu&category=pet-care&sort=rating&page=2
- Tenant ist NICHT im Pfad. Tenant ist der Host (portal-a.tld, portal-b.tld). Interne Calls senden x-tenant-id.
Canonical-Regeln
- Land-/Stadtseiten kanonisieren auf ihren eigenen sauberen Pfad (ohne Querystring).
- Near-Seiten kanonisieren auf /discover/{country}/near/{cellId}. Der Canonical wird aus einer gerundeten Zelle abgeleitet, nicht aus den eingehenden Koordinaten.
- page=1 wird aus dem Canonical und aus der internen Link-Generierung weggelassen.
- Nicht unterstützte Kombinationen (z. B. Country-Mismatch) liefern 404, kein Redirect. Redirect-Ketten haben in Tests das Crawl-Budget belastet.
Resolver-Logik
Resolver-Input ist (tenant, pathname, query). Resolver-Output ist ein normalisiertes GeoQuery mit Query Plan. Kein DB-Zugriff im Resolver. Diese Trennung war später wichtig. Sie hat uns vor einem fiesen Caching-Bug gerettet.
export type TenantId = string;
export type GeoScope =
| { kind: 'country'; countryCode: string }
| { kind: 'city'; countryCode: string; citySlug: string }
| { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };
export type GeoFilters = {
q?: string;
category?: string;
sort?: 'relevance' | 'rating' | 'distance';
page: number;
pageSize: number;
};
export type GeoQuery = {
tenantId: TenantId;
scope: GeoScope;
filters: GeoFilters;
canonicalPath: string;
cacheKey: string;
};
import { z } from 'zod';
import type { GeoQuery, TenantId } from './GeoQuery';
const QuerySchema = z.object({
q: z.string().trim().min(1).max(120).optional(),
category: z.string().trim().min(1).max(60).optional(),
sort: z.enum(['relevance', 'rating', 'distance']).optional(),
page: z.coerce.number().int().min(1).max(200).default(1),
pageSize: z.coerce.number().int().min(5).max(50).default(20)
});
const CountryCodeSchema = z.string().regex(/^[a-z]{2}$/i);
const CitySlugSchema = z.string().regex(/^[a-z0-9-]{2,80}$/i);
const CellIdSchema = z.string().regex(/^[a-z0-9]{4,12}$/i);
export function resolveGeo(
tenantId: TenantId,
pathname: string,
query: Record<string, unknown>
): GeoQuery {
const filters = QuerySchema.parse(query);
const parts = pathname.split('/').filter(Boolean);
// /discover/{country}
// /discover/{country}/{city}
// /discover/{country}/near/{cellId}
if (parts[0] !== 'discover') {
throw new Error('Not a discovery route');
}
const countryCode = CountryCodeSchema.parse(parts[1] ?? '');
let scope: GeoQuery['scope'];
if (parts.length === 2) {
scope = { kind: 'country', countryCode: countryCode.toLowerCase() };
} else if (parts[2] === 'near') {
const cellId = CellIdSchema.parse(parts[3] ?? '');
// radius is NOT in the path, but is bounded.
const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));
scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };
} else {
const citySlug = CitySlugSchema.parse(parts[2] ?? '');
scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };
}
const canonicalPath = canonicalizePath(scope);
const cacheKey = buildCacheKey(tenantId, scope, filters);
return {
tenantId,
scope,
filters: {
...filters,
sort: filters.sort ?? 'relevance'
},
canonicalPath,
cacheKey
};
}
function canonicalizePath(scope: GeoQuery['scope']): string {
switch (scope.kind) {
case 'country':
return `/discover/${scope.countryCode}`;
case 'city':
return `/discover/${scope.countryCode}/${scope.citySlug}`;
case 'near':
return `/discover/${scope.countryCode}/near/${scope.cellId}`;
}
}
function buildCacheKey(
tenantId: string,
scope: GeoQuery['scope'],
filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }
): string {
// Note: canonical ignores querystring, cache does not.
// But we keep it bounded and explicit.
const q = filters.q ? `q=${filters.q}` : '';
const c = filters.category ? `cat=${filters.category}` : '';
const s = `sort=${filters.sort ?? 'relevance'}`;
const p = `p=${filters.page}`;
const ps = `ps=${filters.pageSize}`;
const scopeKey =
scope.kind === 'country'
? `country:${scope.countryCode}`
: scope.kind === 'city'
? `city:${scope.countryCode}:${scope.citySlug}`
: `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;
return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;
}
Request-Flow Schritt für Schritt
- Edge/CDN empfängt die Anfrage. Der Cache-Key enthält Host + Pfad + begrenzte Query-Parameter (q, category, sort, page, pageSize, r).
- Der App-Server erzeugt TenantContext aus dem Host-Header. Noch keine DB.
- GeoResolver parst die URL. Erzeugt GeoQuery mit canonicalPath und serverseitigem cacheKey.
- GeoController baut einen QueryPlan. Er entscheidet anhand der Tenant-Konfiguration und des Scope-Typs, welche Provider zu nutzen sind.
- Der Provider führt eine Read-Model-Query gegen GeoIndex aus (schnell). Danach hydriert er Ergebnisse aus der bestehenden Source-DB/API über entity IDs (kein Refactor nötig).
- Der Response-Assembler hängt SEO-Metadaten an: canonical, robots, Pagination-Links.
- Der Server setzt Cache-Header (s-maxage + stale-while-revalidate). Body ist tenant-scoped und wird nie zwischen Tenants geteilt.
No-Refactor-Strategie: GeoIndex Read Model
Wir können bestehende Content-Tabellen aktuell nicht umstrukturieren. Daher fügen wir einen separaten GeoIndex hinzu, den wir unabhängig rebuilden können. Er speichert gerade genug für effiziente Discovery: tenantId, entityId, entityType, countryCode, citySlug, lat, lng und einige Filterfelder. Hydration holt das vollständige Objekt aus der aktuellen Quelle (SQL-Zeilen, CMS-API usw.).
Trade-off: Eventual Consistency. Index-Rebuild-Lag ist für Discovery-Seiten akzeptabel. Wir setzen ein SLA: Updates erscheinen innerhalb von 15 Minuten. Wenn wir später Echtzeit brauchen (Buchungsverfügbarkeit), ist das eine andere Oberfläche und sollte den Discovery-Cache nicht wiederverwenden.
API-Oberfläche
Zwei Oberflächen: (A) HTML-Seiten für SEO und Nutzer, (B) JSON-API für Client-Rendering und zukünftige Erweiterungen. Gleicher Resolver + gleicher Query Plan. Unterschiedliche Presenter.
- HTML: GET /discover/{country}, /discover/{country}/{city}, /discover/{country}/near/{cellId}
- API: GET /api/discovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..
- Admin (intern): POST /internal/geoindex/rebuild (geschützt), POST /internal/geoindex/upsert (optional, später)
import type { IncomingMessage, ServerResponse } from 'http';
import { resolveGeo } from './GeoResolver';
import { runQueryPlan } from './GeoController';
export async function discoveryApi(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? '', 'http://localhost');
const tenantId = String(req.headers['x-tenant-id'] ?? 'default');
// We reuse the same resolver by mapping query -> a pseudo-path.
// This keeps logic aligned between HTML and API.
const scope = url.searchParams.get('scope') ?? 'country';
const country = url.searchParams.get('country') ?? '';
const city = url.searchParams.get('city');
const cell = url.searchParams.get('cell');
const pseudoPath =
scope === 'city' && city
? `/discover/${country}/${city}`
: scope === 'near' && cell
? `/discover/${country}/near/${cell}`
: `/discover/${country}`;
const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));
const result = await runQueryPlan(geoQuery);
res.statusCode = 200;
res.setHeader('content-type', 'application/json; charset=utf-8');
// cache: public at CDN, tenant-specific key already handled upstream
res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');
res.end(JSON.stringify(result));
}
Skalierbarkeit & Performance
- Primäres Performance-Ziel: gecachte Discovery-HTML vom CDN für populäre Geo-Landings (Land/Stadt) ausliefern.
- Near-Seiten sind cachebar, haben aber mehr Varianten (cellId + r + q + category + sort + page). Wir begrenzen r, pageSize und validieren Filter strikt.
- GeoIndex-Query muss schnell sein: Indizes tenantId + countryCode + citySlug; für near Cell-Buckets (Prefix-Match) und danach Distanz-Refinement in der App.
- Teure SQL-Haversine über große Datasets vermeiden. Es wirkt einfach. Ist es nicht.
- Hydration ist gebatcht nach entityId-Liste. Eine Query pro entityType, kein N+1.
Radius-Suchstrategie (Near-Seiten)
Wir führen keinen vollständigen Radius-Scan über alle Zeilen aus. Stattdessen: (1) cellId mappt auf einen Bounding-Bucket (geohash-artiger Prefix), (2) Kandidaten aus GeoIndex per Bucket-Prefix holen, (3) in der Applikation per Haversine-Distanz verfeinern, (4) sortieren + paginieren. Das hält DB-Last vorhersagbar.
export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const sinDLat = Math.sin(dLat / 2);
const sinDLng = Math.sin(dLng / 2);
const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;
const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));
return R * c;
}
Caching-Modell (CDN + Server)
Wir cachen auf zwei Ebenen. Das CDN cached das vollständige HTML/JSON für anonymen Traffic. Der Server-Cache speichert Provider-Ergebnisse, keyed by GeoQuery.cacheKey. Der Key enthält Tenant, Scope und begrenzte Filter. Nicht alles sollte gecached werden. Buchungsverfügbarkeit wird später ausgeschlossen.
- CDN-Cache-Key variiert nach Host-Header. Das ist die Tenant-Grenze.
- Server-Cache-Key enthält tenantId explizit. Niemals auf impliziten Host im Prozess verlassen.
- Cache-TTLs: Land/Stadt 30–60 Minuten im CDN (stale-while-revalidate aktiv). Near-Seiten 5 Minuten.
- Wir behalten einen manuellen Bust-Hebel pro Tenant (Versionssuffix im Cache-Key). Wird bei Migrationen und schlechten Deploys genutzt.
Details zur SEO-Stabilität
- Canonical-Tags: zeigen immer auf den sauberen hierarchischen Pfad (ohne Querystring).
- Robots: wenn Query-Parameter vorhanden sind, die nicht whitelisted sind (q/category/sort/page/pageSize/r), setze noindex. Das blockiert Müll-Parameter aus externen Links.
- Pagination: rel=next/prev wird nur für Seiten > 1 generiert und wenn resultCount > pageSize.
- Stabiles internes Linking: UI-Links geben immer kanonische Pfade aus; Querystring nur für user-selektierte Filter.
Zukünftige Erweiterungen ohne Bloat
Wir erweitern durch Hinzufügen von Providern und Presentern, nicht durch Stopfen von Features in den Resolver. Buchung, Reviews und Karten hängen an Entity-Seiten oder dedizierten APIs. Discovery bleibt eine List-and-Filter-Oberfläche. Diese Grenze wird im Code Review durchgesetzt.
- Buchung: separate /api/booking Endpoints. Discovery zeigt Verfügbarkeits-Badges nur, wenn gecached und nicht-personalisiert.
- Reviews: separater Review-Service/Provider. Discovery liest aggregierte Rating-Felder aus GeoIndex (vorberechnet).
- Karten: Map-Tiles und Marker werden über /api/discovery/markers mit aggressivem Caching geladen; nicht in die HTML-Response einbetten, wenn es TTFB verschlechtert.
Eine Sache, die schiefging (und was wir geändert haben)
Wir haben die erste Version mit einem Server-Cache-Key ausgeliefert, der tenantId NICHT enthielt. Lokal „funktionierte“ es. Im Staging sah es okay aus. Dann Produktion. Portal A zeigte plötzlich Portal-B-Listings auf City-Seiten. Gleicher Pfad, anderer Tenant. Cache-Kollision. Hässlich.
Der Fix war langweilig, aber strikt: tenantId wurde in GeoQuery verpflichtend, und das Bauen des Cache-Keys wanderte in den Resolver, damit es nicht übersprungen werden kann. Zusätzlich haben wir eine Runtime-Assertion: wenn hydrierte Entities eine andere tenantId enthalten, werfen wir und überspringen den Cache-Write. Absichtlich laut.
Query Plan und Provider-Vertrag
import type { GeoQuery } from '../GeoQuery';
export type GeoHit = {
entityId: string;
entityType: 'place' | 'service' | 'listing';
lat: number;
lng: number;
citySlug?: string;
countryCode: string;
score?: number;
distanceMeters?: number;
};
export type GeoResult = {
hits: GeoHit[];
total: number;
page: number;
pageSize: number;
};
export interface GeoProvider {
search(query: GeoQuery): Promise<GeoResult>;
}
QueryPlan ist ein kleiner Switch über (Tenant-Config + Scope-Kind). Beispiel: einige Tenants nutzen nur SQL; andere einen Remote-Portal-Service. Gleicher GeoQuery-Input. Gleicher GeoResult-Output. Das macht Rollouts vorhersagbar.
SQL GeoIndex: Minimales Schema
CREATE TABLE geo_index (
tenant_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
country_code TEXT NOT NULL,
city_slug TEXT,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
cell_prefix TEXT NOT NULL,
category TEXT,
rating_avg DOUBLE PRECISION,
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, entity_id)
);
CREATE INDEX geo_index_country_city
ON geo_index (tenant_id, country_code, city_slug);
CREATE INDEX geo_index_cell_prefix
ON geo_index (tenant_id, country_code, cell_prefix);
Trade-offs (explizit)
- Wir tauschen perfekte Präzision gegen stabile URLs: Near-Seiten kanonisieren nach cellId. Zwei Nutzer im Abstand von 300 m können auf derselben Canonical-Seite landen. Passt.
- Wir tauschen Frische gegen Cachebarkeit: Discovery-Seiten sind nicht Echtzeit. Index-Updates können verzögert sein.
- Wir vermeiden DB-Refactor jetzt, zahlen aber einen zusätzlichen Write-Pfad zur Pflege von GeoIndex.
- Wir vermeiden Kopplung an CMS-Routing, besitzen jetzt aber einen separaten Route-Namespace (/discover). Das ist beabsichtigt. Und ein Versprechen, das wir halten müssen.
- Wir verfeinern Distanzen in App-Code für Near-Suchen, um teure DB-Mathematik zu vermeiden. Das verlagert CPU auf die App-Schicht, die sich leichter horizontal skalieren lässt.
Rollout-Plan (praktisch)
- Resolver ausliefern + leere Seiten, die 404 zurückgeben, hinter einem Feature-Flag. Prüfen, dass Routing nicht mit dem CMS-Catch-All kollidiert.
- GeoIndex-Backfill-Job für einen Tenant bauen. Counts gegen Source-Daten validieren. Mismatches erwarten; loggen.
- Nur Country-Seiten aktivieren. Cache-Hit-Ratio und Crawl-Stats beobachten.
- Dann City-Seiten aktivieren. Near-Seiten zuletzt (sie sind der Varianten-Generator).
- Nach stabilen Cache-Keys JSON-API-Consumers hinzufügen (Map-Marker, Client-Filtering).
Abnahmeliste (Acceptance Checklist)
- Multi-Tenant-Isolation: derselbe Pfad auf zwei Hosts teilt nie Server-Cache-Einträge.
- Kanonische URLs enthalten keine Query-Parameter und bleiben über Filteränderungen stabil.
- CMS-Slug-Konflikte beeinflussen /discover Routen nicht.
- Near-Seiten erzeugen keine ungebundenen URL-Permutationen (begrenztes r, pageSize).
- Provider-Vertrag liefert deterministische Pagination und Total-Counts.
- GeoIndex-Rebuild kann ohne Downtime laufen und ohne Source-Tabellen lange zu locken.
