slash provides clear separation of concerns:
<routing-context>/<resource-locator>
The left side (routing-context) identifies the domain responsible for resolving the reference. The right side (resource-locator) contains the actual resource identifier, formatted according to the domain's internal conventions. Colons can denote hierarchical subdomains, and key-value pairs can specify exact resource attributes.
Example structure:
platform:inventory/sku:id=INV-9928471
billing:invoices/invoice:id=INV-2024-008831
This schema is intentionally flexible. The gateway only parses the routing context. The resource locator remains opaque to the router and is passed verbatim to the appropriate resolver.
Step 2: Build the Resolver Registry
The gateway maintains a registry that maps routing contexts to resolver implementations. Each resolver adheres to a standardized interface, ensuring the gateway can invoke them uniformly regardless of domain.
interface ResourceResolver<T = unknown> {
resolve(identifier: string, options?: ResolverOptions): Promise<T>;
supports(context: string): boolean;
}
interface ResolverOptions {
version?: string;
fallback?: boolean;
metadata?: Record<string, string>;
}
The registry uses a prefix-matching strategy to handle hierarchical contexts. When a request arrives, the gateway extracts the routing context, matches it against registered resolvers, and delegates execution.
class ResolverRegistry {
private handlers: Map<string, ResourceResolver> = new Map();
register(context: string, resolver: ResourceResolver): void {
this.handlers.set(context, resolver);
}
async resolve(identifier: string, options?: ResolverOptions): Promise<unknown> {
const [context, resource] = identifier.split('/');
const resolver = this.handlers.get(context);
if (!resolver) {
throw new Error(`No resolver registered for context: ${context}`);
}
return resolver.resolve(resource, options);
}
}
Step 3: Implement Domain-Specific Resolvers
Resolvers encapsulate all domain-specific lookup logic. They handle version selection, fallback strategies, legacy system bridging, and data transformation. The gateway remains completely unaware of how resources are fetched.
class InventoryResolver implements ResourceResolver {
async resolve(resource: string, options?: ResolverOptions): Promise<unknown> {
const params = new URLSearchParams(resource.split(':')[1] || '');
const skuId = params.get('id');
if (!skuId) {
throw new Error('Missing SKU identifier');
}
// Determine target system based on ID prefix or options
const targetSystem = this.selectTargetSystem(skuId, options?.version);
return this.fetchFromSystem(targetSystem, skuId);
}
private selectTargetSystem(skuId: string, version?: string): string {
if (version === 'v2' || skuId.startsWith('NEW-')) {
return 'inventory-modern';
}
return 'inventory-legacy';
}
private async fetchFromSystem(system: string, skuId: string): Promise<unknown> {
// HTTP/gRPC call to actual service
// Includes circuit breaker, retry, and timeout logic
return { system, skuId, resolvedAt: new Date().toISOString() };
}
supports(context: string): boolean {
return context === 'platform:inventory';
}
}
Step 4: Integrate with the Gateway Router
The gateway acts as the single entry point for all identifier resolution requests. It parses incoming identifiers, validates the routing context, applies query-time enrichment, and delegates to the registry.
class ResolutionGateway {
constructor(private registry: ResolverRegistry) {}
async handleRequest(identifier: string, preferences?: Record<string, string>): Promise<unknown> {
// Parse optional query-time enrichment
const [baseIdentifier, enrichment] = identifier.split(':');
const options: ResolverOptions = {
metadata: enrichment ? Object.fromEntries(enrichment.split(',').map(p => p.split('='))) : {}
};
if (preferences) {
options.metadata = { ...options.metadata, ...preferences };
}
return this.registry.resolve(baseIdentifier, options);
}
}
Architecture Decisions and Rationale
Why a gateway instead of service mesh? Service meshes operate at the network layer, managing TCP/HTTP routing, mTLS, and load balancing. They lack application-level context about resource identity, versioning strategies, or domain-specific fallback logic. The resolver gateway operates at the application layer, where business rules dictate how references should be resolved.
Why prefix-based context matching? Hierarchical contexts (platform:inventory, platform:billing) allow teams to register broad resolvers for entire domains while still supporting granular overrides. The gateway matches the longest prefix first, enabling fallback to domain-level handlers when specific subdomain resolvers are unavailable.
Why keep the resource locator opaque? The gateway should never parse or validate resource identifiers. Doing so couples the router to domain-specific ID formats. By treating the right side of the slash as an opaque string, teams can evolve ID schemas independently without touching routing infrastructure.
Why support query-time enrichment? Persistent identifiers should remain stable, but lookup requirements change. Allowing consumers to append contextual parameters at query time (e.g., :version=v2,region=eu) enables dynamic resolution without modifying stored data. This pattern mirrors HTTP content negotiation and keeps the data layer clean.
Pitfall Guide
1. Treating the Identifier as a Global Standard
Explanation: Teams often attempt to enforce a universal identifier schema across all domains. This creates friction when domains have legitimate legacy ID formats or regulatory constraints.
Fix: Treat the identifier as a local convention. The gateway only requires the routing context. Allow each domain to define its own resource locator syntax. Document the schema per domain, not globally.
2. Embedding Routing Logic Inside Resolvers
Explanation: Resolvers that contain network routing decisions, load balancing logic, or service discovery code violate separation of concerns. They become tightly coupled to infrastructure and difficult to test.
Fix: Resolvers should only contain domain lookup logic. Network routing, retries, circuit breaking, and service discovery belong in the gateway or a dedicated client library. Resolvers call abstracted service clients, not raw endpoints.
3. Ignoring Resolver Versioning
Explanation: As APIs evolve, resolvers must support multiple versions. Failing to version resolvers leads to stale lookups or breaking changes for consumers expecting older behavior.
Fix: Implement resolver versioning through explicit interfaces or registry keys. Example: platform:inventory:v1, platform:inventory:v2. Allow consumers to request specific versions via query-time enrichment. Deprecate old resolvers with clear migration windows.
Explanation: Under load, teams sometimes cache resolved URLs or call services directly to avoid gateway latency. This breaks the pattern and reintroduces location coupling.
Fix: Optimize the gateway, not around it. Implement resolver-level caching, connection pooling, and async resolution. Use distributed tracing to identify bottlenecks. If direct access is unavoidable, document it as an exception with explicit SLAs and monitoring.
5. Overcomplicating the Query Syntax
Explanation: Teams often add custom delimiters, nested structures, or domain-specific parsing rules to the identifier. This increases parsing complexity and breaks gateway uniformity.
Fix: Keep the identifier simple. Use standard key-value pairs for enrichment. If complex preferences are needed, pass them through standard HTTP headers or request metadata, not the identifier string. The gateway should parse only the routing context; everything else is forwarded verbatim.
6. Missing Fallback Strategies
Explanation: Resolvers that fail fast without fallbacks cause cascading errors when target systems are temporarily unavailable or data is missing.
Fix: Implement explicit fallback chains within resolvers. Example: query modern system β fallback to legacy system β return cached representation β return structured error. Log fallback triggers for observability. Never swallow errors silently.
7. Storing Resolver State in the Identifier
Explanation: Some teams embed transient state (e.g., session tokens, temporary flags) into the identifier. This violates the principle of stable references and causes resolution failures when state expires.
Fix: Identifiers must be stateless and persistent. Transient data belongs in request headers, query parameters, or session stores. If a resolver requires temporary context, pass it through the options object, not the identifier string.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High infrastructure churn (frequent host/path changes) | Resolver-Driven Identifier | Abstracts location changes; zero-downtime routing updates | Medium upfront, low long-term |
| Strict compliance requiring immutable audit trails | Global ID Mapping Service | Centralized, auditable reference table with version history | High (dedicated service + storage) |
| Low-latency real-time streaming | Direct URL/Endpoint Storage | Eliminates resolution hop; predictable network path | Low initially, high risk during migrations |
| Multi-tenant SaaS with regional isolation | Resolver-Driven Identifier + Context Routing | Routes to tenant-specific resolvers without data duplication | Medium (resolver registry + regional endpoints) |
| Legacy monolith modernization | Resolver-Driven Identifier (phased) | Allows gradual migration of references without breaking existing consumers | Medium (parallel run + validation) |
Configuration Template
gateway:
resolver_registry:
hot_reload: true
match_strategy: longest_prefix
timeout_ms: 2000
retry_policy:
max_attempts: 3
backoff_ms: 100
jitter: true
contexts:
platform:inventory:
resolver_class: InventoryResolver
versioning:
enabled: true
default: v2
deprecated: [v1]
fallback:
enabled: true
chain: [modern_system, legacy_system, cache]
observability:
tracing: true
metrics: [resolution_latency, fallback_count, error_rate]
billing:invoices:
resolver_class: InvoiceResolver
versioning:
enabled: true
default: v3
fallback:
enabled: false
observability:
tracing: true
metrics: [resolution_latency, error_rate]
enrichment:
allowed_keys: [version, region, format, locale]
max_length: 256
validation: strict
Quick Start Guide
- Initialize the resolver registry: Create a
ResolverRegistry instance and register your first domain context with a concrete resolver implementation. Ensure the resolver adheres to the standardized interface.
- Deploy the gateway router: Wrap the registry in a
ResolutionGateway class. Expose a single HTTP/gRPC endpoint that accepts identifiers and optional enrichment metadata.
- Register resolvers: Implement domain-specific resolvers for each routing context. Focus on lookup logic, version selection, and fallback chains. Avoid network routing code inside resolvers.
- Test resolution paths: Use integration tests to verify prefix matching, fallback behavior, and query-time enrichment. Validate that stored identifiers resolve correctly across version changes.
- Migrate legacy references: Run a background job to scan existing URL-based foreign keys. Convert them to resolver identifiers using a mapping script. Validate resolution in a staging environment before production cutover.