Back to KB

Increased blast radius when routing logic changes

Difficulty
Intermediate
Read Time
89 min

Decoupling Identity from Location: A Resolver-Driven Reference Pattern for Distributed Systems

By Codcompass TeamΒ·Β·89 min read

Decoupling Identity from Location: A Resolver-Driven Reference Pattern for Distributed Systems

Current Situation Analysis

In distributed architectures, cross-service data relationships are inevitable. When Service A needs to reference a resource managed by Service B, the most common implementation is storing an absolute URL or a path-based identifier directly in the database. This approach feels intuitive during initial development but creates severe operational debt as the system matures.

The fundamental flaw lies in conflating resource identity with resource location. A URL answers two distinct questions simultaneously: what the resource is, and where it currently resides. In stable monoliths, this coupling is manageable. In microservices, where hostnames, API versions, routing rules, and service boundaries shift frequently, storing location data as a persistent foreign key guarantees future breakage.

This problem is routinely underestimated because teams treat internal routing as static infrastructure. Engineering organizations frequently assume that once an API endpoint is published, it will remain stable for the lifetime of the data. Reality contradicts this assumption. Service migrations, domain-driven boundary adjustments, and API version rollouts occur regularly. When the underlying topology changes, every stored URL becomes a broken reference. Tracking down which records point to deprecated endpoints requires full database scans, and repairing them often forces coordinated downtime or complex data migration scripts.

Industry observations consistently show that URL-based cross-service references account for a disproportionate share of post-migration incidents. Teams that rely on direct endpoint storage typically face:

  • Cascading failures during host or path migrations
  • Inability to query historical data when API versions are deprecated
  • Tight coupling between data persistence layers and network topology
  • Increased blast radius when routing logic changes

The operational cost of maintaining location-dependent references grows non-linearly with system complexity. A pattern that separates logical identity from physical routing is not an optimization; it is a necessity for sustainable distributed systems.

WOW Moment: Key Findings

The following comparison illustrates why decoupling identity from location fundamentally changes how teams manage cross-service references. The metrics reflect real-world operational behavior observed in systems that transitioned from direct URL storage to a resolver-driven identifier pattern.

ApproachMigration ResilienceQuery-Time FlexibilityInfrastructure CouplingImplementation Overhead
Direct URL StorageLow (breaks on host/path changes)None (static snapshot)High (tied to network topology)Low initially, high long-term
Global ID Mapping ServiceMedium (requires external lookup)Limited (depends on mapping schema)Medium (centralized but rigid)High (requires dedicated service)
Resolver-Driven IdentifierHigh (routing abstracted)High (enrichable at query time)Low (logical only)Medium (gateway + resolvers)

This finding matters because it shifts the burden of routing from the data layer to the execution layer. Instead of embedding network topology into persistent records, teams store a stable logical handle. The gateway intercepts the handle, delegates to a domain-specific resolver, and returns the current representation. When infrastructure changes, only the resolver registry updates. Existing records remain valid without modification. This enables zero-downtime routing migrations, simplifies multi-version API support, and centralizes routing policy enforcement.

Core Solution

The resolver-driven identifier pattern operates on a simple premise: store a logical reference, resolve it at runtime through a centralized gateway, and delegate lookup logic to domain-specific handlers. The implementation requires four coordinated components: an identifier schema, a resolver registry, context-specific resolver implementations, and a gateway router.

Step 1: Define the Identifier Schema

The identifier must encode enough information for the gateway to route the request without embedding network details. A two-part structure separated by a forward 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 base

d 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.

```typescript
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.

4. Bypassing the Gateway for Performance

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

  • Define routing context taxonomy: Establish a clear naming convention for domains and subdomains. Document ownership and registration process.
  • Implement resolver interface: Create a standardized ResourceResolver contract with explicit error handling, timeout, and retry semantics.
  • Build gateway registry: Deploy a prefix-matching resolver registry with hot-reload capabilities for zero-downtime resolver updates.
  • Add observability: Instrument resolver hops with distributed tracing, metrics for resolution latency, and structured logging for fallback events.
  • Implement query-time enrichment: Support optional metadata injection without modifying stored identifiers. Validate enrichment schemas at the gateway.
  • Establish resolver versioning policy: Define deprecation timelines, backward compatibility requirements, and consumer migration paths for each resolver.
  • Configure fallback chains: Ensure every resolver implements explicit fallback strategies with circuit breakers and degradation paths.
  • Create migration tooling: Build scripts to scan legacy URL references, convert them to resolver identifiers, and validate resolution before cutover.

Decision Matrix

ScenarioRecommended ApproachWhyCost Impact
High infrastructure churn (frequent host/path changes)Resolver-Driven IdentifierAbstracts location changes; zero-downtime routing updatesMedium upfront, low long-term
Strict compliance requiring immutable audit trailsGlobal ID Mapping ServiceCentralized, auditable reference table with version historyHigh (dedicated service + storage)
Low-latency real-time streamingDirect URL/Endpoint StorageEliminates resolution hop; predictable network pathLow initially, high risk during migrations
Multi-tenant SaaS with regional isolationResolver-Driven Identifier + Context RoutingRoutes to tenant-specific resolvers without data duplicationMedium (resolver registry + regional endpoints)
Legacy monolith modernizationResolver-Driven Identifier (phased)Allows gradual migration of references without breaking existing consumersMedium (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

  1. 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.
  2. Deploy the gateway router: Wrap the registry in a ResolutionGateway class. Expose a single HTTP/gRPC endpoint that accepts identifiers and optional enrichment metadata.
  3. 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.
  4. 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.
  5. 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.