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.
/healthzfor liveness,/readyzfor readiness, both Kubernetes-friendly. - Metrics. Counters / histograms for every RPC method, exported in Prometheus format.
Architecture
AppModule
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
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: trueon ConfigModule — misconfig fails at boot -
watchForChanges: falsein production — hot-reload is dev-only -
LoggerModule.processorsincludesRedactionProcessorwith every sensitive path -
auth.cacheTTL≤ acceptable revocation window - Database
pool.max≤ Postgresmax_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 } } -
/metricsendpoint 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 — when the workload is async jobs, not request handlers
- Observability stack — beyond metrics: traces + telemetry-relay for cross-pod shipping
- Multi-tenant SaaS — extends this stack with RLS-driven tenant isolation