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

Geobasierte Erkennungsarchitektur für Mehrmandantenportale. Definiert kanonische URLs, Resolver-Logik, Caching-Strategie und ein Geo-Read-Modell ohne CMS-Kopplung oder Datenbank-Refactoring. Konzipiert für SEO-Stabilität, Skalierbarkeit und zukünftige Erweiterungen wie Buchung und Karten.
Veröffentlicht:
Aleksandar Stajić
Aktualisiert am: 1. Februar 2026 um 20:52
Kanonische Architektur, URL-Design, Resolver-Logik, API- & Skalierbarkeitsspezifikation

Bild generiert

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

  1. Edge/CDN empfängt die Anfrage. Der Cache-Key enthält Host + Pfad + begrenzte Query-Parameter (q, category, sort, page, pageSize, r).
  2. Der App-Server erzeugt TenantContext aus dem Host-Header. Noch keine DB.
  3. GeoResolver parst die URL. Erzeugt GeoQuery mit canonicalPath und serverseitigem cacheKey.
  4. GeoController baut einen QueryPlan. Er entscheidet anhand der Tenant-Konfiguration und des Scope-Typs, welche Provider zu nutzen sind.
  5. 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).
  6. Der Response-Assembler hängt SEO-Metadaten an: canonical, robots, Pagination-Links.
  7. 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)

  1. Resolver ausliefern + leere Seiten, die 404 zurückgeben, hinter einem Feature-Flag. Prüfen, dass Routing nicht mit dem CMS-Catch-All kollidiert.
  2. GeoIndex-Backfill-Job für einen Tenant bauen. Counts gegen Source-Daten validieren. Mismatches erwarten; loggen.
  3. Nur Country-Seiten aktivieren. Cache-Hit-Ratio und Crawl-Stats beobachten.
  4. Dann City-Seiten aktivieren. Near-Seiten zuletzt (sie sind der Varianten-Generator).
  5. 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.