Arquitectura Canónica, Diseño de URL, Lógica del Resolvedor, Especificación de API y Escalabilidad

Ilustración
Geo Discovery: arquitectura canónica, diseño de URL, lógica del resolver, API y especificación de escalabilidad
Este documento especifica la superficie de descubrimiento geográfico (país/ciudad/radio) a través de múltiples portales (multi-tenant), sin forzar una refactorización inmediata de la base de datos y sin acoplarse al enrutamiento del CMS (página/post). Mantiene la estabilidad SEO, es compatible con la caché y deja espacio para reservas, reseñas y mapas sin convertir la aplicación en un bloque monolítico.
Restricciones y no-objetivos
- Multi-tenant: la misma base de código sirve a múltiples portales. El tenant afecta el alcance del contenido, la identidad visual y, en ocasiones, la fuente de datos.
- El descubrimiento geográfico debe soportar: país, ciudad y búsqueda por radio (alrededor de un punto).
- Sin refactorización inmediata de la base de datos: no podemos reestructurar las tablas existentes en un esquema geo ideal en este momento.
- Independencia del enrutamiento del CMS: las páginas geo no son “posts” ni “páginas”. No pueden ser bloqueadas por conflictos de slug del CMS.
- Estabilidad SEO: las URL canónicas no deben cambiar cuando cambian los filtros u opciones de ordenación.
- Compatibilidad con caché: el CDN y la caché del servidor deben tener claves predecibles. Evitar variaciones por usuario.
- Separación estricta de responsabilidades: discovery, CMS y resolución del tenant son módulos separados con límites explícitos.
- No-objetivo (por ahora): geocodificación perfecta. Aceptamos un solo geocoder, una estrategia de normalización y almacenamos el resultado normalizado.
Arquitectura canónica
Implementamos el descubrimiento geográfico como un bounded context autónomo con una superficie pública mínima: (1) URL → Resolver, (2) Resolver → Query Plan, (3) Query Plan → Data Providers, (4) Respuesta → metadatos SEO y de caché. Las rutas del CMS nunca llaman a discovery. Discovery nunca llama al enrutamiento del CMS. Solo comparten utilidades de bajo nivel (HTTP, caché, contexto de tenant).
Componentes clave
- TenantContext: resuelve el tenant a partir del encabezado Host (o de un identificador de portal explícito en llamadas internas).
- GeoResolver: analiza y valida los segmentos de URL geográficos; emite un GeoQuery normalizado.
- GeoIndex (Read Model): tabla o colección separada que mapea entityId → lat/lng + scope del tenant + campos mínimos buscables. Esto evita refactorizar las tablas de la base de datos origen.
- Data Providers: fuentes intercambiables (por ejemplo, tablas SQL existentes, API de WordPress, otro servicio de portal), encapsuladas detrás de una interfaz.
- SEO Router: genera la URL canónica y los metadatos (canonical, hreflang si es necesario, flags robots).
- Capa de caché: claves de caché del CDN y caché del servidor con claves tenant-scoped y versionado.
Estructura de carpetas
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
Diseño de URL (SEO Canonical)
Mantenemos la URL canónica puramente jerárquica y legible para humanos. Los filtros y la ordenación permanecen en la querystring, pero NO afectan al canonical. La búsqueda por radio utiliza una página “near” estable con un canonical basado en una celda de coordenadas redondeada, no en lat/lng en bruto. Esto evita variantes infinitas de URL y previene la explosión de la caché. Es un compromiso deliberado.
- Landing de país: /discover/{countryCode} (ejemplo: /discover/de)
- Landing de ciudad: /discover/{countryCode}/{citySlug} (ejemplo: /discover/de/munich)
- Near (radio): /discover/{countryCode}/near/{cellId} (ejemplo: /discover/de/near/u281z), donde cellId es un identificador corto tipo geohash
- Parámetros de consulta opcionales (no canónicos): ?q=shih-tzu&category=pet-care&sort=rating&page=2
- El tenant NO está en la ruta. El tenant es el host (portal-a.tld, portal-b.tld). Las llamadas internas envían x-tenant-id.
Reglas de canonicalización
- Las páginas de país/ciudad canonizan hacia su propia ruta limpia (sin querystring).
- Las páginas near canonizan a /discover/{country}/near/{cellId}. El canonical se deriva de una celda redondeada, no de las coordenadas entrantes.
- page=1 se omite del canonical y de la generación de enlaces internos.
- Las combinaciones no soportadas (por ejemplo, desajuste de país) devuelven 404, no una redirección. Las cadenas de redirección perjudicaban el presupuesto de rastreo en las pruebas.
Lógica del resolver
La entrada del resolver es (tenant, pathname, query). La salida es un GeoQuery normalizado con un query plan. No hay acceso a base de datos dentro del resolver. Esta separación fue crucial más adelante: nos salvó de un bug de caché bastante desagradable.
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);
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] ?? '');
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 {
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('&')}`;
}
Flujo de solicitud paso a paso
- El Edge/CDN recibe la solicitud. La clave de caché incluye el host + la ruta + parámetros de consulta acotados (q, category, sort, page, pageSize, r).
- El servidor de la aplicación crea el TenantContext a partir del encabezado Host. Sin acceso a base de datos todavía.
- El GeoResolver analiza la URL y produce un GeoQuery con canonicalPath y cacheKey del servidor.
- El GeoController construye un QueryPlan y decide qué provider(s) usar según la configuración del tenant y el tipo de scope.
- El provider ejecuta una consulta del read-model contra el GeoIndex (rápida) y luego hidrata los resultados desde la fuente existente (DB SQL, API CMS) usando los entity IDs.
- El ensamblador de la respuesta adjunta metadatos SEO: canonical, robots y enlaces de paginación.
- El servidor establece encabezados de caché (s-maxage + stale-while-revalidate). El cuerpo está acotado por tenant y nunca se comparte entre tenants.
Estrategia sin refactorización: Read Model GeoIndex
No podemos reestructurar las tablas de contenido existentes de inmediato. Por ello añadimos un GeoIndex separado que puede reconstruirse de forma independiente. Almacena solo lo necesario para una discovery eficiente: tenantId, entityId, entityType, countryCode, citySlug, lat, lng y algunos campos de filtrado. La hidratación obtiene el objeto completo desde la fuente actual.
Compromiso: consistencia eventual. El retraso en la reconstrucción del índice es aceptable para las páginas de discovery. SLA: las actualizaciones aparecen en un plazo de 15 minutos. Si más adelante necesitamos tiempo real (disponibilidad de reservas), esa será otra superficie y no debe reutilizar la caché de discovery.
Superficie de API
Dos superficies: (A) páginas HTML para SEO y usuarios, (B) API JSON para renderizado del cliente y extensiones futuras. Mismo resolver y mismo query plan. Presentadores distintos.
- 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 (protegido), POST /internal/geoindex/upsert (opcional, más adelante)
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');
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');
res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');
res.end(JSON.stringify(result));
}
Escalabilidad y rendimiento
- Objetivo principal de rendimiento: servir el HTML de discovery desde el CDN para los landings geo populares (país/ciudad).
- Las páginas near son cacheables pero tienen más variantes (cellId + r + q + category + sort + page). Acotamos r y pageSize y validamos estrictamente los filtros.
- La consulta al GeoIndex debe ser rápida: usar índices por tenantId + countryCode + citySlug; para near usar buckets de celdas (coincidencia por prefijo) y refinar la distancia en la aplicación.
- Evitar cálculos haversine SQL costosos sobre grandes conjuntos de datos. Parece simple. No lo es.
- La hidratación se realiza por lotes según la lista de entityId: una consulta por entityType, no N+1.
Estrategia de búsqueda por radio (páginas near)
No ejecutamos un escaneo de radio completo sobre todas las filas. En su lugar: (1) el cellId mapea a un bucket delimitador (prefijo tipo geohash), (2) se obtienen candidatos del GeoIndex por prefijo de bucket, (3) se refina en la aplicación usando distancia haversine, (4) se ordena y pagina. Esto mantiene la carga de la base de datos predecible.
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;
}
Modelo de caché (CDN + servidor)
Cacheamos en dos capas. El CDN cachea el HTML/JSON completo para tráfico anónimo. El caché del servidor almacena los resultados de los providers indexados por GeoQuery.cacheKey. La clave incluye tenant, scope y filtros acotados. No todo debe cachearse. La disponibilidad de reservas se excluirá más adelante.
- La clave de caché del CDN varía según el encabezado Host: ese es el límite del tenant.
- La clave de caché del servidor incluye explícitamente tenantId. Nunca depender de un host implícito en memoria.
- TTL de caché: país/ciudad 30–60 minutos en el CDN (stale-while-revalidate habilitado). Páginas near: 5 minutos.
- Mantenemos una palanca manual de invalidación por tenant (sufijo de versión en la clave de caché), usada durante migraciones y despliegues problemáticos.
Detalles de estabilidad SEO
- Etiquetas canonical: siempre apuntan a la ruta jerárquica limpia (sin querystring).
- Robots: si existen parámetros de consulta no permitidos (q/category/sort/page/pageSize/r), establecer noindex para bloquear parámetros basura de enlaces externos.
- Paginación: rel=next/prev solo se genera para páginas > 1 y si resultCount > pageSize.
- Enlazado interno estable: la UI siempre emite rutas canónicas; la querystring solo se usa para filtros seleccionados por el usuario.
Extensiones futuras sin sobrecarga
Extendemos añadiendo providers y presenters, no introduciendo funcionalidades en el resolver. Reservas, reseñas y mapas cuelgan de páginas de entidad o APIs dedicadas. Discovery sigue siendo una superficie de listado y filtrado. Ese límite se hace cumplir en las revisiones de código.
- Reservas: endpoints /api/booking separados. Discovery solo muestra badges de disponibilidad si están cacheados y no son personales.
- Reseñas: servicio/provider de reseñas separado. Discovery lee campos de valoración agregados desde el GeoIndex (precalculados).
- Mapas: tiles y marcadores obtenidos desde /api/discovery/markers con caché agresivo; no incrustarlos en la respuesta HTML si empeoran el TTFB.
Un problema que salió mal (y lo que cambiamos)
Lanzamos la primera versión con una clave de caché del servidor que NO incluía tenantId. En local «funcionaba». En staging parecía correcto. Luego en producción: el portal A empezó a mostrar listados del portal B en páginas de ciudad. Misma ruta, tenant distinto. Colisión de caché. Feo.
La solución fue simple pero estricta: tenantId pasó a ser obligatorio en GeoQuery y la construcción de la clave de caché se movió al resolver para que no pudiera omitirse. También añadimos una aserción en tiempo de ejecución: si las entidades hidratadas contienen un tenantId diferente, lanzamos un error y omitimos la escritura en caché. Ruidoso a propósito.
Query Plan y contrato de providers
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>;
}
El QueryPlan es un simple conmutador basado en la configuración del tenant y el tipo de scope. Por ejemplo: algunos tenants usan solo SQL; otros usan un servicio de portal remoto. Misma entrada GeoQuery. Misma salida GeoResult. Esto hace que los despliegues sean predecibles.
GeoIndex SQL: esquema mínimo
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);
Compromisos (explícitos)
- Intercambiamos precisión perfecta por URLs estables: las páginas near canonizan por cellId. Dos usuarios a 300 m pueden aterrizar en la misma página canónica. Aceptable.
- Intercambiamos frescura por cacheabilidad: las páginas de discovery no son en tiempo real. Las actualizaciones del índice pueden retrasarse.
- Evitamos un refactor de base de datos ahora, pero pagamos una ruta de escritura adicional para mantener el GeoIndex.
- Evitamos acoplar el enrutamiento del CMS, pero ahora poseemos un namespace de rutas separado (/discover). Es intencional y es una promesa que debemos mantener.
- Refinamos la distancia en el código de la aplicación para búsquedas near para evitar cálculos costosos en la base de datos, trasladando CPU a la capa de aplicación, que es más fácil de escalar horizontalmente.
Plan de despliegue (práctico)
- Desplegar el resolver y páginas vacías que devuelvan 404 detrás de un feature flag. Confirmar que el enrutamiento no colisiona con el catch-all del CMS.
- Construir un job de backfill del GeoIndex para un tenant. Validar recuentos frente a los datos origen. Esperar discrepancias y registrarlas.
- Habilitar solo las páginas de país. Vigilar el ratio de aciertos de caché y las estadísticas de rastreo.
- Habilitar después las páginas de ciudad. Las páginas near al final (son las generadoras de variantes).
- Tras ver claves de caché estables, añadir consumidores de la API JSON (marcadores de mapa, filtrado en cliente).
Lista de aceptación
- Aislamiento multi-tenant: la misma ruta en dos hosts nunca comparte entradas de caché del servidor.
- Las URL canónicas no incluyen parámetros de consulta y permanecen estables ante cambios de filtros.
- Los conflictos de slug del CMS no afectan a las rutas /discover.
- Las páginas near no generan permutaciones de URL no acotadas (r y pageSize limitados).
- El contrato del provider devuelve una paginación determinista y totales coherentes.
- La reconstrucción del GeoIndex puede ejecutarse sin downtime y sin bloquear las tablas origen durante largos periodos.
