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.
Related Articles

Model-View-Controller (MVC): Das strukturelle Rückgrat moderner Webanwendungen
Model-View-Controller, meist als MVC abgekürzt, bleibt eines der beständigsten Architekturmuster in der Softwareentwicklung. Es bietet Teams eine praktische Möglichkeit, Geschäftslogik, Präsentation und Benutzerinteraktion zu trennen, damit Anwendungen einfacher zu erstellen, zu erweitern, zu testen und zu warten bleiben. Dieser Artikel erklärt, was MVC ist, warum es immer noch wichtig ist, wo es in die heutigen Web-Stacks passt und wie es mit der umfassenderen Plattformarchitektur, Lieferqualität, Migrationsstrategie und betrieblichen Reife zusammenhängt.

Unternehmensfähige mandantenfähige Architektur für eine internationale Plattform
Loving Rocks ist eine Hochzeitsplattform auf Unternehmensniveau, konzipiert mit einer echten Mehrmandantenarchitektur, isolierten Datenbanken pro Mandant und integrierter Internationalisierung für globale Skalierbarkeit, Sicherheit und langfristige Betriebsstabilität.