Canonical Architecture, URL Design, Resolver Logic, API & Scalability Specification

AI generated
Geo Discovery: Canonical Architecture, URL Design, Resolver Logic, API & Scalability Spec
This document specifies the geo-based discovery surface (country/city/radius) across multiple portals (multi-tenant), without forcing immediate DB refactoring, and without coupling to CMS routing (page/post). It keeps SEO stable, stays cache-friendly, and leaves room for booking/reviews/maps without turning the app into a blob.
Constraints and Non-Goals
- 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/sort 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.
Canonical Architecture
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).
Key Components
- 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/collection that maps entityId -> lat/lng + 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.
Folder Structure
apps/
web/
routes/
discovery/ # discovery entry points (not CMS)
pages/
[...cms].vue # CMS catch-all (kept away from /discover)
server/
src/
tenant/
TenantContext.ts
tenantConfig.ts
discovery/
geo/
GeoResolver.ts
GeoQuery.ts
GeoCanonical.ts
GeoController.ts
providers/
GeoProvider.ts
SqlGeoProvider.ts
RemoteGeoProvider.ts
index/
GeoIndexRepository.ts
migrations/
cache/
Cache.ts
cacheKeys.ts
http/
errors.ts
requestContext.ts
packages/
shared/
src/
geo/
normalize.ts
haversine.ts
validation/
zod.ts
URL Design (SEO Canonical)
We keep the canonical URL purely hierarchical and human-readable. Filters/sort 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/lng. That prevents infinite URL variants. It also prevents cache explosion. It’s a deliberate compromise.
- Country landing: /discover/{countryCode} (example: /discover/de)
- City landing: /discover/{countryCode}/{citySlug} (example: /discover/de/munich)
- Near (radius): /discover/{countryCode}/near/{cellId} (example: /discover/de/near/u281z) 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.
Canonical Rules
- Country/city pages canonicalize to their own clean path (no querystring).
- Near pages canonicalize to /discover/{country}/near/{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.
Resolver Logic
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.
export type TenantId = string;
export type GeoScope =
| { kind: 'country'; countryCode: string }
| { kind: 'city'; countryCode: string; citySlug: string }
| { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };
export type GeoFilters = {
q?: string;
category?: string;
sort?: 'relevance' | 'rating' | 'distance';
page: number;
pageSize: number;
};
export type GeoQuery = {
tenantId: TenantId;
scope: GeoScope;
filters: GeoFilters;
canonicalPath: string;
cacheKey: string;
};
import { z } from 'zod';
import type { GeoQuery, TenantId } from './GeoQuery';
const QuerySchema = z.object({
q: z.string().trim().min(1).max(120).optional(),
category: z.string().trim().min(1).max(60).optional(),
sort: z.enum(['relevance', 'rating', 'distance']).optional(),
page: z.coerce.number().int().min(1).max(200).default(1),
pageSize: z.coerce.number().int().min(5).max(50).default(20)
});
const CountryCodeSchema = z.string().regex(/^[a-z]{2}$/i);
const CitySlugSchema = z.string().regex(/^[a-z0-9-]{2,80}$/i);
const CellIdSchema = z.string().regex(/^[a-z0-9]{4,12}$/i);
export function resolveGeo(
tenantId: TenantId,
pathname: string,
query: Record<string, unknown>
): GeoQuery {
const filters = QuerySchema.parse(query);
const parts = pathname.split('/').filter(Boolean);
// /discover/{country}
// /discover/{country}/{city}
// /discover/{country}/near/{cellId}
if (parts[0] !== 'discover') {
throw new Error('Not a discovery route');
}
const countryCode = CountryCodeSchema.parse(parts[1] ?? '');
let scope: GeoQuery['scope'];
if (parts.length === 2) {
scope = { kind: 'country', countryCode: countryCode.toLowerCase() };
} else if (parts[2] === 'near') {
const cellId = CellIdSchema.parse(parts[3] ?? '');
// radius is NOT in the path, but is bounded.
const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));
scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };
} else {
const citySlug = CitySlugSchema.parse(parts[2] ?? '');
scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };
}
const canonicalPath = canonicalizePath(scope);
const cacheKey = buildCacheKey(tenantId, scope, filters);
return {
tenantId,
scope,
filters: {
...filters,
sort: filters.sort ?? 'relevance'
},
canonicalPath,
cacheKey
};
}
function canonicalizePath(scope: GeoQuery['scope']): string {
switch (scope.kind) {
case 'country':
return `/discover/${scope.countryCode}`;
case 'city':
return `/discover/${scope.countryCode}/${scope.citySlug}`;
case 'near':
return `/discover/${scope.countryCode}/near/${scope.cellId}`;
}
}
function buildCacheKey(
tenantId: string,
scope: GeoQuery['scope'],
filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }
): string {
// Note: canonical ignores querystring, cache does not.
// But we keep it bounded and explicit.
const q = filters.q ? `q=${filters.q}` : '';
const c = filters.category ? `cat=${filters.category}` : '';
const s = `sort=${filters.sort ?? 'relevance'}`;
const p = `p=${filters.page}`;
const ps = `ps=${filters.pageSize}`;
const scopeKey =
scope.kind === 'country'
? `country:${scope.countryCode}`
: scope.kind === 'city'
? `city:${scope.countryCode}:${scope.citySlug}`
: `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;
return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;
}
Step-by-Step Request Flow
- Edge/CDN 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/API 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.
No-Refactor Strategy: GeoIndex Read Model
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.).
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.
API Surface
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.
- HTML: GET /discover/{country}, /discover/{country}/{city}, /discover/{country}/near/{cellId}
- API: GET /api/discovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..
- Admin (internal): POST /internal/geoindex/rebuild (protected), POST /internal/geoindex/upsert (optional, later)
import type { IncomingMessage, ServerResponse } from 'http';
import { resolveGeo } from './GeoResolver';
import { runQueryPlan } from './GeoController';
export async function discoveryApi(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? '', 'http://localhost');
const tenantId = String(req.headers['x-tenant-id'] ?? 'default');
// We reuse the same resolver by mapping query -> a pseudo-path.
// This keeps logic aligned between HTML and API.
const scope = url.searchParams.get('scope') ?? 'country';
const country = url.searchParams.get('country') ?? '';
const city = url.searchParams.get('city');
const cell = url.searchParams.get('cell');
const pseudoPath =
scope === 'city' && city
? `/discover/${country}/${city}`
: scope === 'near' && cell
? `/discover/${country}/near/${cell}`
: `/discover/${country}`;
const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));
const result = await runQueryPlan(geoQuery);
res.statusCode = 200;
res.setHeader('content-type', 'application/json; charset=utf-8');
// cache: public at CDN, tenant-specific key already handled upstream
res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');
res.end(JSON.stringify(result));
}
Scalability & Performance
- Primary performance goal: serve cached discovery HTML from CDN for popular geo landings (country/city).
- 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.
Radius Search Strategy (Near Pages)
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.
export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const sinDLat = Math.sin(dLat / 2);
const sinDLng = Math.sin(dLng / 2);
const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;
const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));
return R * c;
}
Caching Model (CDN + Server)
We cache at two layers. CDN caches the full HTML/JSON 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.
- 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/city 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.
SEO Stability Details
- Canonical tags: always point to the clean hierarchical path (no querystring).
- Robots: if query params are present and not whitelisted (q/category/sort/page/pageSize/r), set noindex. This blocks garbage params from external links.
- Pagination: rel=next/prev generated only for pages > 1 and if resultCount > pageSize.
- Stable internal linking: UI links always emit canonical paths; querystring only for user-selected filters.
Future Extensions Without Bloat
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.
- Booking: separate /api/booking endpoints. Discovery only shows availability badges if cached and non-personal.
- Reviews: separate review service/provider. Discovery reads aggregated rating fields from GeoIndex (precomputed).
- Maps: map tiles and markers fetched from /api/discovery/markers with aggressive caching; not embedded into the HTML response if it breaks TTFB.
One Thing That Went Wrong (and What We Changed)
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.
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.
Query Plan and Provider Contract
import type { GeoQuery } from '../GeoQuery';
export type GeoHit = {
entityId: string;
entityType: 'place' | 'service' | 'listing';
lat: number;
lng: number;
citySlug?: string;
countryCode: string;
score?: number;
distanceMeters?: number;
};
export type GeoResult = {
hits: GeoHit[];
total: number;
page: number;
pageSize: number;
};
export interface GeoProvider {
search(query: GeoQuery): Promise<GeoResult>;
}
QueryPlan 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.
SQL GeoIndex: Minimal Schema
CREATE TABLE geo_index (
tenant_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
country_code TEXT NOT NULL,
city_slug TEXT,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
cell_prefix TEXT NOT NULL,
category TEXT,
rating_avg DOUBLE PRECISION,
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, entity_id)
);
CREATE INDEX geo_index_country_city
ON geo_index (tenant_id, country_code, city_slug);
CREATE INDEX geo_index_cell_prefix
ON geo_index (tenant_id, country_code, cell_prefix);
Trade-offs (Explicit)
- 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 (/discover). 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.
Rollout Plan (Practical)
- 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).
Acceptance Checklist
- 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 /discover 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.