Architettura Canonica, Progettazione URL, Logica del Resolver, Specifiche API e Scalabilità

Architettura di scoperta geobasata per portali multi-tenant. Definisce URL canonici, logica di risoluzione, strategia di caching e un modello di lettura geografico senza accoppiamento con CMS o rifattorizzazione del database. Progettata per stabilità SEO, scalabilità ed estensioni future come prenotazioni e mappe.
Pubblicato:
Aleksandar Stajić
Aggiornato il: 20 febbraio 2026 alle ore 21:40
Architettura Canonica, Progettazione URL, Logica del Resolver, Specifiche API e Scalabilità

Illustrazione

Geo Discovery: architettura canonica, design URL, logica del resolver, API e specifica di scalabilità

Questo documento definisce la superficie di discovery basata sulla geolocalizzazione (paese/città/raggio) su più portali (multi-tenant), senza forzare un refactoring immediato del DB e senza accoppiarsi al routing del CMS (pagina/post). Mantiene stabile la SEO, resta cache-friendly e lascia spazio a prenotazioni/recensioni/mappe senza trasformare l’app in un blob.

Vincoli e non-obiettivi

  • Multi-tenant: la stessa codebase serve più portali. Il tenant influenza lo scope dei contenuti, il branding e talvolta la fonte dati.
  • La geo discovery deve supportare: paese, città, ricerca per raggio (attorno a un punto).
  • Nessun refactoring immediato del database: non possiamo rimodellare le tabelle esistenti in uno schema geo perfetto adesso.
  • Indipendenza dal routing del CMS: le pagine geo non sono “post” o “pagine”. Non possono essere bloccate da conflitti di slug del CMS.
  • Stabilità SEO: gli URL canonici non devono cambiare quando cambiano filtri/opzioni di ordinamento.
  • Cache-friendliness: CDN + cache server devono avere chiavi prevedibili. Evitare variazioni per utente.
  • Separazione rigorosa delle responsabilità: discovery, CMS e risoluzione del tenant sono moduli separati con confini espliciti.
  • Non-obiettivo (per ora): geocoding perfetto. Accettiamo un geocoder, una strategia di normalizzazione e memorizziamo il risultato normalizzato.

Architettura canonica

Implementiamo la geo discovery come un bounded context separato con una piccola superficie pubblica: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> metadati SEO + Cache. Le route CMS non chiamano mai la discovery. La discovery non chiama mai il routing del CMS. Condividono solo utility di basso livello (HTTP, caching, contesto tenant).

Componenti chiave

  • TenantContext: risolve il tenant dall’header Host (o da un portal id esplicito nelle chiamate interne).
  • GeoResolver: analizza + valida i segmenti URL geo; emette un GeoQuery normalizzato.
  • GeoIndex (Read Model): una tabella/collezione separata che mappa entityId -> lat/lng + scope tenant + campi minimi ricercabili. Evita il refactoring delle tabelle DB sorgente.
  • Data Providers: sorgenti plug-in (es. tabelle SQL esistenti, API WordPress, un altro servizio di portale). Sono dietro un’interfaccia.
  • SEO Router: produce URL canonico e meta (canonical, hreflang se necessario, flag robots).
  • Caching Layer: chiavi cache CDN + cache lato server con chiavi tenant-scoped e versioning.

Struttura delle cartelle

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

Design URL (SEO Canonical)

Manteniamo l’URL canonico puramente gerarchico e leggibile. Filtri/ordinamento restano nella querystring ma NON influenzano il canonical. La ricerca per raggio usa una pagina “near” stabile con un canonical basato su una cella di coordinate arrotondata, non sul lat/lng grezzo. Questo evita varianti infinite di URL e previene l’esplosione della cache. È un compromesso deliberato.

  • Landing paese: /discover/{countryCode} (esempio: /discover/de)
  • Landing città: /discover/{countryCode}/{citySlug} (esempio: /discover/de/munich)
  • Near (raggio): /discover/{countryCode}/near/{cellId} (esempio: /discover/de/near/u281z) dove cellId è un identificatore breve tipo geohash
  • Query params opzionali (non canonici): ?q=shih-tzu&category=pet-care&sort=rating&page=2
  • Il tenant NON è nel path. Il tenant è l’host (portal-a.tld, portal-b.tld). Le chiamate interne passano x-tenant-id.

Regole canonical

  • Le pagine paese/città canonizzano sul proprio path pulito (senza querystring).
  • Le pagine near canonizzano su /discover/{country}/near/{cellId}. Il canonical deriva da una cella arrotondata, non dalle coordinate in ingresso.
  • page=1 è omesso dal canonical e dalla generazione dei link interni.
  • Combinazioni non supportate (es. mismatch paese) restituiscono 404, non un redirect. Le catene di redirect danneggiavano il crawl budget nei test.

Logica del resolver

L’input del resolver è (tenant, pathname, query). L’output è un GeoQuery normalizzato con un query plan. Nessun accesso DB dentro il resolver. Questa separazione è stata importante in seguito: ci ha salvato da un brutto bug di caching.

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('&')}`;
}

Flusso della richiesta passo per passo

  1. Edge/CDN riceve la richiesta. La chiave cache include host + path + query params limitati (q, category, sort, page, pageSize, r).
  2. Il server applicativo crea TenantContext dall’header Host. Nessun DB ancora.
  3. GeoResolver analizza l’URL. Produce GeoQuery con canonicalPath e cacheKey del server.
  4. GeoController costruisce un QueryPlan. Decide quali provider colpire in base alla configurazione tenant e al tipo di scope.
  5. Il provider esegue la query read-model su GeoIndex (veloce). Poi idrata i risultati dalla sorgente DB/API esistente usando gli entity ID (nessun refactor richiesto).
  6. L’assembler della risposta aggiunge metadati SEO: canonical, robots, link di paginazione.
  7. Il server imposta gli header cache (s-maxage + stale-while-revalidate). Il body è tenant-scoped, mai condiviso tra tenant.

Strategia senza refactor: GeoIndex read model

Non possiamo ristrutturare ora le tabelle contenuto esistenti. Quindi aggiungiamo un GeoIndex separato che possiamo ricostruire indipendentemente. Memorizza quanto basta per una discovery efficiente: tenantId, entityId, entityType, countryCode, citySlug, lat, lng e alcuni campi filtro. L’idratazione recupera l’oggetto completo dalla fonte attuale (righe SQL, API CMS, ecc.).

Compromesso: consistenza eventuale. Il ritardo di rebuild dell’indice è accettabile per le pagine discovery. Impostiamo uno SLA: gli aggiornamenti appaiono entro 15 minuti. Se in futuro serve real-time (disponibilità prenotazioni), è una superficie diversa e non deve riusare la cache di discovery.

Superficie API

Due superfici: (A) pagine HTML per SEO e utenti, (B) API JSON per rendering client ed estensioni future. Stesso resolver + stesso query plan. Presenter diversi.

  • 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 (interno): POST /internal/geoindex/rebuild (protetto), POST /internal/geoindex/upsert (opzionale, più avanti)
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));
}

Scalabilità e prestazioni

  • Obiettivo principale di performance: servire HTML di discovery in cache dal CDN per landing geo popolari (paese/città).
  • Le pagine near sono cacheabili ma hanno più varianti (cellId + r + q + category + sort + page). Limitiamo r, pageSize e validiamo i filtri in modo rigoroso.
  • La query su GeoIndex deve essere veloce: usare indici tenantId + countryCode + citySlug; per near usare bucket di celle (match per prefisso) e poi rifinire per distanza nell’app.
  • Evitare l’haversine SQL costoso su dataset grandi. Sembra semplice. Non lo è.
  • L’idratazione è batched per lista di entityId. Una query per entityType, non N+1.

Strategia di ricerca per raggio (pagine near)

Non eseguiamo una scansione completa a raggio su tutte le righe. Invece: (1) cellId mappa a un bucket di bounding (prefisso tipo geohash), (2) recuperiamo candidati da GeoIndex tramite prefisso bucket, (3) rifiniamo nell’app con distanza haversine, (4) ordiniamo + paginiamo. Questo rende il carico DB prevedibile.

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;
}

Modello di caching (CDN + server)

Cache su due livelli. Il CDN cachea l’intero HTML/JSON per traffico anonimo. La cache server memorizza i risultati dei provider indicizzati da GeoQuery.cacheKey. La chiave include tenant, scope e filtri limitati. Non tutto deve essere cacheato. La disponibilità prenotazioni verrà esclusa più avanti.

  • La chiave cache CDN varia in base all’header Host. È il confine tenant.
  • La chiave cache server include tenantId esplicitamente. Mai affidarsi all’host implicito in-process.
  • TTL cache: paese/città 30–60 minuti su CDN (stale-while-revalidate abilitato). Pagine near 5 minuti.
  • Manteniamo una leva manuale di bust per tenant (suffisso di versione nella cache key). Usata durante migrazioni e deploy problematici.

Dettagli di stabilità SEO

  • Tag canonical: puntano sempre al path gerarchico pulito (senza querystring).
  • Robots: se sono presenti query params non in whitelist (q/category/sort/page/pageSize/r), impostare noindex. Questo blocca parametri spazzatura da link esterni.
  • Paginazione: rel=next/prev generati solo per pagine > 1 e se resultCount > pageSize.
  • Linking interno stabile: i link UI emettono sempre path canonici; querystring solo per filtri selezionati dall’utente.

Estensioni future senza bloat

Estendiamo aggiungendo provider e presenter, non infilando funzionalità nel resolver. Prenotazioni, recensioni e mappe vivono su pagine entity o API dedicate. La discovery resta una superficie list-and-filter. Questo confine viene imposto in code review.

  • Prenotazioni: endpoint /api/booking separati. La discovery mostra badge di disponibilità solo se cacheati e non personali.
  • Recensioni: servizio/provider separato. La discovery legge campi di rating aggregati da GeoIndex (precalcolati).
  • Mappe: tile e marker da /api/discovery/markers con caching aggressivo; non incorporare nell’HTML se peggiora TTFB.

Una cosa andata storta (e cosa abbiamo cambiato)

Abbiamo rilasciato la prima versione con una cache key server che NON includeva tenantId. In locale “funzionava”. In staging sembrava ok. Poi produzione. Il Portale A ha iniziato a mostrare listing del Portale B sulle pagine città. Stesso path, tenant diverso. Collisione di cache. Brutto.

La correzione è stata noiosa ma rigorosa: tenantId è diventato obbligatorio in GeoQuery e la costruzione della cache key è stata spostata nel resolver così non può essere saltata. Abbiamo anche aggiunto un’asserzione runtime: se le entità idratate contengono un tenantId diverso, lanciamo un errore e saltiamo la scrittura in cache. È rumoroso apposta.

Query plan e contratto del provider

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>;
}

Il QueryPlan è un piccolo switch su (tenant config + scope kind). Esempio: alcuni tenant usano solo SQL; altri un servizio remoto del portale. Stesso input GeoQuery. Stesso output GeoResult. Questo rende il rollout prevedibile.

SQL GeoIndex: schema minimo

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);

Compromessi (espliciti)

  • Scambiamo precisione perfetta per URL stabili: le pagine near canonizzano per cellId. Due utenti a 300 m possono finire sulla stessa pagina canonical. Va bene.
  • Scambiamo freschezza per cacheabilità: le pagine discovery non sono real-time. Gli update dell’indice possono ritardare.
  • Evitiamo il refactor DB ora, ma paghiamo un percorso di scrittura extra per mantenere GeoIndex.
  • Evitiamo l’accoppiamento al routing CMS, ma ora possediamo un namespace di route separato (/discover). È intenzionale. Ed è una promessa da mantenere.
  • Rifiniamo la distanza nel codice applicativo per le ricerche near per evitare matematica DB costosa. Sposta CPU sul tier applicativo, più facile da scalare orizzontalmente.

Piano di rollout (pratico)

  1. Rilasciare resolver + pagine vuote che restituiscono 404 dietro feature flag. Confermare che il routing non collide col catch-all del CMS.
  2. Costruire un job di backfill GeoIndex per un tenant. Validare i conteggi vs dati sorgente. Aspettarsi mismatch; loggarli.
  3. Abilitare solo le pagine paese. Monitorare cache hit ratio e statistiche di crawl.
  4. Abilitare poi le pagine città. Le pagine near per ultime (sono generatrici di varianti).
  5. Dopo chiavi cache stabili, aggiungere consumer della JSON API (marker mappa, filtering client).

Checklist di accettazione

  • Isolamento multi-tenant: lo stesso path su due host non condivide mai entry di cache server.
  • Gli URL canonici non includono query params e restano stabili quando cambiano i filtri.
  • I conflitti di slug del CMS non influenzano le route /discover.
  • Le pagine near non generano permutazioni URL illimitate (r e pageSize limitati).
  • Il contratto del provider restituisce paginazione deterministica e total count.
  • Il rebuild di GeoIndex può girare senza downtime e senza bloccare a lungo le tabelle sorgente.