ADR-0037: Cache Key Naming Convention
Status
Accepted - 2025-01-26
Context
Consistent cache key naming prevents collisions, enables bulk invalidation, and simplifies debugging.
Decision
Hierarchical key convention with namespace, resource, and identifier segments.
Rationale
- Collision Prevention: Unique keys per tenant + resource
- Bulk Invalidation: Pattern-based deletion (org:123:properties:*)
- Debugging: Human-readable keys
- Consistency: Enforced via TypeScript functions
Key Convention
Format
{namespace}:{tenant_id}:{resource}:{id}:{sub_resource}
Examples:
org:abc-123:properties                          # List all properties
org:abc-123:property:prop-456                   # Single property
org:abc-123:availability:prop-456:2025-02       # Availability for property (monthly)
org:abc-123:pricing:prop-456                    # Pricing for property
session:sess-789                                # User session
ratelimit:org-abc-123:/api/v1/bookings         # Rate limit counter
Alternatives Considered
Alternative 1: Flat Keys
Rejected - High collision risk, no namespace isolation
property:123  # Ambiguous - which org?
Alternative 2: JSON-Encoded Keys
Rejected - Hard to read, harder to debug
{"org":"abc","resource":"property","id":"123"}
Alternative 3: UUID-Only Keys
Rejected - Not human-readable, can't pattern-match
550e8400-e29b-41d4-a716-446655440000
Implementation
1. Key Builder Functions
// src/cache/keys.ts
export const CacheKeys = {
  // Properties
  properties: (orgId: string) =>
    `org:${orgId}:properties`,
  property: (orgId: string, propertyId: string) =>
    `org:${orgId}:property:${propertyId}`,
  propertyWithVersion: (orgId: string, propertyId: string, version: number) =>
    `org:${orgId}:property:${propertyId}:v${version}`,
  // Availability
  availability: (orgId: string, propertyId: string, yearMonth: string) =>
    `org:${orgId}:availability:${propertyId}:${yearMonth}`,
  availabilityDay: (orgId: string, propertyId: string, date: string) =>
    `org:${orgId}:availability:${propertyId}:${date}`,
  // Bookings
  bookings: (orgId: string) =>
    `org:${orgId}:bookings`,
  booking: (orgId: string, bookingId: string) =>
    `org:${orgId}:booking:${bookingId}`,
  bookingsByProperty: (orgId: string, propertyId: string) =>
    `org:${orgId}:bookings:property:${propertyId}`,
  // Pricing
  pricing: (orgId: string, propertyId: string) =>
    `org:${orgId}:pricing:${propertyId}`,
  pricingByDate: (orgId: string, propertyId: string, date: string) =>
    `org:${orgId}:pricing:${propertyId}:${date}`,
  // Users & Sessions
  user: (orgId: string, userId: string) =>
    `org:${orgId}:user:${userId}`,
  session: (sessionId: string) =>
    `session:${sessionId}`,
  // API Responses (Channel Integrations)
  apiResponse: (channel: string, endpoint: string, params: string) =>
    `api:${channel}:${endpoint}:${params}`,
  // Rate Limiting
  rateLimit: (orgId: string, endpoint: string, window: string) =>
    `ratelimit:${orgId}:${endpoint}:${window}`,
  // Idempotency
  idempotency: (idempotencyKey: string) =>
    `idempotency:${idempotencyKey}`,
};
2. Dynamic Key Generation
// src/cache/keyBuilder.ts
export class CacheKeyBuilder {
  constructor(private namespace: string, private orgId?: string) {}
  resource(name: string, id?: string): string {
    const parts = [this.namespace];
    if (this.orgId) {
      parts.push(this.orgId);
    }
    parts.push(name);
    if (id) {
      parts.push(id);
    }
    return parts.join(':');
  }
  withSubResource(base: string, subResource: string, id?: string): string {
    const parts = [base, subResource];
    if (id) parts.push(id);
    return parts.join(':');
  }
}
// Usage
const builder = new CacheKeyBuilder('org', orgId);
const key = builder.resource('property', propertyId);
// Result: org:abc-123:property:prop-456
Namespace Rules
Namespace Prefixes
| Prefix | Purpose | Example | 
|---|---|---|
| org: | Multi-tenant data | org:abc-123:properties | 
| session: | User sessions | session:sess-789 | 
| ratelimit: | Rate limiting | ratelimit:org-abc:endpoint | 
| api: | External API responses | api:hostaway:listings:page1 | 
| idempotency: | Idempotency keys | idempotency:req-abc-123 | 
| lock: | Distributed locks | lock:booking:prop-456 | 
| queue: | Job queue metadata | queue:webhook:job-123 | 
Bulk Invalidation Patterns
Invalidate All Properties for Org
export async function invalidateAllProperties(orgId: string) {
  const pattern = `org:${orgId}:property:*`;
  const keys = await redis.keys(pattern);
  if (keys.length > 0) {
    await redis.del(...keys);
  }
  // Also invalidate list
  await redis.del(CacheKeys.properties(orgId));
}
Invalidate Availability for Property
export async function invalidateAvailability(orgId: string, propertyId: string) {
  const pattern = `org:${orgId}:availability:${propertyId}:*`;
  const keys = await redis.keys(pattern);
  if (keys.length > 0) {
    await redis.del(...keys);
  }
}
Invalidate All Caches for Org (Dangerous!)
export async function invalidateOrg(orgId: string) {
  const pattern = `org:${orgId}:*`;
  const keys = await redis.keys(pattern);
  if (keys.length > 0) {
    await redis.del(...keys);
  }
  logger.warn({ orgId, keysDeleted: keys.length }, 'Invalidated all org caches');
}
⚠️ Warning: KEYS command is slow on large datasets. Use SCAN in production:
export async function invalidateOrgSafe(orgId: string) {
  const pattern = `org:${orgId}:*`;
  let cursor = '0';
  let deletedCount = 0;
  do {
    const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
    cursor = nextCursor;
    if (keys.length > 0) {
      await redis.del(...keys);
      deletedCount += keys.length;
    }
  } while (cursor !== '0');
  logger.info({ orgId, deletedCount }, 'Invalidated org caches');
}
Special Characters Handling
Escape Special Characters
export function escapeKey(value: string): string {
  // Replace special chars that could cause issues
  return value.replace(/[:\s]/g, '_');
}
// Usage
const propertyName = "Villa:Sunset Beach";
const key = `org:${orgId}:property:${escapeKey(propertyName)}`;
// Result: org:abc-123:property:Villa_Sunset_Beach
Key Length Limits
Redis key max length: 512MB (practically unlimited)
Best practice: Keep keys under 100 characters for readability.
// BAD: Too long
const badKey = `org:abc-123:property:prop-456:availability:2025-01-26:unit:unit-789:pricing:rule:rule-123`;
// GOOD: Hierarchical, concise
const goodKey = `org:abc-123:availability:prop-456:2025-01-26`;
Debugging
List All Keys for Org
# Redis CLI
redis-cli KEYS "org:abc-123:*"
# Or programmatically
const keys = await redis.keys('org:abc-123:*');
console.log(keys);
Inspect Key Value
redis-cli GET "org:abc-123:property:prop-456"
Count Keys by Pattern
export async function countKeys(pattern: string): Promise<number> {
  const keys = await redis.keys(pattern);
  return keys.length;
}
// Usage
const propCount = await countKeys('org:abc-123:property:*');
console.log(`Cached properties: ${propCount}`);
Consequences
Positive
- ✅ Collision-Free: Unique keys per tenant + resource
- ✅ Bulk Invalidation: Pattern-based deletion
- ✅ Human-Readable: Easy to debug
- ✅ Consistent: Enforced via TypeScript
Negative
- ❌ Longer Keys: More memory per key
- ❌ KEYS Command: Slow on large datasets
Mitigations
- Keys are small (avg 50 bytes), memory impact negligible
- Use SCANinstead ofKEYSin production
Validation Checklist
-  All cache keys use CacheKeysfunctions
-  Multi-tenant keys include org:${orgId}prefix
-  Bulk invalidation uses SCAN(notKEYS)
- Special characters escaped in dynamic keys
- Keys under 100 characters
- Debugging utilities available