---
id: "titan/recipes/api-service"
title: "API service stack"
description: "Public HTTP/WS API — auth, database, cache, rate limiting, health, metrics."
---

# API service stack

The canonical shape for a public API that handles user traffic:
authenticated requests, database reads/writes, cache in front,
rate limiting per user, health probes for Kubernetes, metrics for
dashboards.

## Shape

- **Authenticated.** JWT tokens (or service-key for trusted
  callers) verified on every request.
- **Database-backed.** Postgres + Kysely, soft-delete + timestamps
  plugins on, RLS for tenant isolation.
- **Cache in front.** L1 in-memory per pod, L2 in Redis shared
  across pods.
- **Rate-limited.** Per-user sliding window in Redis, tiered plans.
- **Health-probed.** `/healthz` for liveness, `/readyz` for
  readiness, both Kubernetes-friendly.
- **Metrics.** Counters / histograms for every RPC method,
  exported in Prometheus format.

## Architecture

```mermaid
flowchart LR
  Client[Browser / mobile]
  Client -->|HTTPS + JWT| Edge[Netron HTTP transport]
  Edge --> Auth[titan-auth<br/>JWTService]
  Auth --> Rate[titan-ratelimit<br/>per-user]
  Rate --> Service[Your @Service classes]
  Service --> Cache[titan-cache<br/>L1 → L2]
  Cache -.L2.-> Redis[(Redis)]
  Service --> DB[titan-database<br/>Kysely + RLS]
  DB --> PG[(PostgreSQL)]
  Service --> Metrics[titan-metrics]
  Health[titan-health] -.indicators.-> DB
  Health -.indicators.-> Redis
  Probe[Kubernetes probes] --> Health
  Metrics --> Prom["/metrics endpoint"]
```

## `AppModule`

```typescript
import { Module } from '@omnitron-dev/titan';
import { z } from '@omnitron-dev/titan/validation';
import { ConfigModule } from '@omnitron-dev/titan/module/config';
import { LoggerModule, ConsoleTransport, RedactionProcessor }
  from '@omnitron-dev/titan/module/logger';

import { TitanRedisModule } from '@omnitron-dev/titan-redis';
import { TitanAuthModule } from '@omnitron-dev/titan-auth';
import { TitanDatabaseModule } from '@omnitron-dev/titan-database';
import { TitanCacheModule } from '@omnitron-dev/titan-cache';
import { TitanRateLimitModule } from '@omnitron-dev/titan-ratelimit';
import { TitanHealthModule } from '@omnitron-dev/titan-health';
import { TitanMetricsModule } from '@omnitron-dev/titan-metrics';
import { sql } from 'kysely';

const AppConfigSchema = z.object({
  env: z.enum(['development', 'staging', 'production']),
  redis: z.object({ url: z.string().url() }),
  database: z.object({ url: z.string().url() }),
  auth: z.object({
    jwtSecret: z.string(),
    issuer: z.string(),
    audience: z.string(),
  }),
  rateLimit: z.object({
    defaultLimit: z.number().int(),
    defaultWindowMs: z.number().int(),
  }),
});

@Module({
  imports: [
    // ── Configuration + logging (auto-loaded; shown for completeness) ──
    ConfigModule.forRoot({
      schema:  AppConfigSchema,
      sources: [
        { type: 'file', path: 'config/default.yaml' },
        { type: 'file', path: 'config/${NODE_ENV}.yaml', optional: true },
        { type: 'env',  prefix: 'APP_' },
      ],
      validateOnStartup: true,
      watchForChanges:   process.env.NODE_ENV !== 'production',
    }),

    LoggerModule.forRoot({
      level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
      transports: [new ConsoleTransport()],
      processors: [
        new RedactionProcessor(['password', 'token', 'headers.authorization', 'creditCard.*']),
      ],
    }),

    // ── Foundation ─────────────────────────────────────────────────────
    TitanRedisModule.forRootAsync({
      useFactory: (config: ConfigService) => ({
        clients: [
          { namespace: 'default', url: config.get('redis.url'), db: 0 },
          { namespace: 'cache',   url: config.get('redis.url'), db: 1 },
          { namespace: 'rl',      url: config.get('redis.url'), db: 4 },
        ],
      }),
      inject: [ConfigService],
    }),

    // ── Database ───────────────────────────────────────────────────────
    TitanDatabaseModule.forRootAsync({
      useFactory: (config: ConfigService) => ({
        connection: {
          dialect:        'postgres',
          connection:     config.get('database.url'),
          pool:           { min: 2, max: 20 },
          migrationsPath: './migrations',
          coerceBigint:   true,
        },
        // Global plugins are a Kysera plugin list, not a boolean map.
        kysera: {
          plugins: ['soft-delete', 'timestamps', 'audit'],
        },
        // RLS is enabled per-repository via @Policy/@Filter decorators
        // (see the Multi-tenant SaaS recipe), not a global plugin toggle.
      }),
      inject: [ConfigService],
    }),

    // ── Cache (L1 in-memory + L2 Redis) ────────────────────────────────
    TitanCacheModule.forRootAsync({
      useFactory: (redis: RedisService) => ({
        multiTier: true,
        l1: { maxSize: 5_000,  ttl: 60 },             // hot — 1 min
        l2: { client: redis.getClient('cache'), ttl: 3_600, prefix: 'cache:' },
        evictionPolicy: 'lru',
      }),
      inject: [RedisService],
    }),

    // ── Auth ───────────────────────────────────────────────────────────
    TitanAuthModule.forRootAsync({
      useFactory: (config: ConfigService) => ({
        algorithm:    'HS256',
        jwtSecret:    config.get('auth.jwtSecret'),
        issuer:       config.get('auth.issuer'),
        audience:     config.get('auth.audience'),
        cacheEnabled: true,
        cacheMaxSize: 5_000,
        cacheTTL:     300_000,                         // 5 min
      }),
      inject: [ConfigService],
    }),

    // ── Rate limiting ──────────────────────────────────────────────────
    TitanRateLimitModule.forRootAsync({
      useFactory: (config: ConfigService) => ({
        enabled:         true,
        strategy:        'sliding-window',
        storageType:     'redis',                      // shared across pods
        defaultLimit:    config.get('rateLimit.defaultLimit'),
        defaultWindowMs: config.get('rateLimit.defaultWindowMs'),
        keyPrefix:       'rl',
        tiers: {
          free:       { name: 'free',       limit: 60,     windowMs: 60_000 },
          pro:        { name: 'pro',        limit: 1_000,  windowMs: 60_000 },
          enterprise: { name: 'enterprise', limit: 10_000, windowMs: 60_000 },
        },
      }),
      inject: [ConfigService],
    }),

    // ── Health probes ──────────────────────────────────────────────────
    TitanHealthModule.forRootAsync({
      // The database indicator wants an object with raw(), execute() or
      // query() — a Kysely instance has none of them, so wrap it. Passing the
      // connection straight through throws on the first check.
      useFactory: async (db: DatabaseManager, redis: RedisService) => ({
        enableMemoryIndicator:    true,
        enableEventLoopIndicator: true,
        enableDatabaseIndicator:  true,
        databaseConnection: {
          execute: async (q: string) => { await sql.raw(q).execute(await db.getConnection()); },
        },
        enableRedisIndicator:     true,
        redisClient:              redis.getClient('default'),
        memoryThresholds:         { heapDegradedThreshold: 0.8, heapUnhealthyThreshold: 0.95 },
        eventLoopThresholds:      { lagDegradedThreshold: 50, lagUnhealthyThreshold: 200 },
        timeout:                  3_000,
        enableCaching:            true,
        cacheTtl:                 1_000,
        enableRpcService:         true,                 // expose via Netron
        version:                  process.env.APP_VERSION,
      }),
      inject: [DATABASE_MANAGER, RedisService],
    }),

    // ── Metrics ────────────────────────────────────────────────────────
    TitanMetricsModule.forRoot({
      appName:    'my-api',
      collection: {
        enabled:  true,
        interval: 10_000,
        process:  true,
        system:   true,
        rpc:      true,                                 // auto-collect Netron call metrics
      },
      storage: { type: 'memory', batchSize: 500, flushInterval: 10_000 },
      retention: { maxAge: '7d', cleanupInterval: 3_600_000 },
    }),

    // ── Your feature modules ───────────────────────────────────────────
    UsersModule,
    OrdersModule,
    BillingModule,
  ],
})
export class AppModule {}
```

## A typical `@Service`

```typescript
import { Service, Inject } from '@omnitron-dev/titan';
import { Public } from '@omnitron-dev/titan/netron';
import { Cacheable, CacheInvalidate } from '@omnitron-dev/titan-cache';
import { InjectRepository } from '@omnitron-dev/titan-database';
import { RequireAuth } from '@omnitron-dev/titan-auth';
import { RateLimit, RATE_LIMIT_SERVICE_TOKEN, type IRateLimitService } from '@omnitron-dev/titan-ratelimit';
import { Metrics }     from '@omnitron-dev/titan-metrics';

@Service('users@1.0.0')
class UsersService {
  constructor(
    @InjectRepository(UsersRepository) private readonly repo: UsersRepository,
    // @RateLimit reads the service off this exact property name.
    @Inject(RATE_LIMIT_SERVICE_TOKEN) private readonly __rateLimitService__: IRateLimitService,
  ) {}

  @Public()
  @RequireAuth({ allowAnonymous: false })
  @RateLimit({ limit: 100, windowMs: 60_000 })   // key = {class}:{method}:{arg0} by default
  @Cacheable({ cacheName: 'users', keyPrefix: 'u', ttl: 60, tags: (id) => [`user:${id}`] })
  @Metrics('users.findById')                      // tracks total + duration + errors
  async findById(id: string) {
    return this.repo.find(id);
  }

  @Public()
  @RequireAuth({ roles: ['admin'] })
  @CacheInvalidate({ cacheName: 'users', tags: (input) => [`user:${input.id}`] })
  async update(input: { id: string; patch: Partial<User> }) {
    return this.repo.update(input.id, input.patch);
  }
}
```

## Cross-module wiring notes

| Concern               | Wiring detail                                                                                                                                                                                                                                                |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Redis namespaces      | `default` (general), `cache` (L2), `rl` (rate limits) — isolate per DB index to prevent collisions                                                                                                                                                           |
| Cache L2 client       | `TitanCacheModule.forRootAsync` injects `RedisService` and gets the `cache` namespace explicitly                                                                                                                                                             |
| Health → database     | `await db.getConnection()` — `getConnection()` is async; the awaited `Kysely` instance is what `DatabaseHealthIndicator` accepts                                                                                                                             |
| Health → redis        | Pass `redis.getClient('default')` — uses `.ping()` for liveness                                                                                                                                                                                              |
| Auth cache vs JWT TTL | `cacheTTL: 300_000` < typical JWT expiry; revoked tokens stay valid until cache expires. Lower for high-churn revocation.                                                                                                                                    |
| Rate limit key shape  | `@RateLimit` takes an options object only. The key defaults to `{class}:{method}:{arg0}`; pass `keyGenerator: (...args) => ...` to fold in the auth identity. The service must inject the limiter as `__rateLimitService__` or the decorator silently no-ops |
| Metrics + Netron      | `collection.rpc: true` auto-instruments every `@Public` method — no per-method `@Metrics` needed unless you want a custom metric name (`@Metrics('users.findById')`)                                                                                         |

## Production checklist

- [ ] `validateOnStartup: true` on ConfigModule — misconfig fails at boot
- [ ] `watchForChanges: false` in production — hot-reload is dev-only
- [ ] `LoggerModule.processors` includes `RedactionProcessor` with **every** sensitive path
- [ ] `auth.cacheTTL` ≤ acceptable revocation window
- [ ] Database `pool.max` ≤ Postgres `max_connections` ÷ pod count
- [ ] Rate limit `storageType: 'redis'` (not `'memory'`) for multi-pod
- [ ] Kubernetes probes wired:
  `yaml
      livenessProbe:  { httpGet: { path: /healthz, port: 3000 } }
      readinessProbe: { httpGet: { path: /readyz,  port: 3000 } }
      `
- [ ] `/metrics` endpoint exposed and scraped by Prometheus
- [ ] Logs shipping off-host (sidecar / fluentbit / stdout to log aggregator)
- [ ] Database migrations run as a separate step (not at app boot in production)

## See also

- [Worker fleet](/docs/titan/recipes/worker-fleet) — when the workload is async
  jobs, not request handlers
- [Observability stack](/docs/titan/recipes/observability-stack) — beyond metrics:
  traces + telemetry-relay for cross-pod shipping
- [Multi-tenant SaaS](/docs/titan/recipes/multi-tenant-saas) — extends this stack
  with RLS-driven tenant isolation