[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"portal-settings:stajic:de":3,"public-menus:all":37,"post:canonical-architecture-url-design-resolver-logic-api-scalability-specification:de":204,"related:post:canonical-architecture-url-design-resolver-logic-api-scalability-specification:de:1":907},{"statusCode":4,"data":5,"message":36},200,{"tenantId":6,"lang":7,"defaultLang":7,"siteUrl":8,"contactEmail":9,"brandName":10,"logoUrl":11,"siteName":10,"siteDescription":12,"ogImage":9,"robotsIndex":13,"socialLinks":9,"reservedSlugs":9,"seoPolicy":14},"stajic","de","https:\u002F\u002Fstajic.de",null,"Stajic Platform","\u002FLogo_Planet.svg","Stajic Portal",true,{"branding":15,"relatedContent":16,"crossDomainLinks":17},{"logoUrl":11},{"enabled":13},[18,21,24,27,30,33],{"url":19,"label":20,"isActive":13,"showInFooter":13,"includeInSameAs":13},"https:\u002F\u002Ffigure.rocks","figure.rocks",{"url":22,"label":23,"isActive":13,"showInFooter":13,"includeInSameAs":13},"https:\u002F\u002Floving.rocks","loving.rocks",{"url":25,"label":26,"isActive":13,"showInFooter":13,"includeInSameAs":13},"https:\u002F\u002Fbazify.com","bazify.com",{"url":28,"label":29,"isActive":13,"showInFooter":13,"includeInSameAs":13},"https:\u002F\u002Fbazify.de","bazify.de",{"url":31,"label":32,"isActive":13,"showInFooter":13,"includeInSameAs":13},"https:\u002F\u002Fbazify.at","bazify.at",{"url":34,"label":35,"isActive":13,"showInFooter":13,"includeInSameAs":13},"https:\u002F\u002Fbazify.ba","bazify.ba","Portal settings resolved",[38,44],{"id":39,"name":40,"location":41,"isActive":13,"isDefault":42,"items":43},1,"main-navigation","header",false,[],{"id":45,"name":46,"location":47,"isActive":13,"isDefault":13,"items":48},4,"main-menu","sidebar",[49,65,78,92,102,117,132],{"id":50,"title":51,"url":59,"target":60,"icon":61,"isActive":13,"type":62,"productId":9,"categoryId":9,"shopCategoryId":9,"articleId":9,"pageId":63,"portfolioId":9,"children":64},"item-18",{"de":52,"en":53,"es":54,"fr":55,"it":53,"ru":56,"sr":57,"zh":58},"Startseite","Home","Inicio","Accueil","Главная","Почетна","首页","\u002Ffull-stack-web-developer-munich-performance-seo-and-maintainable-builds","_self","i-lucide-home","page",111,[],{"id":66,"title":67,"url":74,"target":60,"icon":75,"isActive":13,"type":62,"productId":9,"categoryId":9,"shopCategoryId":9,"articleId":9,"pageId":76,"portfolioId":9,"children":77},"item-22",{"de":68,"en":68,"es":69,"fr":68,"it":70,"ru":71,"sr":72,"zh":73},"Vision","Visión","Visione","Видение","Визија","想象","\u002Fueber-uns-webdesign-muenchen-webaplikation","i-lucide-eye",113,[],{"id":79,"title":80,"url":88,"target":60,"icon":89,"isActive":13,"type":62,"productId":9,"categoryId":9,"shopCategoryId":9,"articleId":9,"pageId":90,"portfolioId":9,"children":91},"item-19",{"de":81,"en":82,"es":83,"fr":82,"it":84,"ru":85,"sr":86,"zh":87},"Leistungen","Services","Servicios","Servizi","Услуги","Услуге","服务","\u002Fservices-dienstleistungen-muenchen","i-lucide-wrench",116,[],{"id":93,"title":94,"url":98,"target":60,"icon":99,"isActive":13,"type":62,"productId":9,"categoryId":9,"shopCategoryId":9,"articleId":9,"pageId":100,"portfolioId":9,"children":101},"item-23",{"de":95,"en":95,"es":95,"fr":95,"it":95,"ru":96,"sr":96,"zh":97},"Blog","Блог","博客","\u002Fblog","i-lucide-book-open",112,[],{"id":103,"title":104,"url":113,"target":60,"icon":114,"isActive":13,"type":62,"productId":9,"categoryId":9,"shopCategoryId":9,"articleId":9,"pageId":115,"portfolioId":9,"children":116},"item-32",{"de":105,"en":106,"es":107,"fr":108,"it":109,"ru":110,"sr":111,"zh":112},"Neue Technologien","New Technologies","Nuevas tecnologías","Nouvelles technologies","Nuove tecnologie","Новые технологии","Нове технологије","新技术！","\u002Fneue-webtechnologien","i-lucide-sparkles",122,[],{"id":118,"title":119,"url":128,"target":60,"icon":129,"isActive":13,"type":62,"productId":9,"categoryId":9,"shopCategoryId":9,"articleId":9,"pageId":130,"portfolioId":9,"children":131},"item-20",{"de":120,"en":121,"es":122,"fr":123,"it":124,"ru":125,"sr":126,"zh":127},"Kontakt","Contact us!","Contacto","Contact","Contatto","Контакт","Контактирајте нас","联系我们！","\u002Fcontact","i-lucide-mail",115,[],{"id":133,"title":134,"url":143,"target":60,"icon":144,"isActive":13,"type":62,"productId":9,"categoryId":9,"shopCategoryId":9,"articleId":9,"pageId":145,"portfolioId":9,"children":146},"item-21",{"de":135,"en":136,"es":137,"fr":138,"it":139,"ru":140,"sr":141,"zh":142},"Unsere Arbeit","Our Work","Nuestro trabajo","Nos réalisations","I nostri lavori","Наши работы","Наши радови","文件夹","\u002Fportfolio","i-lucide-briefcase",114,[147,160,174,180,192],{"id":148,"title":149,"url":143,"target":60,"icon":158,"isActive":13,"type":62,"productId":9,"categoryId":9,"shopCategoryId":9,"articleId":9,"pageId":145,"portfolioId":9,"children":159},"item-24",{"de":150,"en":151,"es":152,"fr":153,"it":154,"ru":155,"sr":156,"zh":157},"Alle Projekte","All Projects","Todos los proyectos","Tous les projets","Tutti i progetti","Все проекты","Сви пројекти","所有项目","i-lucide-grid-3x3",[],{"id":161,"title":162,"url":170,"target":60,"icon":171,"isActive":13,"type":172,"productId":9,"categoryId":9,"shopCategoryId":9,"articleId":9,"pageId":9,"portfolioId":9,"children":173},"item-29",{"de":163,"en":164,"es":165,"fr":166,"it":167,"ru":168,"sr":169,"zh":142},"Local Roots, Global Reach","Local Roots - Global Reach","Empresa local ","Entreprise locale","Azienda locale","Местная компания","Локално предузеће глобално тржиште","\u002Fportfolio\u002Flocal-roots-global-reach-communication-media-systems-for-modern-business","i-lucide-folder","custom",[],{"id":175,"title":176,"url":178,"target":60,"icon":171,"isActive":13,"type":172,"productId":9,"categoryId":9,"shopCategoryId":9,"articleId":9,"pageId":9,"portfolioId":9,"children":179},"item-28",{"de":177,"en":177,"es":177,"fr":177,"it":177,"ru":177,"sr":177,"zh":177},"Solr Suggester","\u002Fportfolio\u002Fsolr-fuzzy-suggester-und-solr-infix-suggester-abfrage-ueber-ajax-und-filterung",[],{"id":181,"title":182,"url":190,"target":60,"icon":171,"isActive":13,"type":172,"productId":9,"categoryId":9,"shopCategoryId":9,"articleId":9,"pageId":9,"portfolioId":9,"children":191},"item-27",{"de":183,"en":184,"es":185,"fr":186,"it":187,"ru":188,"sr":189,"zh":184},"Firmenwebseite SEO","Company Website SEO","Sitio web corporativo SEO","Site web d’entreprise SEO","Sito web aziendale SEO","Корпоративный сайт SEO","Пословна веб-страница SEO","\u002Fportfolio\u002Fseo-sem-branding-mobile-webseite-muenchen",[],{"id":193,"title":194,"url":202,"target":60,"icon":171,"isActive":13,"type":172,"productId":9,"categoryId":9,"shopCategoryId":9,"articleId":9,"pageId":9,"portfolioId":9,"children":203},"item-31",{"de":195,"en":196,"es":197,"fr":198,"it":199,"ru":200,"sr":201,"zh":196},"Digitalisierungsportal","Digitalization Portal","Portal de digitalización","Portail de numérisation","Portale di digitalizzazione","Портал цифровизации","Портал за дигитализацију","\u002Fportfolio\u002Fdigitalisierungsportal-archiv-museum-bibliothek-ead-lido-mets-mods",[],{"statusCode":4,"data":205,"message":906},{"id":206,"title":207,"slug":208,"content":209,"contentJson":210,"excerpt":509,"featuredImage":510,"featuredImageAlt":511,"featuredImageCaption":9,"featuredImageTitle":9,"featuredImageCopyright":9,"featuredImageAuthor":9,"featuredImageSourceUrl":9,"featuredImageLicense":9,"featuredImageIsAiGenerated":42,"status":512,"publishedAt":513,"createdAt":514,"updatedAt":515,"seoLocalePaths":516,"categories":525,"author":546,"translations":551},"383","Kanonische Architektur, URL-Design, Resolver-Logik, API- & Skalierbarkeitsspezifikation","canonical-architecture-url-design-resolver-logic-api-scalability-specification","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1\",\"data\":{\"text\":\"Geo Discovery: Kanonische Architektur, URL-Design, Resolver-Logik, API- & Skalierungs-Spezifikation\",\"level\":1},\"type\":\"header\"},{\"id\":\"p-scope\",\"data\":{\"text\":\"Dieses Dokument spezifiziert die geo-basierte Discovery-Oberfläche (Land\u002FStadt\u002FRadius) über mehrere Portale (Multi-Tenant), ohne sofortiges DB-Refactoring zu erzwingen und ohne Kopplung an das CMS-Routing (Seite\u002FBeitrag). Es hält SEO stabil, bleibt cache-freundlich und lässt Raum für Buchung\u002FReviews\u002FKarten, ohne die App in einen Blob zu verwandeln.\"},\"type\":\"paragraph\"},{\"id\":\"h-goals\",\"data\":{\"text\":\"Constraints und Nicht-Ziele\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-constraints\",\"data\":{\"items\":[\"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-\u002FSortieroptionen 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.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-arch\",\"data\":{\"text\":\"Kanonische Architektur\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-arch\",\"data\":{\"text\":\"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).\"},\"type\":\"paragraph\"},{\"id\":\"h-components\",\"data\":{\"text\":\"Kernkomponenten\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-components\",\"data\":{\"items\":[\"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\u002FCollection, die entityId -> lat\u002Flng + 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.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-folder\",\"data\":{\"text\":\"Ordnerstruktur\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-folder\",\"data\":{\"code\":\"apps\u002F\\n  web\u002F\\n    routes\u002F\\n      discovery\u002F                  # discovery entry points (not CMS)\\n    pages\u002F\\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\\n  server\u002F\\n    src\u002F\\n      tenant\u002F\\n        TenantContext.ts\\n        tenantConfig.ts\\n      discovery\u002F\\n        geo\u002F\\n          GeoResolver.ts\\n          GeoQuery.ts\\n          GeoCanonical.ts\\n          GeoController.ts\\n          providers\u002F\\n            GeoProvider.ts\\n            SqlGeoProvider.ts\\n            RemoteGeoProvider.ts\\n          index\u002F\\n            GeoIndexRepository.ts\\n            migrations\u002F\\n      cache\u002F\\n        Cache.ts\\n        cacheKeys.ts\\n      http\u002F\\n        errors.ts\\n        requestContext.ts\\npackages\u002F\\n  shared\u002F\\n    src\u002F\\n      geo\u002F\\n        normalize.ts\\n        haversine.ts\\n      validation\u002F\\n        zod.ts\\n\",\"language\":\"text\"},\"type\":\"code\"},{\"id\":\"h-url\",\"data\":{\"text\":\"URL-Design (SEO Canonical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-url\",\"data\":{\"text\":\"Wir halten die kanonische URL rein hierarchisch und gut lesbar. Filter\u002FSortierung 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\u002Flng. Das verhindert unendliche URL-Varianten und beugt Cache-Explosionen vor. Es ist ein bewusstes Trade-off.\"},\"type\":\"paragraph\"},{\"id\":\"list-url\",\"data\":{\"items\":[\"Country-Landing: \u002Fdiscover\u002F{countryCode} (Beispiel: \u002Fdiscover\u002Fde)\",\"City-Landing: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (Beispiel: \u002Fdiscover\u002Fde\u002Fmunich)\",\"Near (Radius): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (Beispiel: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) 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.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-canonical-rules\",\"data\":{\"text\":\"Canonical-Regeln\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-canonical\",\"data\":{\"items\":[\"Land-\u002FStadtseiten kanonisieren auf ihren eigenen sauberen Pfad (ohne Querystring).\",\"Near-Seiten kanonisieren auf \u002Fdiscover\u002F{country}\u002Fnear\u002F{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.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-resolver\",\"data\":{\"text\":\"Resolver-Logik\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-resolver\",\"data\":{\"text\":\"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.\"},\"type\":\"paragraph\"},{\"id\":\"code-types\",\"data\":{\"code\":\"export type TenantId = string;\\n\\nexport type GeoScope =\\n  | { kind: 'country'; countryCode: string }\\n  | { kind: 'city'; countryCode: string; citySlug: string }\\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\\n\\nexport type GeoFilters = {\\n  q?: string;\\n  category?: string;\\n  sort?: 'relevance' | 'rating' | 'distance';\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport type GeoQuery = {\\n  tenantId: TenantId;\\n  scope: GeoScope;\\n  filters: GeoFilters;\\n  canonicalPath: string;\\n  cacheKey: string;\\n};\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"code-resolver\",\"data\":{\"code\":\"import { z } from 'zod';\\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\\n\\nconst QuerySchema = z.object({\\n  q: z.string().trim().min(1).max(120).optional(),\\n  category: z.string().trim().min(1).max(60).optional(),\\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\\n  page: z.coerce.number().int().min(1).max(200).default(1),\\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\\n});\\n\\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\\n\\nexport function resolveGeo(\\n  tenantId: TenantId,\\n  pathname: string,\\n  query: Record\u003Cstring, unknown>\\n): GeoQuery {\\n  const filters = QuerySchema.parse(query);\\n  const parts = pathname.split('\u002F').filter(Boolean);\\n\\n  \u002F\u002F \u002Fdiscover\u002F{country}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\\n  if (parts[0] !== 'discover') {\\n    throw new Error('Not a discovery route');\\n  }\\n\\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\\n\\n  let scope: GeoQuery['scope'];\\n  if (parts.length === 2) {\\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\\n  } else if (parts[2] === 'near') {\\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\\n    \u002F\u002F radius is NOT in the path, but is bounded.\\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\\n  } else {\\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\\n  }\\n\\n  const canonicalPath = canonicalizePath(scope);\\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\\n\\n  return {\\n    tenantId,\\n    scope,\\n    filters: {\\n      ...filters,\\n      sort: filters.sort ?? 'relevance'\\n    },\\n    canonicalPath,\\n    cacheKey\\n  };\\n}\\n\\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\\n  switch (scope.kind) {\\n    case 'country':\\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\\n    case 'city':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\\n    case 'near':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\\n  }\\n}\\n\\nfunction buildCacheKey(\\n  tenantId: string,\\n  scope: GeoQuery['scope'],\\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\\n): string {\\n  \u002F\u002F Note: canonical ignores querystring, cache does not.\\n  \u002F\u002F But we keep it bounded and explicit.\\n  const q = filters.q ? `q=${filters.q}` : '';\\n  const c = filters.category ? `cat=${filters.category}` : '';\\n  const s = `sort=${filters.sort ?? 'relevance'}`;\\n  const p = `p=${filters.page}`;\\n  const ps = `ps=${filters.pageSize}`;\\n\\n  const scopeKey =\\n    scope.kind === 'country'\\n      ? `country:${scope.countryCode}`\\n      : scope.kind === 'city'\\n        ? `city:${scope.countryCode}:${scope.citySlug}`\\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\\n\\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-flow\",\"data\":{\"text\":\"Request-Flow Schritt für Schritt\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-flow\",\"data\":{\"items\":[\"Edge\u002FCDN 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\u002FAPI ü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.\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-read-model\",\"data\":{\"text\":\"No-Refactor-Strategie: GeoIndex Read Model\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-read-model\",\"data\":{\"text\":\"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.).\"},\"type\":\"paragraph\"},{\"id\":\"p-tradeoffs-read-model\",\"data\":{\"text\":\"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.\"},\"type\":\"paragraph\"},{\"id\":\"h-api\",\"data\":{\"text\":\"API-Oberfläche\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-api\",\"data\":{\"text\":\"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.\"},\"type\":\"paragraph\"},{\"id\":\"list-api\",\"data\":{\"items\":[\"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\",\"API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..\",\"Admin (intern): POST \u002Finternal\u002Fgeoindex\u002Frebuild (geschützt), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, später)\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"code-api-handler\",\"data\":{\"code\":\"import type { IncomingMessage, ServerResponse } from 'http';\\nimport { resolveGeo } from '.\u002FGeoResolver';\\nimport { runQueryPlan } from '.\u002FGeoController';\\n\\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\\n\\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\\n\\n  \u002F\u002F We reuse the same resolver by mapping query -> a pseudo-path.\\n  \u002F\u002F This keeps logic aligned between HTML and API.\\n  const scope = url.searchParams.get('scope') ?? 'country';\\n  const country = url.searchParams.get('country') ?? '';\\n  const city = url.searchParams.get('city');\\n  const cell = url.searchParams.get('cell');\\n\\n  const pseudoPath =\\n    scope === 'city' && city\\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\\n      : scope === 'near' && cell\\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\\n        : `\u002Fdiscover\u002F${country}`;\\n\\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\\n  const result = await runQueryPlan(geoQuery);\\n\\n  res.statusCode = 200;\\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\\n  \u002F\u002F cache: public at CDN, tenant-specific key already handled upstream\\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\\n\\n  res.end(JSON.stringify(result));\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-scalability\",\"data\":{\"text\":\"Skalierbarkeit & Performance\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-scale\",\"data\":{\"items\":[\"Primäres Performance-Ziel: gecachte Discovery-HTML vom CDN für populäre Geo-Landings (Land\u002FStadt) 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.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-near-strategy\",\"data\":{\"text\":\"Radius-Suchstrategie (Near-Seiten)\",\"level\":3},\"type\":\"header\"},{\"id\":\"p-near\",\"data\":{\"text\":\"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.\"},\"type\":\"paragraph\"},{\"id\":\"code-haversine\",\"data\":{\"code\":\"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\\n  const R = 6371000;\\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\\n\\n  const dLat = toRad(b.lat - a.lat);\\n  const dLng = toRad(b.lng - a.lng);\\n\\n  const lat1 = toRad(a.lat);\\n  const lat2 = toRad(b.lat);\\n\\n  const sinDLat = Math.sin(dLat \u002F 2);\\n  const sinDLng = Math.sin(dLng \u002F 2);\\n\\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\\n\\n  return R * c;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-cache\",\"data\":{\"text\":\"Caching-Modell (CDN + Server)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-cache\",\"data\":{\"text\":\"Wir cachen auf zwei Ebenen. Das CDN cached das vollständige HTML\u002FJSON 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.\"},\"type\":\"paragraph\"},{\"id\":\"list-cache\",\"data\":{\"items\":[\"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\u002FStadt 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.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-seo\",\"data\":{\"text\":\"Details zur SEO-Stabilität\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-seo\",\"data\":{\"items\":[\"Canonical-Tags: zeigen immer auf den sauberen hierarchischen Pfad (ohne Querystring).\",\"Robots: wenn Query-Parameter vorhanden sind, die nicht whitelisted sind (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), setze noindex. Das blockiert Müll-Parameter aus externen Links.\",\"Pagination: rel=next\u002Fprev 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.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-extensions\",\"data\":{\"text\":\"Zukünftige Erweiterungen ohne Bloat\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-extensions\",\"data\":{\"text\":\"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.\"},\"type\":\"paragraph\"},{\"id\":\"list-extensions\",\"data\":{\"items\":[\"Buchung: separate \u002Fapi\u002Fbooking Endpoints. Discovery zeigt Verfügbarkeits-Badges nur, wenn gecached und nicht-personalisiert.\",\"Reviews: separater Review-Service\u002FProvider. Discovery liest aggregierte Rating-Felder aus GeoIndex (vorberechnet).\",\"Karten: Map-Tiles und Marker werden über \u002Fapi\u002Fdiscovery\u002Fmarkers mit aggressivem Caching geladen; nicht in die HTML-Response einbetten, wenn es TTFB verschlechtert.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-went-wrong\",\"data\":{\"text\":\"Eine Sache, die schiefging (und was wir geändert haben)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-wrong\",\"data\":{\"text\":\"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.\"},\"type\":\"paragraph\"},{\"id\":\"p-fix\",\"data\":{\"text\":\"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.\"},\"type\":\"paragraph\"},{\"id\":\"h-query-plan\",\"data\":{\"text\":\"Query Plan und Provider-Vertrag\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-provider\",\"data\":{\"code\":\"import type { GeoQuery } from '..\u002FGeoQuery';\\n\\nexport type GeoHit = {\\n  entityId: string;\\n  entityType: 'place' | 'service' | 'listing';\\n  lat: number;\\n  lng: number;\\n  citySlug?: string;\\n  countryCode: string;\\n  score?: number;\\n  distanceMeters?: number;\\n};\\n\\nexport type GeoResult = {\\n  hits: GeoHit[];\\n  total: number;\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport interface GeoProvider {\\n  search(query: GeoQuery): Promise\u003CGeoResult>;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"p-queryplan\",\"data\":{\"text\":\"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.\"},\"type\":\"paragraph\"},{\"id\":\"h-sql\",\"data\":{\"text\":\"SQL GeoIndex: Minimales Schema\",\"level\":3},\"type\":\"header\"},{\"id\":\"code-sql\",\"data\":{\"code\":\"CREATE TABLE geo_index (\\n  tenant_id     TEXT NOT NULL,\\n  entity_id     TEXT NOT NULL,\\n  entity_type   TEXT NOT NULL,\\n  country_code  TEXT NOT NULL,\\n  city_slug     TEXT,\\n  lat           DOUBLE PRECISION NOT NULL,\\n  lng           DOUBLE PRECISION NOT NULL,\\n  cell_prefix   TEXT NOT NULL,\\n  category      TEXT,\\n  rating_avg    DOUBLE PRECISION,\\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\\n  PRIMARY KEY (tenant_id, entity_id)\\n);\\n\\nCREATE INDEX geo_index_country_city\\n  ON geo_index (tenant_id, country_code, city_slug);\\n\\nCREATE INDEX geo_index_cell_prefix\\n  ON geo_index (tenant_id, country_code, cell_prefix);\\n\",\"language\":\"sql\"},\"type\":\"code\"},{\"id\":\"h-tradeoffs\",\"data\":{\"text\":\"Trade-offs (explizit)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-tradeoffs\",\"data\":{\"items\":[\"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 (\u002Fdiscover). 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.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-rollout\",\"data\":{\"text\":\"Rollout-Plan (praktisch)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-rollout\",\"data\":{\"items\":[\"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).\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-acceptance\",\"data\":{\"text\":\"Abnahmeliste (Acceptance Checklist)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-acceptance\",\"data\":{\"items\":[\"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 \u002Fdiscover 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.\"],\"style\":\"unordered\"},\"type\":\"list\"}],\"version\":\"2.29.1\"}",{"time":211,"blocks":212,"version":508},1769827200000,[213,217,222,227,241,245,249,254,264,268,274,278,282,291,295,303,307,311,316,320,324,336,340,344,348,352,356,363,367,371,380,384,388,392,396,400,408,412,420,424,428,435,439,443,447,451,455,459,463,468,472,481,485,494,498],{"id":214,"data":215,"type":41},"h1",{"text":216,"level":39},"Geo Discovery: Kanonische Architektur, URL-Design, Resolver-Logik, API- & Skalierungs-Spezifikation",{"id":218,"data":219,"type":221},"p-scope",{"text":220},"Dieses Dokument spezifiziert die geo-basierte Discovery-Oberfläche (Land\u002FStadt\u002FRadius) über mehrere Portale (Multi-Tenant), ohne sofortiges DB-Refactoring zu erzwingen und ohne Kopplung an das CMS-Routing (Seite\u002FBeitrag). Es hält SEO stabil, bleibt cache-freundlich und lässt Raum für Buchung\u002FReviews\u002FKarten, ohne die App in einen Blob zu verwandeln.","paragraph",{"id":223,"data":224,"type":41},"h-goals",{"text":225,"level":226},"Constraints und Nicht-Ziele",2,{"id":228,"data":229,"type":240},"list-constraints",{"items":230,"style":239},[231,232,233,234,235,236,237,238],"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-\u002FSortieroptionen 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.","unordered","list",{"id":242,"data":243,"type":41},"h-arch",{"text":244,"level":226},"Kanonische Architektur",{"id":246,"data":247,"type":221},"p-arch",{"text":248},"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).",{"id":250,"data":251,"type":41},"h-components",{"text":252,"level":253},"Kernkomponenten",3,{"id":255,"data":256,"type":240},"list-components",{"items":257,"style":239},[258,259,260,261,262,263],"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\u002FCollection, die entityId -> lat\u002Flng + 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.",{"id":265,"data":266,"type":41},"h-folder",{"text":267,"level":226},"Ordnerstruktur",{"id":269,"data":270,"type":273},"code-folder",{"code":271,"language":272},"apps\u002F\n  web\u002F\n    routes\u002F\n      discovery\u002F                  # discovery entry points (not CMS)\n    pages\u002F\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\n  server\u002F\n    src\u002F\n      tenant\u002F\n        TenantContext.ts\n        tenantConfig.ts\n      discovery\u002F\n        geo\u002F\n          GeoResolver.ts\n          GeoQuery.ts\n          GeoCanonical.ts\n          GeoController.ts\n          providers\u002F\n            GeoProvider.ts\n            SqlGeoProvider.ts\n            RemoteGeoProvider.ts\n          index\u002F\n            GeoIndexRepository.ts\n            migrations\u002F\n      cache\u002F\n        Cache.ts\n        cacheKeys.ts\n      http\u002F\n        errors.ts\n        requestContext.ts\npackages\u002F\n  shared\u002F\n    src\u002F\n      geo\u002F\n        normalize.ts\n        haversine.ts\n      validation\u002F\n        zod.ts\n","text","code",{"id":275,"data":276,"type":41},"h-url",{"text":277,"level":226},"URL-Design (SEO Canonical)",{"id":279,"data":280,"type":221},"p-url",{"text":281},"Wir halten die kanonische URL rein hierarchisch und gut lesbar. Filter\u002FSortierung 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\u002Flng. Das verhindert unendliche URL-Varianten und beugt Cache-Explosionen vor. Es ist ein bewusstes Trade-off.",{"id":283,"data":284,"type":240},"list-url",{"items":285,"style":239},[286,287,288,289,290],"Country-Landing: \u002Fdiscover\u002F{countryCode} (Beispiel: \u002Fdiscover\u002Fde)","City-Landing: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (Beispiel: \u002Fdiscover\u002Fde\u002Fmunich)","Near (Radius): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (Beispiel: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) 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.",{"id":292,"data":293,"type":41},"h-canonical-rules",{"text":294,"level":253},"Canonical-Regeln",{"id":296,"data":297,"type":240},"list-canonical",{"items":298,"style":239},[299,300,301,302],"Land-\u002FStadtseiten kanonisieren auf ihren eigenen sauberen Pfad (ohne Querystring).","Near-Seiten kanonisieren auf \u002Fdiscover\u002F{country}\u002Fnear\u002F{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.",{"id":304,"data":305,"type":41},"h-resolver",{"text":306,"level":226},"Resolver-Logik",{"id":308,"data":309,"type":221},"p-resolver",{"text":310},"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.",{"id":312,"data":313,"type":273},"code-types",{"code":314,"language":315},"export type TenantId = string;\n\nexport type GeoScope =\n  | { kind: 'country'; countryCode: string }\n  | { kind: 'city'; countryCode: string; citySlug: string }\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\n\nexport type GeoFilters = {\n  q?: string;\n  category?: string;\n  sort?: 'relevance' | 'rating' | 'distance';\n  page: number;\n  pageSize: number;\n};\n\nexport type GeoQuery = {\n  tenantId: TenantId;\n  scope: GeoScope;\n  filters: GeoFilters;\n  canonicalPath: string;\n  cacheKey: string;\n};\n","ts",{"id":317,"data":318,"type":273},"code-resolver",{"code":319,"language":315},"import { z } from 'zod';\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\n\nconst QuerySchema = z.object({\n  q: z.string().trim().min(1).max(120).optional(),\n  category: z.string().trim().min(1).max(60).optional(),\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\n  page: z.coerce.number().int().min(1).max(200).default(1),\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\n});\n\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\n\nexport function resolveGeo(\n  tenantId: TenantId,\n  pathname: string,\n  query: Record\u003Cstring, unknown>\n): GeoQuery {\n  const filters = QuerySchema.parse(query);\n  const parts = pathname.split('\u002F').filter(Boolean);\n\n  \u002F\u002F \u002Fdiscover\u002F{country}\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\n  if (parts[0] !== 'discover') {\n    throw new Error('Not a discovery route');\n  }\n\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\n\n  let scope: GeoQuery['scope'];\n  if (parts.length === 2) {\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\n  } else if (parts[2] === 'near') {\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\n    \u002F\u002F radius is NOT in the path, but is bounded.\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\n  } else {\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\n  }\n\n  const canonicalPath = canonicalizePath(scope);\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\n\n  return {\n    tenantId,\n    scope,\n    filters: {\n      ...filters,\n      sort: filters.sort ?? 'relevance'\n    },\n    canonicalPath,\n    cacheKey\n  };\n}\n\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\n  switch (scope.kind) {\n    case 'country':\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\n    case 'city':\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\n    case 'near':\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\n  }\n}\n\nfunction buildCacheKey(\n  tenantId: string,\n  scope: GeoQuery['scope'],\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\n): string {\n  \u002F\u002F Note: canonical ignores querystring, cache does not.\n  \u002F\u002F But we keep it bounded and explicit.\n  const q = filters.q ? `q=${filters.q}` : '';\n  const c = filters.category ? `cat=${filters.category}` : '';\n  const s = `sort=${filters.sort ?? 'relevance'}`;\n  const p = `p=${filters.page}`;\n  const ps = `ps=${filters.pageSize}`;\n\n  const scopeKey =\n    scope.kind === 'country'\n      ? `country:${scope.countryCode}`\n      : scope.kind === 'city'\n        ? `city:${scope.countryCode}:${scope.citySlug}`\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\n\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\n}\n",{"id":321,"data":322,"type":41},"h-flow",{"text":323,"level":226},"Request-Flow Schritt für Schritt",{"id":325,"data":326,"type":240},"list-flow",{"items":327,"style":335},[328,329,330,331,332,333,334],"Edge\u002FCDN 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\u002FAPI ü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.","ordered",{"id":337,"data":338,"type":41},"h-read-model",{"text":339,"level":226},"No-Refactor-Strategie: GeoIndex Read Model",{"id":341,"data":342,"type":221},"p-read-model",{"text":343},"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.).",{"id":345,"data":346,"type":221},"p-tradeoffs-read-model",{"text":347},"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.",{"id":349,"data":350,"type":41},"h-api",{"text":351,"level":226},"API-Oberfläche",{"id":353,"data":354,"type":221},"p-api",{"text":355},"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.",{"id":357,"data":358,"type":240},"list-api",{"items":359,"style":239},[360,361,362],"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}","API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..","Admin (intern): POST \u002Finternal\u002Fgeoindex\u002Frebuild (geschützt), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, später)",{"id":364,"data":365,"type":273},"code-api-handler",{"code":366,"language":315},"import type { IncomingMessage, ServerResponse } from 'http';\nimport { resolveGeo } from '.\u002FGeoResolver';\nimport { runQueryPlan } from '.\u002FGeoController';\n\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\n\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\n\n  \u002F\u002F We reuse the same resolver by mapping query -> a pseudo-path.\n  \u002F\u002F This keeps logic aligned between HTML and API.\n  const scope = url.searchParams.get('scope') ?? 'country';\n  const country = url.searchParams.get('country') ?? '';\n  const city = url.searchParams.get('city');\n  const cell = url.searchParams.get('cell');\n\n  const pseudoPath =\n    scope === 'city' && city\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\n      : scope === 'near' && cell\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\n        : `\u002Fdiscover\u002F${country}`;\n\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\n  const result = await runQueryPlan(geoQuery);\n\n  res.statusCode = 200;\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\n  \u002F\u002F cache: public at CDN, tenant-specific key already handled upstream\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\n\n  res.end(JSON.stringify(result));\n}\n",{"id":368,"data":369,"type":41},"h-scalability",{"text":370,"level":226},"Skalierbarkeit & Performance",{"id":372,"data":373,"type":240},"list-scale",{"items":374,"style":239},[375,376,377,378,379],"Primäres Performance-Ziel: gecachte Discovery-HTML vom CDN für populäre Geo-Landings (Land\u002FStadt) 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.",{"id":381,"data":382,"type":41},"h-near-strategy",{"text":383,"level":253},"Radius-Suchstrategie (Near-Seiten)",{"id":385,"data":386,"type":221},"p-near",{"text":387},"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.",{"id":389,"data":390,"type":273},"code-haversine",{"code":391,"language":315},"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\n  const R = 6371000;\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\n\n  const dLat = toRad(b.lat - a.lat);\n  const dLng = toRad(b.lng - a.lng);\n\n  const lat1 = toRad(a.lat);\n  const lat2 = toRad(b.lat);\n\n  const sinDLat = Math.sin(dLat \u002F 2);\n  const sinDLng = Math.sin(dLng \u002F 2);\n\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\n\n  return R * c;\n}\n",{"id":393,"data":394,"type":41},"h-cache",{"text":395,"level":226},"Caching-Modell (CDN + Server)",{"id":397,"data":398,"type":221},"p-cache",{"text":399},"Wir cachen auf zwei Ebenen. Das CDN cached das vollständige HTML\u002FJSON 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.",{"id":401,"data":402,"type":240},"list-cache",{"items":403,"style":239},[404,405,406,407],"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\u002FStadt 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.",{"id":409,"data":410,"type":41},"h-seo",{"text":411,"level":226},"Details zur SEO-Stabilität",{"id":413,"data":414,"type":240},"list-seo",{"items":415,"style":239},[416,417,418,419],"Canonical-Tags: zeigen immer auf den sauberen hierarchischen Pfad (ohne Querystring).","Robots: wenn Query-Parameter vorhanden sind, die nicht whitelisted sind (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), setze noindex. Das blockiert Müll-Parameter aus externen Links.","Pagination: rel=next\u002Fprev 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.",{"id":421,"data":422,"type":41},"h-extensions",{"text":423,"level":226},"Zukünftige Erweiterungen ohne Bloat",{"id":425,"data":426,"type":221},"p-extensions",{"text":427},"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.",{"id":429,"data":430,"type":240},"list-extensions",{"items":431,"style":239},[432,433,434],"Buchung: separate \u002Fapi\u002Fbooking Endpoints. Discovery zeigt Verfügbarkeits-Badges nur, wenn gecached und nicht-personalisiert.","Reviews: separater Review-Service\u002FProvider. Discovery liest aggregierte Rating-Felder aus GeoIndex (vorberechnet).","Karten: Map-Tiles und Marker werden über \u002Fapi\u002Fdiscovery\u002Fmarkers mit aggressivem Caching geladen; nicht in die HTML-Response einbetten, wenn es TTFB verschlechtert.",{"id":436,"data":437,"type":41},"h-went-wrong",{"text":438,"level":226},"Eine Sache, die schiefging (und was wir geändert haben)",{"id":440,"data":441,"type":221},"p-wrong",{"text":442},"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.",{"id":444,"data":445,"type":221},"p-fix",{"text":446},"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.",{"id":448,"data":449,"type":41},"h-query-plan",{"text":450,"level":226},"Query Plan und Provider-Vertrag",{"id":452,"data":453,"type":273},"code-provider",{"code":454,"language":315},"import type { GeoQuery } from '..\u002FGeoQuery';\n\nexport type GeoHit = {\n  entityId: string;\n  entityType: 'place' | 'service' | 'listing';\n  lat: number;\n  lng: number;\n  citySlug?: string;\n  countryCode: string;\n  score?: number;\n  distanceMeters?: number;\n};\n\nexport type GeoResult = {\n  hits: GeoHit[];\n  total: number;\n  page: number;\n  pageSize: number;\n};\n\nexport interface GeoProvider {\n  search(query: GeoQuery): Promise\u003CGeoResult>;\n}\n",{"id":456,"data":457,"type":221},"p-queryplan",{"text":458},"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.",{"id":460,"data":461,"type":41},"h-sql",{"text":462,"level":253},"SQL GeoIndex: Minimales Schema",{"id":464,"data":465,"type":273},"code-sql",{"code":466,"language":467},"CREATE TABLE geo_index (\n  tenant_id     TEXT NOT NULL,\n  entity_id     TEXT NOT NULL,\n  entity_type   TEXT NOT NULL,\n  country_code  TEXT NOT NULL,\n  city_slug     TEXT,\n  lat           DOUBLE PRECISION NOT NULL,\n  lng           DOUBLE PRECISION NOT NULL,\n  cell_prefix   TEXT NOT NULL,\n  category      TEXT,\n  rating_avg    DOUBLE PRECISION,\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\n  PRIMARY KEY (tenant_id, entity_id)\n);\n\nCREATE INDEX geo_index_country_city\n  ON geo_index (tenant_id, country_code, city_slug);\n\nCREATE INDEX geo_index_cell_prefix\n  ON geo_index (tenant_id, country_code, cell_prefix);\n","sql",{"id":469,"data":470,"type":41},"h-tradeoffs",{"text":471,"level":226},"Trade-offs (explizit)",{"id":473,"data":474,"type":240},"list-tradeoffs",{"items":475,"style":239},[476,477,478,479,480],"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 (\u002Fdiscover). 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.",{"id":482,"data":483,"type":41},"h-rollout",{"text":484,"level":226},"Rollout-Plan (praktisch)",{"id":486,"data":487,"type":240},"list-rollout",{"items":488,"style":335},[489,490,491,492,493],"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).",{"id":495,"data":496,"type":41},"h-acceptance",{"text":497,"level":226},"Abnahmeliste (Acceptance Checklist)",{"id":499,"data":500,"type":240},"list-acceptance",{"items":501,"style":239},[502,503,504,505,506,507],"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 \u002Fdiscover 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.","2.29.1","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.","\u002Fuploads\u002F2026\u002F01\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification-1769890763607-7rghbp.webp","canonical-architecture-url-design-resolver-logic-api-scalability-specification-1769890763607-7rghbp","PUBLISHED","2026-01-31T06:12:00.000Z","2026-01-31T20:12:05.337Z","2026-02-20T20:40:40.678Z",{"en":517,"de":518,"sr":519,"es":520,"fr":521,"it":522,"ru":523,"zh":524},"\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fde\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fsr\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fes\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Ffr\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fit\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fru\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fzh\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification",[526,530,534,538,542],{"id":527,"name":528,"slug":529},108,"Delivery-Assessment","delivery-assessment",{"id":531,"name":532,"slug":533},107,"Assessments","assessments",{"id":535,"name":536,"slug":537},45,"Referenzmodell: Digitale Plattform","digital-platform",{"id":539,"name":540,"slug":541},66,"Content-Operations","content-ops",{"id":543,"name":544,"slug":545},79,"Playbook: Security Hardening","security-hardening",{"id":547,"login":548,"email":549,"displayName":550},"20","rooth8233","aleksandar@stajic.de","Aleksandar Stajić",[552,678],{"lang":7,"title":207,"content":209,"contentJson":553,"excerpt":509},{"time":211,"blocks":554,"version":508},[555,557,559,561,564,566,568,570,573,575,577,579,581,584,586,589,591,593,595,597,599,602,604,606,608,610,612,615,617,619,622,624,626,628,630,632,635,637,640,642,644,647,649,651,653,655,657,659,661,663,665,668,670,673,675],{"id":214,"data":556,"type":41},{"text":216,"level":39},{"id":218,"data":558,"type":221},{"text":220},{"id":223,"data":560,"type":41},{"text":225,"level":226},{"id":228,"data":562,"type":240},{"items":563,"style":239},[231,232,233,234,235,236,237,238],{"id":242,"data":565,"type":41},{"text":244,"level":226},{"id":246,"data":567,"type":221},{"text":248},{"id":250,"data":569,"type":41},{"text":252,"level":253},{"id":255,"data":571,"type":240},{"items":572,"style":239},[258,259,260,261,262,263],{"id":265,"data":574,"type":41},{"text":267,"level":226},{"id":269,"data":576,"type":273},{"code":271,"language":272},{"id":275,"data":578,"type":41},{"text":277,"level":226},{"id":279,"data":580,"type":221},{"text":281},{"id":283,"data":582,"type":240},{"items":583,"style":239},[286,287,288,289,290],{"id":292,"data":585,"type":41},{"text":294,"level":253},{"id":296,"data":587,"type":240},{"items":588,"style":239},[299,300,301,302],{"id":304,"data":590,"type":41},{"text":306,"level":226},{"id":308,"data":592,"type":221},{"text":310},{"id":312,"data":594,"type":273},{"code":314,"language":315},{"id":317,"data":596,"type":273},{"code":319,"language":315},{"id":321,"data":598,"type":41},{"text":323,"level":226},{"id":325,"data":600,"type":240},{"items":601,"style":335},[328,329,330,331,332,333,334],{"id":337,"data":603,"type":41},{"text":339,"level":226},{"id":341,"data":605,"type":221},{"text":343},{"id":345,"data":607,"type":221},{"text":347},{"id":349,"data":609,"type":41},{"text":351,"level":226},{"id":353,"data":611,"type":221},{"text":355},{"id":357,"data":613,"type":240},{"items":614,"style":239},[360,361,362],{"id":364,"data":616,"type":273},{"code":366,"language":315},{"id":368,"data":618,"type":41},{"text":370,"level":226},{"id":372,"data":620,"type":240},{"items":621,"style":239},[375,376,377,378,379],{"id":381,"data":623,"type":41},{"text":383,"level":253},{"id":385,"data":625,"type":221},{"text":387},{"id":389,"data":627,"type":273},{"code":391,"language":315},{"id":393,"data":629,"type":41},{"text":395,"level":226},{"id":397,"data":631,"type":221},{"text":399},{"id":401,"data":633,"type":240},{"items":634,"style":239},[404,405,406,407],{"id":409,"data":636,"type":41},{"text":411,"level":226},{"id":413,"data":638,"type":240},{"items":639,"style":239},[416,417,418,419],{"id":421,"data":641,"type":41},{"text":423,"level":226},{"id":425,"data":643,"type":221},{"text":427},{"id":429,"data":645,"type":240},{"items":646,"style":239},[432,433,434],{"id":436,"data":648,"type":41},{"text":438,"level":226},{"id":440,"data":650,"type":221},{"text":442},{"id":444,"data":652,"type":221},{"text":446},{"id":448,"data":654,"type":41},{"text":450,"level":226},{"id":452,"data":656,"type":273},{"code":454,"language":315},{"id":456,"data":658,"type":221},{"text":458},{"id":460,"data":660,"type":41},{"text":462,"level":253},{"id":464,"data":662,"type":273},{"code":466,"language":467},{"id":469,"data":664,"type":41},{"text":471,"level":226},{"id":473,"data":666,"type":240},{"items":667,"style":239},[476,477,478,479,480],{"id":482,"data":669,"type":41},{"text":484,"level":226},{"id":486,"data":671,"type":240},{"items":672,"style":335},[489,490,491,492,493],{"id":495,"data":674,"type":41},{"text":497,"level":226},{"id":499,"data":676,"type":240},{"items":677,"style":239},[502,503,504,505,506,507],{"lang":679,"title":680,"content":681,"contentJson":682,"excerpt":905},"en","Canonical Architecture, URL Design, Resolver Logic, API & Scalability Specification","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1\",\"data\":{\"text\":\"Geo Discovery: Canonical Architecture, URL Design, Resolver Logic, API & Scalability Spec\",\"level\":1},\"type\":\"header\"},{\"id\":\"p-scope\",\"data\":{\"text\":\"This document specifies the geo-based discovery surface (country\u002Fcity\u002Fradius) across multiple portals (multi-tenant), without forcing immediate DB refactoring, and without coupling to CMS routing (page\u002Fpost). It keeps SEO stable, stays cache-friendly, and leaves room for booking\u002Freviews\u002Fmaps without turning the app into a blob.\"},\"type\":\"paragraph\"},{\"id\":\"h-goals\",\"data\":{\"text\":\"Constraints and Non-Goals\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-constraints\",\"data\":{\"items\":[\"Multi-tenant: same codebase serves multiple portals. Tenant affects content scope, branding, and sometimes data source.\",\"Geo discovery must support: country, city, radius search (around a point).\",\"No immediate database refactoring: we cannot reshape existing tables into a perfect geo schema right now.\",\"Independence from CMS routing: geo pages are not \\\"posts\\\" or \\\"pages\\\". They cannot be blocked by CMS slug conflicts.\",\"SEO stability: canonical URLs must not change when filters\u002Fsort options change.\",\"Cache friendliness: CDN + server cache should have predictable keys. Avoid per-user variation.\",\"Strict separation of concerns: discovery, CMS, and tenant resolution are separate modules with explicit boundaries.\",\"Non-goal (for now): perfect geocoding. We accept one geocoder, one normalization strategy, and we store the normalized result.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-arch\",\"data\":{\"text\":\"Canonical Architecture\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-arch\",\"data\":{\"text\":\"We implement geo discovery as its own bounded context with a small public surface: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> SEO + Cache metadata. CMS routes never call into discovery. Discovery never calls CMS routing. They share only low-level utilities (HTTP, caching, tenant context).\"},\"type\":\"paragraph\"},{\"id\":\"h-components\",\"data\":{\"text\":\"Key Components\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-components\",\"data\":{\"items\":[\"TenantContext: resolves tenant from host header (or explicit portal id in internal calls).\",\"GeoResolver: parses + validates geo URL segments; emits a normalized GeoQuery.\",\"GeoIndex (Read Model): a separate table\u002Fcollection that maps entityId -> lat\u002Flng + tenant scope + minimal searchable fields. This avoids refactoring source DB tables.\",\"Data Providers: pluggable sources (e.g., existing SQL tables, WordPress API, another portal service). They are behind an interface.\",\"SEO Router: produces canonical URL and meta (canonical, hreflang if needed, robots flags).\",\"Caching Layer: CDN cache keys + server-side cache with tenant-scoped keys and versioning.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-folder\",\"data\":{\"text\":\"Folder Structure\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-folder\",\"data\":{\"code\":\"apps\u002F\\n  web\u002F\\n    routes\u002F\\n      discovery\u002F                  # discovery entry points (not CMS)\\n    pages\u002F\\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\\n  server\u002F\\n    src\u002F\\n      tenant\u002F\\n        TenantContext.ts\\n        tenantConfig.ts\\n      discovery\u002F\\n        geo\u002F\\n          GeoResolver.ts\\n          GeoQuery.ts\\n          GeoCanonical.ts\\n          GeoController.ts\\n          providers\u002F\\n            GeoProvider.ts\\n            SqlGeoProvider.ts\\n            RemoteGeoProvider.ts\\n          index\u002F\\n            GeoIndexRepository.ts\\n            migrations\u002F\\n      cache\u002F\\n        Cache.ts\\n        cacheKeys.ts\\n      http\u002F\\n        errors.ts\\n        requestContext.ts\\npackages\u002F\\n  shared\u002F\\n    src\u002F\\n      geo\u002F\\n        normalize.ts\\n        haversine.ts\\n      validation\u002F\\n        zod.ts\\n\",\"language\":\"text\"},\"type\":\"code\"},{\"id\":\"h-url\",\"data\":{\"text\":\"URL Design (SEO Canonical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-url\",\"data\":{\"text\":\"We keep the canonical URL purely hierarchical and human-readable. Filters\u002Fsort stay in querystring but do NOT affect the canonical. Radius search uses a stable \\\"near\\\" page with a canonical based on a rounded coordinate cell, not the raw lat\u002Flng. That prevents infinite URL variants. It also prevents cache explosion. It’s a deliberate compromise.\"},\"type\":\"paragraph\"},{\"id\":\"list-url\",\"data\":{\"items\":[\"Country landing: \u002Fdiscover\u002F{countryCode} (example: \u002Fdiscover\u002Fde)\",\"City landing: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (example: \u002Fdiscover\u002Fde\u002Fmunich)\",\"Near (radius): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (example: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) where cellId is a short geohash-ish identifier\",\"Optional query params (non-canonical): ?q=shih-tzu&category=pet-care&sort=rating&page=2\",\"Tenant is NOT in the path. Tenant is the host (portal-a.tld, portal-b.tld). Internal calls pass x-tenant-id.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-canonical-rules\",\"data\":{\"text\":\"Canonical Rules\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-canonical\",\"data\":{\"items\":[\"Country\u002Fcity pages canonicalize to their own clean path (no querystring).\",\"Near pages canonicalize to \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. The canonical is derived from a rounded cell, not the incoming coordinates.\",\"page=1 is omitted from canonical and from internal link generation.\",\"Unsupported combinations (e.g., country mismatch) return 404, not a redirect. Redirect chains were hurting crawl budget in testing.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-resolver\",\"data\":{\"text\":\"Resolver Logic\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-resolver\",\"data\":{\"text\":\"Resolver input is (tenant, pathname, query). Resolver output is a normalized GeoQuery with a query plan. No DB access inside the resolver. That separation mattered later. It saved us from a nasty caching bug.\"},\"type\":\"paragraph\"},{\"id\":\"code-types\",\"data\":{\"code\":\"export type TenantId = string;\\n\\nexport type GeoScope =\\n  | { kind: 'country'; countryCode: string }\\n  | { kind: 'city'; countryCode: string; citySlug: string }\\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\\n\\nexport type GeoFilters = {\\n  q?: string;\\n  category?: string;\\n  sort?: 'relevance' | 'rating' | 'distance';\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport type GeoQuery = {\\n  tenantId: TenantId;\\n  scope: GeoScope;\\n  filters: GeoFilters;\\n  canonicalPath: string;\\n  cacheKey: string;\\n};\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"code-resolver\",\"data\":{\"code\":\"import { z } from 'zod';\\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\\n\\nconst QuerySchema = z.object({\\n  q: z.string().trim().min(1).max(120).optional(),\\n  category: z.string().trim().min(1).max(60).optional(),\\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\\n  page: z.coerce.number().int().min(1).max(200).default(1),\\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\\n});\\n\\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\\n\\nexport function resolveGeo(\\n  tenantId: TenantId,\\n  pathname: string,\\n  query: Record\u003Cstring, unknown>\\n): GeoQuery {\\n  const filters = QuerySchema.parse(query);\\n  const parts = pathname.split('\u002F').filter(Boolean);\\n\\n  \u002F\u002F \u002Fdiscover\u002F{country}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\\n  if (parts[0] !== 'discover') {\\n    throw new Error('Not a discovery route');\\n  }\\n\\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\\n\\n  let scope: GeoQuery['scope'];\\n  if (parts.length === 2) {\\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\\n  } else if (parts[2] === 'near') {\\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\\n    \u002F\u002F radius is NOT in the path, but is bounded.\\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\\n  } else {\\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\\n  }\\n\\n  const canonicalPath = canonicalizePath(scope);\\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\\n\\n  return {\\n    tenantId,\\n    scope,\\n    filters: {\\n      ...filters,\\n      sort: filters.sort ?? 'relevance'\\n    },\\n    canonicalPath,\\n    cacheKey\\n  };\\n}\\n\\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\\n  switch (scope.kind) {\\n    case 'country':\\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\\n    case 'city':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\\n    case 'near':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\\n  }\\n}\\n\\nfunction buildCacheKey(\\n  tenantId: string,\\n  scope: GeoQuery['scope'],\\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\\n): string {\\n  \u002F\u002F Note: canonical ignores querystring, cache does not.\\n  \u002F\u002F But we keep it bounded and explicit.\\n  const q = filters.q ? `q=${filters.q}` : '';\\n  const c = filters.category ? `cat=${filters.category}` : '';\\n  const s = `sort=${filters.sort ?? 'relevance'}`;\\n  const p = `p=${filters.page}`;\\n  const ps = `ps=${filters.pageSize}`;\\n\\n  const scopeKey =\\n    scope.kind === 'country'\\n      ? `country:${scope.countryCode}`\\n      : scope.kind === 'city'\\n        ? `city:${scope.countryCode}:${scope.citySlug}`\\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\\n\\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-flow\",\"data\":{\"text\":\"Step-by-Step Request Flow\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-flow\",\"data\":{\"items\":[\"Edge\u002FCDN receives request. Cache key includes host + path + bounded query params (q, category, sort, page, pageSize, r).\",\"App server creates TenantContext from Host header. No DB yet.\",\"GeoResolver parses URL. Produces GeoQuery with canonicalPath and server cacheKey.\",\"GeoController builds a QueryPlan. It decides which provider(s) to hit based on tenant config and scope kind.\",\"Provider executes read-model query against GeoIndex (fast). Then hydrates results from the existing source DB\u002FAPI using entity IDs (no refactor required).\",\"Response assembler attaches SEO metadata: canonical, robots, pagination links.\",\"Server sets cache headers (s-maxage + stale-while-revalidate). Body is tenant-scoped, never shared across tenants.\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-read-model\",\"data\":{\"text\":\"No-Refactor Strategy: GeoIndex Read Model\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-read-model\",\"data\":{\"text\":\"We cannot restructure existing content tables right now. So we add a separate GeoIndex that we can rebuild independently. It stores just enough to do discovery efficiently: tenantId, entityId, entityType, countryCode, citySlug, lat, lng, and a few filter fields. Hydration fetches the full object from the current source (SQL rows, CMS API, etc.).\"},\"type\":\"paragraph\"},{\"id\":\"p-tradeoffs-read-model\",\"data\":{\"text\":\"Trade-off: eventual consistency. Index rebuild lag is acceptable for discovery pages. We set SLA: updates appear within 15 minutes. If we need real-time later (booking availability), that’s a different surface and should not reuse the discovery cache.\"},\"type\":\"paragraph\"},{\"id\":\"h-api\",\"data\":{\"text\":\"API Surface\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-api\",\"data\":{\"text\":\"Two surfaces: (A) HTML pages for SEO and users, (B) JSON API for client rendering and future extensions. Same resolver + same query plan. Different presenters.\"},\"type\":\"paragraph\"},{\"id\":\"list-api\",\"data\":{\"items\":[\"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\",\"API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..\",\"Admin (internal): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protected), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, later)\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"code-api-handler\",\"data\":{\"code\":\"import type { IncomingMessage, ServerResponse } from 'http';\\nimport { resolveGeo } from '.\u002FGeoResolver';\\nimport { runQueryPlan } from '.\u002FGeoController';\\n\\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\\n\\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\\n\\n  \u002F\u002F We reuse the same resolver by mapping query -> a pseudo-path.\\n  \u002F\u002F This keeps logic aligned between HTML and API.\\n  const scope = url.searchParams.get('scope') ?? 'country';\\n  const country = url.searchParams.get('country') ?? '';\\n  const city = url.searchParams.get('city');\\n  const cell = url.searchParams.get('cell');\\n\\n  const pseudoPath =\\n    scope === 'city' && city\\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\\n      : scope === 'near' && cell\\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\\n        : `\u002Fdiscover\u002F${country}`;\\n\\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\\n  const result = await runQueryPlan(geoQuery);\\n\\n  res.statusCode = 200;\\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\\n  \u002F\u002F cache: public at CDN, tenant-specific key already handled upstream\\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\\n\\n  res.end(JSON.stringify(result));\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-scalability\",\"data\":{\"text\":\"Scalability & Performance\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-scale\",\"data\":{\"items\":[\"Primary performance goal: serve cached discovery HTML from CDN for popular geo landings (country\u002Fcity).\",\"Near pages are cacheable but have more variants (cellId + r + q + category + sort + page). We bound r, pageSize, and validate filters strictly.\",\"GeoIndex query must be fast: use tenantId + countryCode + citySlug indexes; for near use cell buckets (prefix match) then refine by distance in app.\",\"Avoid expensive SQL haversine over large datasets. It looks simple. It is not.\",\"Hydration is batched by entityId list. One query per entityType, not N+1.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-near-strategy\",\"data\":{\"text\":\"Radius Search Strategy (Near Pages)\",\"level\":3},\"type\":\"header\"},{\"id\":\"p-near\",\"data\":{\"text\":\"We do not run a full radius scan over all rows. Instead: (1) cellId maps to a bounding bucket (geohash-ish prefix), (2) fetch candidates from GeoIndex by bucket prefix, (3) refine in application with haversine distance, (4) sort + paginate. This keeps DB load predictable.\"},\"type\":\"paragraph\"},{\"id\":\"code-haversine\",\"data\":{\"code\":\"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\\n  const R = 6371000;\\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\\n\\n  const dLat = toRad(b.lat - a.lat);\\n  const dLng = toRad(b.lng - a.lng);\\n\\n  const lat1 = toRad(a.lat);\\n  const lat2 = toRad(b.lat);\\n\\n  const sinDLat = Math.sin(dLat \u002F 2);\\n  const sinDLng = Math.sin(dLng \u002F 2);\\n\\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\\n\\n  return R * c;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-cache\",\"data\":{\"text\":\"Caching Model (CDN + Server)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-cache\",\"data\":{\"text\":\"We cache at two layers. CDN caches the full HTML\u002FJSON for anonymous traffic. Server cache stores provider results keyed by GeoQuery.cacheKey. The key includes tenant, scope, and bounded filters. Not everything should be cached. Booking availability later will be excluded.\"},\"type\":\"paragraph\"},{\"id\":\"list-cache\",\"data\":{\"items\":[\"CDN cache key varies by Host header. That is the tenant boundary.\",\"Server cache key includes tenantId explicitly. Never rely on implicit host in-process.\",\"Cache TTLs: country\u002Fcity 30–60 minutes at CDN (stale-while-revalidate enabled). near pages 5 minutes.\",\"We keep a manual bust lever per tenant (version suffix in cache key). Used during migrations and bad deploys.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-seo\",\"data\":{\"text\":\"SEO Stability Details\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-seo\",\"data\":{\"items\":[\"Canonical tags: always point to the clean hierarchical path (no querystring).\",\"Robots: if query params are present and not whitelisted (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), set noindex. This blocks garbage params from external links.\",\"Pagination: rel=next\u002Fprev generated only for pages > 1 and if resultCount > pageSize.\",\"Stable internal linking: UI links always emit canonical paths; querystring only for user-selected filters.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-extensions\",\"data\":{\"text\":\"Future Extensions Without Bloat\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-extensions\",\"data\":{\"text\":\"We extend by adding providers and presenters, not by stuffing features into the resolver. Booking, reviews, and maps hang off entity pages or dedicated APIs. Discovery remains a list-and-filter surface. That boundary is enforced in code review.\"},\"type\":\"paragraph\"},{\"id\":\"list-extensions\",\"data\":{\"items\":[\"Booking: separate \u002Fapi\u002Fbooking endpoints. Discovery only shows availability badges if cached and non-personal.\",\"Reviews: separate review service\u002Fprovider. Discovery reads aggregated rating fields from GeoIndex (precomputed).\",\"Maps: map tiles and markers fetched from \u002Fapi\u002Fdiscovery\u002Fmarkers with aggressive caching; not embedded into the HTML response if it breaks TTFB.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-went-wrong\",\"data\":{\"text\":\"One Thing That Went Wrong (and What We Changed)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-wrong\",\"data\":{\"text\":\"We shipped the first version with a server cache key that did NOT include tenantId. It “worked” in local. In staging it looked fine. Then production. Portal A started showing Portal B listings on city pages. Same path, different tenant. Cache collision. Ugly.\"},\"type\":\"paragraph\"},{\"id\":\"p-fix\",\"data\":{\"text\":\"Fix was boring but strict: tenantId became mandatory in GeoQuery, and cache key building moved into the resolver so it can’t be skipped. We also added a runtime assertion: if hydrated entities contain a different tenantId, we throw and skip cache write. That’s noisy on purpose.\"},\"type\":\"paragraph\"},{\"id\":\"h-query-plan\",\"data\":{\"text\":\"Query Plan and Provider Contract\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-provider\",\"data\":{\"code\":\"import type { GeoQuery } from '..\u002FGeoQuery';\\n\\nexport type GeoHit = {\\n  entityId: string;\\n  entityType: 'place' | 'service' | 'listing';\\n  lat: number;\\n  lng: number;\\n  citySlug?: string;\\n  countryCode: string;\\n  score?: number;\\n  distanceMeters?: number;\\n};\\n\\nexport type GeoResult = {\\n  hits: GeoHit[];\\n  total: number;\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport interface GeoProvider {\\n  search(query: GeoQuery): Promise\u003CGeoResult>;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"p-queryplan\",\"data\":{\"text\":\"QueryPlan is a small switch on (tenant config + scope kind). Example: some tenants use only SQL; others use a remote portal service. Same GeoQuery input. Same GeoResult output. That makes rollout predictable.\"},\"type\":\"paragraph\"},{\"id\":\"h-sql\",\"data\":{\"text\":\"SQL GeoIndex: Minimal Schema\",\"level\":3},\"type\":\"header\"},{\"id\":\"code-sql\",\"data\":{\"code\":\"CREATE TABLE geo_index (\\n  tenant_id     TEXT NOT NULL,\\n  entity_id     TEXT NOT NULL,\\n  entity_type   TEXT NOT NULL,\\n  country_code  TEXT NOT NULL,\\n  city_slug     TEXT,\\n  lat           DOUBLE PRECISION NOT NULL,\\n  lng           DOUBLE PRECISION NOT NULL,\\n  cell_prefix   TEXT NOT NULL,\\n  category      TEXT,\\n  rating_avg    DOUBLE PRECISION,\\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\\n  PRIMARY KEY (tenant_id, entity_id)\\n);\\n\\nCREATE INDEX geo_index_country_city\\n  ON geo_index (tenant_id, country_code, city_slug);\\n\\nCREATE INDEX geo_index_cell_prefix\\n  ON geo_index (tenant_id, country_code, cell_prefix);\\n\",\"language\":\"sql\"},\"type\":\"code\"},{\"id\":\"h-tradeoffs\",\"data\":{\"text\":\"Trade-offs (Explicit)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-tradeoffs\",\"data\":{\"items\":[\"We trade perfect precision for stable URLs: near pages canonicalize by cellId. Two users 300m apart can land on the same canonical page. Fine.\",\"We trade freshness for cacheability: discovery pages are not real-time. Index updates can lag.\",\"We avoid DB refactor now, but we do pay an extra write path to maintain GeoIndex.\",\"We avoid coupling to CMS routing, but we now own a separate route namespace (\u002Fdiscover). That’s intentional. It’s also a promise we must keep.\",\"We do distance refinement in app code for near searches to avoid expensive DB math. That shifts CPU to the app tier, which is easier to scale horizontally.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-rollout\",\"data\":{\"text\":\"Rollout Plan (Practical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-rollout\",\"data\":{\"items\":[\"Ship resolver + empty pages returning 404 behind a feature flag. Confirm routing doesn’t collide with CMS catch-all.\",\"Build GeoIndex backfill job for one tenant. Validate counts vs source data. Expect mismatches; log them.\",\"Enable country pages only. Watch cache hit ratio and crawl stats.\",\"Enable city pages next. Then near pages last (they are the variant generator).\",\"After we see stable cache keys, add JSON API consumers (maps markers, client filtering).\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-acceptance\",\"data\":{\"text\":\"Acceptance Checklist\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-acceptance\",\"data\":{\"items\":[\"Multi-tenant isolation: same path on two hosts never shares server cache entries.\",\"Canonical URLs do not include query params and remain stable across filter changes.\",\"CMS slug conflicts do not affect \u002Fdiscover routes.\",\"Near pages do not generate unbounded URL permutations (bounded r, pageSize).\",\"Provider contract returns deterministic paging and total counts.\",\"GeoIndex rebuild can run without downtime and without locking source tables for long periods.\"],\"style\":\"unordered\"},\"type\":\"list\"}],\"version\":\"2.29.1\"}",{"time":211,"blocks":683,"version":508},[684,687,690,693,704,707,710,713,722,725,727,730,733,741,744,751,754,757,759,761,764,774,777,780,783,786,789,793,795,798,806,809,812,814,817,820,827,830,837,840,843,849,852,855,858,861,863,866,869,871,874,882,885,893,896],{"id":214,"data":685,"type":41},{"text":686,"level":39},"Geo Discovery: Canonical Architecture, URL Design, Resolver Logic, API & Scalability Spec",{"id":218,"data":688,"type":221},{"text":689},"This document specifies the geo-based discovery surface (country\u002Fcity\u002Fradius) across multiple portals (multi-tenant), without forcing immediate DB refactoring, and without coupling to CMS routing (page\u002Fpost). It keeps SEO stable, stays cache-friendly, and leaves room for booking\u002Freviews\u002Fmaps without turning the app into a blob.",{"id":223,"data":691,"type":41},{"text":692,"level":226},"Constraints and Non-Goals",{"id":228,"data":694,"type":240},{"items":695,"style":239},[696,697,698,699,700,701,702,703],"Multi-tenant: same codebase serves multiple portals. Tenant affects content scope, branding, and sometimes data source.","Geo discovery must support: country, city, radius search (around a point).","No immediate database refactoring: we cannot reshape existing tables into a perfect geo schema right now.","Independence from CMS routing: geo pages are not \"posts\" or \"pages\". They cannot be blocked by CMS slug conflicts.","SEO stability: canonical URLs must not change when filters\u002Fsort options change.","Cache friendliness: CDN + server cache should have predictable keys. Avoid per-user variation.","Strict separation of concerns: discovery, CMS, and tenant resolution are separate modules with explicit boundaries.","Non-goal (for now): perfect geocoding. We accept one geocoder, one normalization strategy, and we store the normalized result.",{"id":242,"data":705,"type":41},{"text":706,"level":226},"Canonical Architecture",{"id":246,"data":708,"type":221},{"text":709},"We implement geo discovery as its own bounded context with a small public surface: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> SEO + Cache metadata. CMS routes never call into discovery. Discovery never calls CMS routing. They share only low-level utilities (HTTP, caching, tenant context).",{"id":250,"data":711,"type":41},{"text":712,"level":253},"Key Components",{"id":255,"data":714,"type":240},{"items":715,"style":239},[716,717,718,719,720,721],"TenantContext: resolves tenant from host header (or explicit portal id in internal calls).","GeoResolver: parses + validates geo URL segments; emits a normalized GeoQuery.","GeoIndex (Read Model): a separate table\u002Fcollection that maps entityId -> lat\u002Flng + tenant scope + minimal searchable fields. This avoids refactoring source DB tables.","Data Providers: pluggable sources (e.g., existing SQL tables, WordPress API, another portal service). They are behind an interface.","SEO Router: produces canonical URL and meta (canonical, hreflang if needed, robots flags).","Caching Layer: CDN cache keys + server-side cache with tenant-scoped keys and versioning.",{"id":265,"data":723,"type":41},{"text":724,"level":226},"Folder Structure",{"id":269,"data":726,"type":273},{"code":271,"language":272},{"id":275,"data":728,"type":41},{"text":729,"level":226},"URL Design (SEO Canonical)",{"id":279,"data":731,"type":221},{"text":732},"We keep the canonical URL purely hierarchical and human-readable. Filters\u002Fsort stay in querystring but do NOT affect the canonical. Radius search uses a stable \"near\" page with a canonical based on a rounded coordinate cell, not the raw lat\u002Flng. That prevents infinite URL variants. It also prevents cache explosion. It’s a deliberate compromise.",{"id":283,"data":734,"type":240},{"items":735,"style":239},[736,737,738,739,740],"Country landing: \u002Fdiscover\u002F{countryCode} (example: \u002Fdiscover\u002Fde)","City landing: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (example: \u002Fdiscover\u002Fde\u002Fmunich)","Near (radius): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (example: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) where cellId is a short geohash-ish identifier","Optional query params (non-canonical): ?q=shih-tzu&category=pet-care&sort=rating&page=2","Tenant is NOT in the path. Tenant is the host (portal-a.tld, portal-b.tld). Internal calls pass x-tenant-id.",{"id":292,"data":742,"type":41},{"text":743,"level":253},"Canonical Rules",{"id":296,"data":745,"type":240},{"items":746,"style":239},[747,748,749,750],"Country\u002Fcity pages canonicalize to their own clean path (no querystring).","Near pages canonicalize to \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. The canonical is derived from a rounded cell, not the incoming coordinates.","page=1 is omitted from canonical and from internal link generation.","Unsupported combinations (e.g., country mismatch) return 404, not a redirect. Redirect chains were hurting crawl budget in testing.",{"id":304,"data":752,"type":41},{"text":753,"level":226},"Resolver Logic",{"id":308,"data":755,"type":221},{"text":756},"Resolver input is (tenant, pathname, query). Resolver output is a normalized GeoQuery with a query plan. No DB access inside the resolver. That separation mattered later. It saved us from a nasty caching bug.",{"id":312,"data":758,"type":273},{"code":314,"language":315},{"id":317,"data":760,"type":273},{"code":319,"language":315},{"id":321,"data":762,"type":41},{"text":763,"level":226},"Step-by-Step Request Flow",{"id":325,"data":765,"type":240},{"items":766,"style":335},[767,768,769,770,771,772,773],"Edge\u002FCDN receives request. Cache key includes host + path + bounded query params (q, category, sort, page, pageSize, r).","App server creates TenantContext from Host header. No DB yet.","GeoResolver parses URL. Produces GeoQuery with canonicalPath and server cacheKey.","GeoController builds a QueryPlan. It decides which provider(s) to hit based on tenant config and scope kind.","Provider executes read-model query against GeoIndex (fast). Then hydrates results from the existing source DB\u002FAPI using entity IDs (no refactor required).","Response assembler attaches SEO metadata: canonical, robots, pagination links.","Server sets cache headers (s-maxage + stale-while-revalidate). Body is tenant-scoped, never shared across tenants.",{"id":337,"data":775,"type":41},{"text":776,"level":226},"No-Refactor Strategy: GeoIndex Read Model",{"id":341,"data":778,"type":221},{"text":779},"We cannot restructure existing content tables right now. So we add a separate GeoIndex that we can rebuild independently. It stores just enough to do discovery efficiently: tenantId, entityId, entityType, countryCode, citySlug, lat, lng, and a few filter fields. Hydration fetches the full object from the current source (SQL rows, CMS API, etc.).",{"id":345,"data":781,"type":221},{"text":782},"Trade-off: eventual consistency. Index rebuild lag is acceptable for discovery pages. We set SLA: updates appear within 15 minutes. If we need real-time later (booking availability), that’s a different surface and should not reuse the discovery cache.",{"id":349,"data":784,"type":41},{"text":785,"level":226},"API Surface",{"id":353,"data":787,"type":221},{"text":788},"Two surfaces: (A) HTML pages for SEO and users, (B) JSON API for client rendering and future extensions. Same resolver + same query plan. Different presenters.",{"id":357,"data":790,"type":240},{"items":791,"style":239},[360,361,792],"Admin (internal): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protected), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, later)",{"id":364,"data":794,"type":273},{"code":366,"language":315},{"id":368,"data":796,"type":41},{"text":797,"level":226},"Scalability & Performance",{"id":372,"data":799,"type":240},{"items":800,"style":239},[801,802,803,804,805],"Primary performance goal: serve cached discovery HTML from CDN for popular geo landings (country\u002Fcity).","Near pages are cacheable but have more variants (cellId + r + q + category + sort + page). We bound r, pageSize, and validate filters strictly.","GeoIndex query must be fast: use tenantId + countryCode + citySlug indexes; for near use cell buckets (prefix match) then refine by distance in app.","Avoid expensive SQL haversine over large datasets. It looks simple. It is not.","Hydration is batched by entityId list. One query per entityType, not N+1.",{"id":381,"data":807,"type":41},{"text":808,"level":253},"Radius Search Strategy (Near Pages)",{"id":385,"data":810,"type":221},{"text":811},"We do not run a full radius scan over all rows. Instead: (1) cellId maps to a bounding bucket (geohash-ish prefix), (2) fetch candidates from GeoIndex by bucket prefix, (3) refine in application with haversine distance, (4) sort + paginate. This keeps DB load predictable.",{"id":389,"data":813,"type":273},{"code":391,"language":315},{"id":393,"data":815,"type":41},{"text":816,"level":226},"Caching Model (CDN + Server)",{"id":397,"data":818,"type":221},{"text":819},"We cache at two layers. CDN caches the full HTML\u002FJSON for anonymous traffic. Server cache stores provider results keyed by GeoQuery.cacheKey. The key includes tenant, scope, and bounded filters. Not everything should be cached. Booking availability later will be excluded.",{"id":401,"data":821,"type":240},{"items":822,"style":239},[823,824,825,826],"CDN cache key varies by Host header. That is the tenant boundary.","Server cache key includes tenantId explicitly. Never rely on implicit host in-process.","Cache TTLs: country\u002Fcity 30–60 minutes at CDN (stale-while-revalidate enabled). near pages 5 minutes.","We keep a manual bust lever per tenant (version suffix in cache key). Used during migrations and bad deploys.",{"id":409,"data":828,"type":41},{"text":829,"level":226},"SEO Stability Details",{"id":413,"data":831,"type":240},{"items":832,"style":239},[833,834,835,836],"Canonical tags: always point to the clean hierarchical path (no querystring).","Robots: if query params are present and not whitelisted (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), set noindex. This blocks garbage params from external links.","Pagination: rel=next\u002Fprev generated only for pages > 1 and if resultCount > pageSize.","Stable internal linking: UI links always emit canonical paths; querystring only for user-selected filters.",{"id":421,"data":838,"type":41},{"text":839,"level":226},"Future Extensions Without Bloat",{"id":425,"data":841,"type":221},{"text":842},"We extend by adding providers and presenters, not by stuffing features into the resolver. Booking, reviews, and maps hang off entity pages or dedicated APIs. Discovery remains a list-and-filter surface. That boundary is enforced in code review.",{"id":429,"data":844,"type":240},{"items":845,"style":239},[846,847,848],"Booking: separate \u002Fapi\u002Fbooking endpoints. Discovery only shows availability badges if cached and non-personal.","Reviews: separate review service\u002Fprovider. Discovery reads aggregated rating fields from GeoIndex (precomputed).","Maps: map tiles and markers fetched from \u002Fapi\u002Fdiscovery\u002Fmarkers with aggressive caching; not embedded into the HTML response if it breaks TTFB.",{"id":436,"data":850,"type":41},{"text":851,"level":226},"One Thing That Went Wrong (and What We Changed)",{"id":440,"data":853,"type":221},{"text":854},"We shipped the first version with a server cache key that did NOT include tenantId. It “worked” in local. In staging it looked fine. Then production. Portal A started showing Portal B listings on city pages. Same path, different tenant. Cache collision. Ugly.",{"id":444,"data":856,"type":221},{"text":857},"Fix was boring but strict: tenantId became mandatory in GeoQuery, and cache key building moved into the resolver so it can’t be skipped. We also added a runtime assertion: if hydrated entities contain a different tenantId, we throw and skip cache write. That’s noisy on purpose.",{"id":448,"data":859,"type":41},{"text":860,"level":226},"Query Plan and Provider Contract",{"id":452,"data":862,"type":273},{"code":454,"language":315},{"id":456,"data":864,"type":221},{"text":865},"QueryPlan is a small switch on (tenant config + scope kind). Example: some tenants use only SQL; others use a remote portal service. Same GeoQuery input. Same GeoResult output. That makes rollout predictable.",{"id":460,"data":867,"type":41},{"text":868,"level":253},"SQL GeoIndex: Minimal Schema",{"id":464,"data":870,"type":273},{"code":466,"language":467},{"id":469,"data":872,"type":41},{"text":873,"level":226},"Trade-offs (Explicit)",{"id":473,"data":875,"type":240},{"items":876,"style":239},[877,878,879,880,881],"We trade perfect precision for stable URLs: near pages canonicalize by cellId. Two users 300m apart can land on the same canonical page. Fine.","We trade freshness for cacheability: discovery pages are not real-time. Index updates can lag.","We avoid DB refactor now, but we do pay an extra write path to maintain GeoIndex.","We avoid coupling to CMS routing, but we now own a separate route namespace (\u002Fdiscover). That’s intentional. It’s also a promise we must keep.","We do distance refinement in app code for near searches to avoid expensive DB math. That shifts CPU to the app tier, which is easier to scale horizontally.",{"id":482,"data":883,"type":41},{"text":884,"level":226},"Rollout Plan (Practical)",{"id":486,"data":886,"type":240},{"items":887,"style":335},[888,889,890,891,892],"Ship resolver + empty pages returning 404 behind a feature flag. Confirm routing doesn’t collide with CMS catch-all.","Build GeoIndex backfill job for one tenant. Validate counts vs source data. Expect mismatches; log them.","Enable country pages only. Watch cache hit ratio and crawl stats.","Enable city pages next. Then near pages last (they are the variant generator).","After we see stable cache keys, add JSON API consumers (maps markers, client filtering).",{"id":495,"data":894,"type":41},{"text":895,"level":226},"Acceptance Checklist",{"id":499,"data":897,"type":240},{"items":898,"style":239},[899,900,901,902,903,904],"Multi-tenant isolation: same path on two hosts never shares server cache entries.","Canonical URLs do not include query params and remain stable across filter changes.","CMS slug conflicts do not affect \u002Fdiscover routes.","Near pages do not generate unbounded URL permutations (bounded r, pageSize).","Provider contract returns deterministic paging and total counts.","GeoIndex rebuild can run without downtime and without locking source tables for long periods.","Geo-based discovery architecture for multi-tenant portals. Defines canonical URLs, resolver logic, caching strategy, and a geo read-model without CMS coupling or database refactoring. Designed for SEO stability, scalability, and future extensions like booking and maps.","Post erfolgreich abgerufen",{"items":908,"source":944,"manualIds":945,"manualMatchedIds":946},[909,916,923,930,937],{"id":910,"slug":911,"title":912,"excerpt":913,"featuredImage":914,"publishedAt":915},"471","how-to-know-whether-an-ai-agent-actually-used-the-right-evidence","Wie man erkennt, ob ein KI-Agent tatsächlich die richtigen Belege verwendet hat","Ein KI-Agent kann Quellen zitieren und trotzdem die falschen Belege verwenden. Dieser Artikel stellt eine praktische Methode zur Überprüfung der Belegung von Behauptungen, der Quellenautorität, der Anwendbarkeit, der Herkunft sowie der Frage vor, ob die Belege die Antwort tatsächlich beeinflusst haben.","\u002Fuploads\u002F2026\u002F09\u002Fhow-to-know-whether-an-ai-agent-actually-used-the-right-evidence-1790351317188-o5z9ve.webp","2026-09-25T11:47:00.000Z",{"id":917,"slug":918,"title":919,"excerpt":920,"featuredImage":921,"publishedAt":922},"381","enterprise-grade-multi-tenant-architecture-for-an-international-platform","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.","\u002Fuploads\u002F2026\u002F01\u002Fenterprise-grade-multi-tenant-architecture-for-an-international-platform-1769789121298-b6v7ak.webp","2026-01-30T12:04:00.000Z",{"id":924,"slug":925,"title":926,"excerpt":927,"featuredImage":928,"publishedAt":929},"456","zbt-z8102ax-hardware-packaging-review","ZBT Z8102AX Hardware- und Verpackungs-Review: Starker Router, schwache Box","Der ZBT Z8102AX macht einen soliden ersten Eindruck als schlanker, schwarzer 5G-OpenWrt-Router aus Metall mit mehreren Antennenanschlüssen, Dual-SIM-Slots, USB- und LAN\u002FWAN-Ports und einem praktischen Zubehörset. Die Hardware fühlt sich nützlich und seriös an, aber die Verpackung ist eindeutig die Schwachstelle.","\u002Fuploads\u002F2026\u002F06\u002Fopenwrt-router-review-dual-sim-02-1781620590938-y33j4b.webp","2026-06-16T04:40:00.000Z",{"id":931,"slug":932,"title":933,"excerpt":934,"featuredImage":935,"publishedAt":936},"454","zbt-z8102ax-rm500u-ea-5g-modem-test","Quectel RM500U-EA im ZBT Z8102AX: 5G-Bänder, o2 Germany und Signalverhalten in der Praxis","Der ZBT Z8102AX verwendet ein Quectel RM500U-EA-Modem für die 4G- und 5G-Konnektivität. Im ersten Praxistest verband sich der Router erfolgreich mit o2 Germany mit LTE-Band 3 und NR n28. Das Modem funktioniert, aber tiefergehende Diagnosen wie RSRP, RSRQ, SINR, Band-Locking und Zellverhalten müssen noch richtig getestet werden.","\u002Fuploads\u002F2026\u002F06\u002Fopenwrt-router-review-dual-sim-06-1781620597879-qay2sx.webp","2026-06-16T08:39:00.000Z",{"id":938,"slug":939,"title":940,"excerpt":941,"featuredImage":942,"publishedAt":943},"361","model-view-controller-mvc","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.","\u002Fuploads\u002F2026\u002F03\u002Fmodel-view-controller-mvc-1774872805793-0bjubu.webp","2023-04-12T12:57:00.000Z","fallback",[],[]]