[EN] Delegated Resource Identifier (DRI): a pattern for persistent references in microservices
Decoupling Identity from Location: The Delegated Resource Identifier Pattern for Microservices
Current Situation Analysis
In distributed architectures, services frequently need to maintain references to resources owned by other domains. The most common implementation is storing the full endpoint URL in the database. This approach appears efficient during initial development but introduces severe brittleness as the system evolves.
The fundamental flaw lies in the conflation of identity and location. A URL encodes both what the resource is and where it currently resides. When a team refactors API paths, migrates to a new host, introduces versioning, or splits a monolith into microservices, every stored URL becomes a liability. The reference breaks not because the resource changed, but because the path to it changed.
This problem is often overlooked because URL storage works perfectly in stable environments. However, in systems undergoing continuous delivery, location volatility is the norm. Tracking down broken references requires scanning databases for string patterns, updating records, and coordinating across teams, creating operational drag that scales with system complexity.
Data from production incidents consistently shows that cross-service reference failures are a leading cause of cascading errors during infrastructure migrations. When references are tightly coupled to location, a simple hostname change can trigger widespread service degradation, forcing teams to prioritize reference remediation over feature delivery.
WOW Moment: Key Findings
The Delegated Resource Identifier (DRI) pattern resolves this by inverting the resolution responsibility. Instead of the consumer knowing the location, the consumer stores a stable identifier, and a gateway determines the location at runtime.
| Approach | Persistence Stability | Coupling Level | Resolution Responsibility | Evolution Cost |
|---|---|---|---|---|
| Direct URL Storage | Low (Breaks on path/host change) | High (Consumer knows structure) | Consumer / Database | High (Update all stored refs) |
| DRI Pattern | High (Stable across moves) | Low (Consumer knows context only) | Gateway / Resolver | Low (Update resolver config only) |
This finding matters because it transforms cross-service references from static liabilities into dynamic assets. The DRI pattern enables infrastructure changes, API versioning, and service migrations without touching consumer databases. It centralizes routing logic, allowing teams to evolve their endpoints independently while maintaining referential integrity across the ecosystem.
Core Solution
The DRI pattern relies on a structured identifier format that separates routing context from resource specifics. The identifier follows the syntax <context>/<resource>, where the context determines which resolver handles the request, and the resource provides the data needed to locate the specific item.
Identifier Structure
The context portion (left of the slash) acts as a routing key. It identifies the domain responsible for resolution. The resource portion (right of the slash) is opaque to the gateway and defined by the resolver implementation.
Hierarchical levels within the context are separated by colons (:), and key-value assignments use equals signs (=). This syntax allows for flexible, extensible identifiers without imposing a rigid taxonomy.
Example DRI:
billing:transactions/order:id=TXN-9928471
- Context:
billing:transactions - Resource:
order:id=TXN-9928471
The consumer constructs the DRI using data already available. No external API call is required to generate the identifier. A service receiving an order ID knows the context and builds the DRI immediately.
TypeScript Implementation
The following implementation demonstrates the gateway architecture, resolver interface, and enrichment capabilities.
// Core types
interface ResourceIdentifier {
context: string;
resource: string;
metadata?: Record<string, string>;
}
interface ResolutionContext {
[key: string]: string;
}
interface ResolverResponse<T> {
data: T;
source: string;
resolvedAt: Date;
}
// Resolver contract
interface ResourceResolver<T> {
resolve(identifier: ResourceIdentifier, context?: ResolutionContext): Promise<ResolverResponse<T>>;
supportsContext(context: string): boolean;
}
// Gateway implementation
class DelegatedGateway {
private resolvers: Map<string, ResourceResolver<unknown>> = new Map();
private defaultResolver?: ResourceResolver<unknown>;
register<T>(context: string, resolver: ResourceResolver<T>): void {
this.resolvers.set(context, resolver as ResourceResolver<unknown>);
}
setDefaultResolver<T>(resolver: ResourceResolver<T>): void {
this.defaultResolver = resolver as ResourceResolver<unknown>;
}
async resolve<T>(dri: string, context?: ResolutionContext): Promise<ResolverResponse<T>> {
const parsed = this.parseDRI(dri);
const resolver = this.resolvers.get(parsed.context) || this.defaultResolver;
if (!resolver) {
throw new Error(`No resolver registered for context: ${parsed.context}`);
}
return resolver.resolve(parsed, context) as Promise<ResolverResponse<
T>>; }
private parseDRI(dri: string): ResourceIdentifier { const [context, resource] = dri.split('/'); return { context, resource }; } }
// Concrete resolver example class TransactionResolver implements ResourceResolver<Transaction> { supportsContext(context: string): boolean { return context === 'billing:transactions'; }
async resolve( identifier: ResourceIdentifier, context?: ResolutionContext ): Promise<ResolverResponse<Transaction>> { // Extract transaction ID from resource string const idMatch = identifier.resource.match(/id=([^&]+)/); if (!idMatch) { throw new Error('Invalid resource format: missing id'); }
const transactionId = idMatch[1];
// Resolution logic: determine actual endpoint based on context
// Example: Use date from context to select API version
const apiVersion = context?.date
? this.selectVersionByDate(context.date)
: 'v2';
const response = await fetch(
`https://api.billing.internal/${apiVersion}/transactions/${transactionId}`
);
return {
data: await response.json(),
source: `billing-internal-${apiVersion}`,
resolvedAt: new Date()
};
}
private selectVersionByDate(dateStr: string): string { const date = new Date(dateStr); return date < new Date('2024-01-01') ? 'v1' : 'v2'; } }
// Usage const gateway = new DelegatedGateway(); gateway.register('billing:transactions', new TransactionResolver());
// Basic resolution const result = await gateway.resolve<Transaction>( 'billing:transactions/order:id=TXN-9928471' );
// Resolution with enrichment const enrichedResult = await gateway.resolve<Transaction>( 'billing:transactions/order:id=TXN-9928471', { date: '2023-06-15' } );
#### Architecture Decisions
1. **Gateway as Resolution Hub:** The gateway centralizes routing logic. This eliminates duplication across consumers and provides a single point for monitoring, caching, and error handling.
2. **Resolver Interface:** Resolvers implement a strict contract. This allows independent development of resolvers and enables hot-swapping implementations without affecting the gateway.
3. **Context Enrichment:** The pattern supports adding metadata at query time. The persisted DRI remains stable, but consumers can pass additional context (like dates or preferences) to influence resolution. This enables version selection and fallback strategies without modifying stored data.
4. **Default Resolver:** The gateway supports a default resolver for contexts that don't require explicit routing. This simplifies identifiers when a single domain handles most requests.
5. **Opaque Resource Strings:** The gateway treats the resource portion as an opaque string. This allows each domain to define its own resource syntax without coordinating with the gateway team.
### Pitfall Guide
#### 1. The "URL in Disguise" Trap
**Explanation:** Developers embed path segments or version numbers in the resource portion, recreating URL brittleness.
**Fix:** Keep the resource portion abstract. Use identifiers and key-value pairs that remain stable regardless of API structure. Let the resolver handle path construction.
#### 2. Gateway as Single Point of Failure
**Explanation:** Centralizing resolution creates a dependency bottleneck. If the gateway fails, all cross-service references break.
**Fix:** Implement circuit breakers, caching layers, and health checks in the gateway. Consider deploying the gateway as a sidecar or embedded library for critical paths.
#### 3. Resolver Coupling to Consumer Logic
**Explanation:** Resolvers become aware of consumer-specific requirements, violating separation of concerns.
**Fix:** Resolvers should only know how to locate resources. Consumer preferences should be passed as metadata, not hardcoded into resolver logic.
#### 4. Ignoring Resolver Versioning
**Explanation:** Resolver implementations change over time, potentially breaking existing resolution behavior.
**Fix:** Version resolvers independently. Use semantic versioning for resolver contracts and maintain backward compatibility during transitions.
#### 5. Client-Side DRI Parsing
**Explanation:** Consumers attempt to parse or manipulate DRI strings, creating hidden dependencies.
**Fix:** Treat DRIs as opaque blobs to consumers. Provide SDKs that generate DRIs from domain objects without exposing the syntax.
#### 6. Missing Fallback Strategies
**Explanation:** Resolvers fail silently when resources move or become unavailable.
**Fix:** Implement fallback chains in resolvers. Try primary sources first, then secondary sources, and finally return structured errors with retry hints.
#### 7. Over-Normalizing Context
**Explanation:** Creating deeply nested contexts with excessive colons makes routing complex and error-prone.
**Fix:** Limit context depth to two or three levels. Use metadata for additional granularity rather than expanding the context hierarchy.
### Production Bundle
#### Action Checklist
- [ ] Define context taxonomy: Establish naming conventions for contexts across teams.
- [ ] Implement resolvers: Build resolvers for each domain with clear contracts.
- [ ] Deploy gateway: Set up the gateway with resolver registration and monitoring.
- [ ] Migrate references: Replace stored URLs with DRIs using a background migration job.
- [ ] Add enrichment support: Enable query-time context passing for versioning and preferences.
- [ ] Configure fallbacks: Implement error handling and retry logic in resolvers.
- [ ] Monitor resolution: Track latency, error rates, and resolver performance.
- [ ] Document syntax: Publish DRI format guidelines and examples for consumers.
#### Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|----------|---------------------|-----|-------------|
| **Stable monolith with fixed endpoints** | Direct URL Storage | Simplicity outweighs flexibility benefits | Low |
| **Microservices with frequent migrations** | DRI Pattern | Decouples identity from volatile locations | Medium (Gateway overhead) |
| **Multi-tenant SaaS with regional endpoints** | DRI with Context Enrichment | Enables dynamic routing based on tenant metadata | Medium-High |
| **Legacy system integration** | DRI with Adapter Resolver | Wraps legacy APIs behind stable identifiers | Low-Medium |
| **High-throughput read-heavy workload** | DRI with Gateway Caching | Reduces resolver load while maintaining flexibility | Medium |
#### Configuration Template
```typescript
// gateway.config.ts
import { DelegatedGateway } from './gateway';
import { TransactionResolver } from './resolvers/transaction';
import { UserProfileResolver } from './resolvers/user-profile';
import { InventoryResolver } from './resolvers/inventory';
export function configureGateway(): DelegatedGateway {
const gateway = new DelegatedGateway();
// Register domain resolvers
gateway.register('billing:transactions', new TransactionResolver());
gateway.register('identity:profiles', new UserProfileResolver());
gateway.register('warehouse:inventory', new InventoryResolver());
// Configure default resolver for unregistered contexts
gateway.setDefaultResolver(new InventoryResolver());
// Optional: Configure resolver preferences
gateway.setResolverOptions({
timeout: 5000,
retries: 2,
cacheTTL: 300 // seconds
});
return gateway;
}
Quick Start Guide
- Define your DRI: Construct an identifier using
<context>/<resource>syntax. Example:billing:transactions/order:id=TXN-123. - Create a Resolver: Implement the
ResourceResolverinterface for your domain. Handle resource extraction and endpoint resolution. - Register with Gateway: Add your resolver to the gateway using the context key.
- Resolve References: Call
gateway.resolve(dri)to fetch resources. Pass enrichment context as needed. - Store DRIs: Replace URL storage with DRI strings in your database. References remain stable across infrastructure changes.
