barsa b206de8dba refactor: enterprise-grade cleanup of BFF and domain packages
Comprehensive refactoring across 70 files (net -298 lines) improving
type safety, error handling, and code organization:

- Replace .passthrough()/.catchall(z.unknown()) with .strip() in all Zod schemas
- Tighten Record<string, unknown> to bounded union types where possible
- Replace throw new Error with domain-specific exceptions (OrderException,
  FulfillmentException, WhmcsOperationException, SalesforceOperationException, etc.)
- Split AuthTokenService (625 lines) into TokenGeneratorService and
  TokenRefreshService with thin orchestrator
- Deduplicate FreebitClientService with shared makeRequest() method
- Add typed interfaces to WHMCS facade, order service, and fulfillment mapper
- Externalize hardcoded config values to ConfigService with env fallbacks
- Consolidate duplicate billing cycle enums into shared billingCycleSchema
- Standardize logger usage (nestjs-pino @Inject(Logger) everywhere)
- Move shared WHMCS number coercion helpers to whmcs-utils/schema.ts
2026-02-24 19:05:30 +09:00
..

Cache Infrastructure

Redis-backed caching system with CDC (Change Data Capture) event-driven invalidation.

Architecture

Core Components

┌─────────────────────────────────────────────────────────────┐
│                     Application Layer                        │
│  (Orders, Catalog, Invoices, etc. - Domain Logic)           │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────▼─────────────────────────────────┐
│              Domain-Specific Cache Services                  │
│  - OrdersCacheService (CDC-driven)                          │
│  - ServicesCacheService (CDC-driven + safety TTL)           │
│  - WhmcsCacheService (TTL-based)                            │
│                                                              │
│  Features:                                                   │
│  • Request coalescing (prevents thundering herd)            │
│  • Metrics tracking (hits/misses/invalidations)             │
│  • Dependency tracking (granular invalidation)              │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────▼─────────────────────────────────┐
│                     CacheService                             │
│         (Low-level Redis operations)                         │
│                                                              │
│  • get/set with JSON serialization                          │
│  • Pattern-based operations (scan/delete/count)             │
│  • Memory usage tracking                                     │
│  • Automatic error handling                                  │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────▼─────────────────────────────────┐
│                     Redis (ioredis)                          │
│         In-memory data store                                 │
└─────────────────────────────────────────────────────────────┘

CDC Event-Driven Invalidation

┌─────────────────┐         ┌──────────────────┐
│   Salesforce    │ CDC     │  CDC Subscriber  │
│   (Data Source) │───────▶ │  (order-cdc,     │
│                 │ Events  │   catalog-cdc)   │
└─────────────────┘         └─────────┬────────┘
                                      │
                                      │ Invalidate
                                      ▼
                            ┌──────────────────┐
                            │  Cache Service   │
                            │  (Redis)         │
                            └──────────────────┘

Cache Strategies

1. CDC-Driven (Orders, Catalog)

Event-driven invalidation + safety TTL - Cache is invalidated on CDC events, and also expires after a long TTL as a safety net.

Why: CDC is the primary freshness mechanism, but a safety TTL helps self-heal if events are missed (deploy downtime, subscriber issues, replay gaps).

Config:

  • SERVICES_CACHE_SAFETY_TTL_SECONDS (default: 12 hours, set to 0 to disable)

Pros:

  • Real-time invalidation when data changes
  • Zero stale data for customer-visible fields
  • Optimal for frequently read, infrequently changed data

Example:

@Injectable()
export class OrdersCacheService {
  // CDC invalidation + safety TTL (service-specific)
  async getOrderSummaries(
    sfAccountId: string,
    fetcher: () => Promise<OrderSummary[]>
  ): Promise<OrderSummary[]> {
    const key = this.buildAccountKey(sfAccountId);
    return this.getOrSet("summaries", key, fetcher, false);
  }
}

2. TTL-Based (WHMCS, External APIs)

Fixed TTL - Cache expires after a set duration.

Pros:

  • Simple, predictable behavior
  • Good for external systems without CDC
  • Automatic cleanup of stale data

Example:

@Injectable()
export class WhmcsCacheService {
  private readonly INVOICES_TTL = 90; // 90 seconds

  async getInvoicesList(...): Promise<InvoiceList | null> {
    return this.cacheService.getOrSet(key, fetcher, this.INVOICES_TTL);
  }
}

3. Hybrid (Volatile Data)

Short TTL + CDC - Combines both approaches.

Use case: Frequently changing data that also needs CDC invalidation.

private readonly VOLATILE_TTL = 60; // 1 minute

Key Features

Request Coalescing

Prevents "thundering herd" problem when cache is invalidated and multiple requests arrive simultaneously.

// When 100 users request data after CDC invalidation:
// ❌ Without coalescing: 100 API calls
// ✅ With coalescing: 1 API call, shared by all 100 users

private readonly inflightRequests = new Map<string, Promise<unknown>>();

// Check if another request is already fetching this data
const existingRequest = this.inflightRequests.get(key);
if (existingRequest) {
  return existingRequest; // Wait for and share the existing request
}

Dependency Tracking (Catalog Only)

Enables granular invalidation by tracking relationships between cached data and source entities.

// When Product A changes:
// ✅ Invalidate only catalog entries that depend on Product A
// ❌ Don't invalidate unrelated Product B entries

await catalogCache.invalidateProducts(["Product_A_ID"]);

Metrics Tracking

All cache services track performance metrics:

{
  services: { hits: 1250, misses: 48 },
  static: { hits: 890, misses: 12 },
  volatile: { hits: 450, misses: 120 },
  invalidations: 15
}

Access via health endpoints:

  • GET /api/health/services/cache
  • GET /health

Creating a New Cache Service

Step 1: Import Shared Types

import { CacheService } from "@bff/infra/cache/cache.service";
import type { CacheBucketMetrics } from "@bff/infra/cache/cache.types";

Step 2: Define Service Structure

@Injectable()
export class MyDomainCacheService {
  private readonly metrics = {
    data: { hits: 0, misses: 0 },
    invalidations: 0,
  };

  // Request coalescing map
  private readonly inflightRequests = new Map<string, Promise<unknown>>();

  constructor(private readonly cache: CacheService) {}
}

Step 3: Implement Get/Set Pattern

async getMyData(id: string, fetcher: () => Promise<MyData>): Promise<MyData> {
  const key = `mydomain:${id}`;

  // Check cache
  const cached = await this.cache.get<MyData>(key);
  if (cached) {
    this.metrics.data.hits++;
    return cached;
  }

  // Check for in-flight request
  const existing = this.inflightRequests.get(key);
  if (existing) return existing as Promise<MyData>;

  // Fetch fresh data
  this.metrics.data.misses++;
  const fetchPromise = (async () => {
    try {
      const fresh = await fetcher();
      await this.cache.set(key, fresh); // CDC-driven (TTL varies by domain)
      return fresh;
    } finally {
      this.inflightRequests.delete(key);
    }
  })();

  this.inflightRequests.set(key, fetchPromise);
  return fetchPromise;
}

Step 4: Implement Invalidation

async invalidateMyData(id: string): Promise<void> {
  this.metrics.invalidations++;
  await this.cache.del(`mydomain:${id}`);
}

Step 5: Wire Up CDC Subscriber (if needed)

@Injectable()
export class MyDomainCdcSubscriber implements OnModuleInit {
  constructor(private readonly myCache: MyDomainCacheService) {}

  private async handleEvent(data: unknown): Promise<void> {
    const id = extractId(data);
    await this.myCache.invalidateMyData(id);
  }
}

Cache Key Conventions

Use colon-separated hierarchical keys:

domain:type:identifier[:subkey]

Examples:

  • orders:account:001xx000003EgI1AAK
  • orders:detail:80122000000D4UGAA0
  • services:internet:acc_001:jp
  • services:deps:product:01t22000003xABCAA2
  • mapping:userId:user_12345

Configuration

Environment Variables

# Redis connection
REDIS_URL=redis://localhost:6379

# Production (Docker Compose)
REDIS_URL=redis://cache:6379/0

Redis Module

Location: /infra/redis/redis.module.ts

Provides global REDIS_CLIENT using ioredis.

Monitoring

Health Checks

# Overall system health (includes Redis check)
GET /health

# Services cache metrics
GET /api/health/services/cache

Response Format

{
  "timestamp": "2025-11-18T10:30:00.000Z",
  "metrics": {
    "catalog": { "hits": 1250, "misses": 48 },
    "static": { "hits": 890, "misses": 12 },
    "invalidations": 15
  },
  "ttl": {
    "servicesSeconds": null,
    "staticSeconds": null,
    "volatileSeconds": 60
  }
}

Best Practices

DO

  • Use CDC invalidation for Salesforce data
  • Implement request coalescing for all cache services
  • Track metrics (hits/misses/invalidations)
  • Use structured, hierarchical cache keys
  • Document TTL strategy in comments
  • Add JSDoc comments to public methods

DON'T

  • Mix TTL and CDC strategies without clear reasoning
  • Cache sensitive data without encryption
  • Use cache as primary data store
  • Forget to clean up in-flight requests (use finally block)
  • Use overly generic cache keys (risk of collision)

Troubleshooting

High Cache Miss Rate

  1. Check CDC subscriber health: GET /health/sf-events
  2. Verify invalidation patterns match expected keys
  3. Review TTL settings (if applicable)

Stale Data Issues

  1. Confirm CDC events are being received
  2. Check invalidation logic in CDC subscribers
  3. Verify cache key construction matches between set/invalidate

Memory Issues

// Check memory usage
const usage = await cacheService.memoryUsageByPattern("orders:*");
const count = await cacheService.countByPattern("orders:*");

console.log(`${count} keys using ${usage} bytes`);