Documentation
Decorators
Decorators are the primary way to share state in Kynetra FX. App decorators attach values to the app instance — database connections, config, caches. Context decorators attach values to each request's ctx — per-request user objects, tenant data, derived state.
App decorators#
App decorators are set once at startup and live for the lifetime of the process. They are ideal for heavyweight singletons: database clients, connection pools, secret stores, and feature-flag SDKs. Register them with app.decorate(key, value) and retrieve them anywhere with app.getDecorator(key).
import { createFX } from '@kynetra/fx'import { createDbClient } from './db' const app = createFX({ name: 'my-api' }) // Register the database client once at startupapp.decorate('db', createDbClient(process.env.DATABASE_URL)) // Read it inside a route handlerapp.get('/users', (ctx) => { const db = app.getDecorator('db') const users = db.query('SELECT * FROM users LIMIT 20') return ctx.json(users)}) return appThe value is shared — every call to app.getDecorator('db') returns the exact same object. This is what you want for a database client: one connection pool, reused across all requests.
Type-safe app decorators#
The key passed to decorate and getDecorator is always a plain string, but TypeScript does not know the type of the returned value without help. Pass a generic to getDecorator<T>(key) to get a typed return value and full editor autocomplete.
// Define your client types onceexport interface DbClient { query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]> execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }> close(): Promise<void>} export interface CacheClient { get(key: string): Promise<string | null> set(key: string, value: string, ttlSeconds?: number): Promise<void> del(key: string): Promise<void>}import { createFX } from '@kynetra/fx'import type { DbClient, CacheClient } from './types'import { createDbClient } from './db'import { createCacheClient } from './cache' const app = createFX({ name: 'my-api' }) app.decorate('db', createDbClient(process.env.DATABASE_URL))app.decorate('cache', createCacheClient(process.env.REDIS_URL)) app.get('/products/:id', async (ctx) => { const db = app.getDecorator<DbClient>('db') const cache = app.getDecorator<CacheClient>('cache') const cacheKey = `product:${ctx.params.id}` const cached = await cache.get(cacheKey) if (cached) return ctx.json(JSON.parse(cached)) const [product] = await db.query<{ id: string; name: string }>( 'SELECT id, name FROM products WHERE id = ?', [ctx.params.id] ) if (!product) return ctx.json({ error: 'Not found' }, { status: 404 }) await cache.set(cacheKey, JSON.stringify(product), 300) return ctx.json(product)})Checking before decorating#
When writing a plugin that may be registered more than once — or that you want to make idempotent — use app.hasDecorator(key) to check whether the key already exists before calling app.decorate. Calling app.decorate with a key that is already set throws an error, so the guard prevents accidental double-decoration in composed plugin trees.
import { createFX, definePlugin } from '@kynetra/fx'import { MetricsClient } from './metrics-client' export const metricsPlugin = definePlugin({ name: 'metrics', register(app) { // Safe to call this plugin multiple times — only decorates once if (!app.hasDecorator('metrics')) { app.decorate('metrics', new MetricsClient({ endpoint: process.env.METRICS_URL })) } },})Tip
app.boot() or handling requests. Plugin register functions run before boot, making them the right place to call app.decorate.Context decorators#
Context decorators attach values to the per-request ctx object rather than to the app. Register them once with app.decorateContext(key, value | factory). There are two modes:
- Plain value — all requests share the same reference. Use this for immutable config or read-only objects where sharing is safe.
- Factory function
(ctx) => T— called fresh for each incoming request. Use this whenever the decoration holds mutable state or needs to be scoped to a single request.
import { createFX } from '@kynetra/fx'import { appConfig } from './config' const app = createFX({ name: 'my-api' }) // Plain value — shared across all requests (safe: object is read-only)app.decorateContext('config', appConfig) // Factory — fresh object per request (necessary: holds mutable request state)app.decorateContext('requestMeta', (ctx) => ({ startedAt: Date.now(), ip: ctx.header('cf-connecting-ip') ?? ctx.header('x-forwarded-for') ?? 'unknown', requestId: ctx.header('x-request-id') ?? crypto.randomUUID(),})) app.get('/status', (ctx) => { const config = ctx.get('config') const meta = ctx.get('requestMeta') return ctx.json({ env: config.env, requestId: meta.requestId })})Warning
{}) as a context decoration value (not a factory), all requests share the same reference. Mutations in one request bleed into the next. Use a factory function to get isolated per-request instances.Factory-per-request pattern#
The factory pattern is the right choice whenever the decorated value depends on data from the incoming request — a structured logger with the request ID embedded, a user object resolved from the session, or a metrics span tied to this specific request lifecycle.
import { createFX, definePlugin } from '@kynetra/fx'import { createLogger, Logger } from './logger' export const requestLoggerPlugin = definePlugin({ name: 'request-logger', register(app) { // Factory receives ctx so it can read request headers app.decorateContext('logger', (ctx) => createLogger({ requestId: ctx.header('x-request-id') ?? crypto.randomUUID(), method: ctx.method, path: ctx.path, }) ) },})Inside any handler or middleware, retrieve the logger with ctx.get<Logger>('logger'). Because the factory runs for every request, each request gets a logger instance that carries its own requestId — no state bleeds between concurrent requests.
import type { Logger } from './logger' app.get('/users/:id', async (ctx) => { const logger = ctx.get<Logger>('logger') logger.info({ userId: ctx.params.id }, 'fetching user') const db = app.getDecorator<DbClient>('db') const [user] = await db.query('SELECT * FROM users WHERE id = ?', [ctx.params.id]) if (!user) { logger.warn({ userId: ctx.params.id }, 'user not found') return ctx.json({ error: 'Not found' }, { status: 404 }) } logger.info({ userId: user.id }, 'user fetched') return ctx.json(user)})Reading context decorations#
Use ctx.get<T>(key) to retrieve a context decoration from within a handler, middleware, or hook. The generic parameter T tells TypeScript what type to expect. Without the generic, the return type is unknown.
Context decorations are available at all stages of the request lifecycle — including preHandler hooks — because they are set up before routing begins.
import { createFX } from '@kynetra/fx'import type { Logger } from './logger'import type { CurrentUser } from './auth' // Reading context decorations inside a preHandler hookapp.addHook('preHandler', async (ctx) => { const logger = ctx.get<Logger>('logger') const user = ctx.get<CurrentUser>('user') logger.info( { userId: user?.id ?? 'anonymous', path: ctx.path, method: ctx.method }, 'request received' )})import type { Logger } from './logger' // Reading in middleware — same API as in a handlerapp.use(async (ctx, next) => { const logger = ctx.get<Logger>('logger') const token = ctx.header('authorization')?.replace('Bearer ', '') if (!token) { logger.warn('missing authorization header') return ctx.json({ error: 'Unauthorized' }, { status: 401 }) } // Resolve the user and make it available downstream const user = await verifyToken(token) ctx.set('user', user) return next()})Decorators in plugins#
Plugins are the canonical home for decorators. A well-written plugin decorates the app and the context in its register function, and tears down any resources in an onClose hook. This keeps setup, usage, and teardown co-located.
import { createFX, definePlugin } from '@kynetra/fx'import { LRUCache } from 'lru-cache'import type { Logger } from './logger' interface RequestMetrics { cacheHits: number cacheMisses: number flush(): void} export const cachePlugin = definePlugin({ name: 'cache', register(app) { // 1. App-level decorator: one shared LRU cache for the process lifetime if (!app.hasDecorator('cache')) { const cache = new LRUCache<string, string>({ max: 1000, ttl: 1000 * 60 * 5 }) app.decorate('cache', cache) } // 2. Context decorator: per-request metrics collector using a factory app.decorateContext('cacheMetrics', (_ctx): RequestMetrics => { let hits = 0 let misses = 0 return { get cacheHits() { return hits }, get cacheMisses() { return misses }, flush() { // e.g. emit to a metrics backend here }, } }) // 3. Hook: flush per-request metrics after the response is sent app.addHook('onSend', async (ctx) => { const metrics = ctx.get<RequestMetrics>('cacheMetrics') metrics.flush() }) // 4. Hook: close the shared cache gracefully when the app shuts down app.addHook('onClose', async () => { const cache = app.getDecorator<LRUCache<string, string>>('cache') cache.clear() }) },})Register the plugin with app.register(cachePlugin) and the shared cache and per-request metrics are available everywhere without any imports or globals.
import type { LRUCache } from 'lru-cache'import type { RequestMetrics } from './plugins/cache-plugin' app.get('/items/:id', async (ctx) => { const cache = app.getDecorator<LRUCache<string, string>>('cache') const metrics = ctx.get<RequestMetrics>('cacheMetrics') const cacheKey = `item:${ctx.params.id}` const hit = cache.get(cacheKey) if (hit) { metrics.cacheHits // incremented inside the decorator return ctx.json(JSON.parse(hit)) } metrics.cacheMisses const item = await fetchItem(ctx.params.id) cache.set(cacheKey, JSON.stringify(item)) return ctx.json(item)})App vs context — when to use which#
The choice between app decorators and context decorators comes down to lifetime and isolation:
- App decorators — created once, shared across every request for the life of the process. Use them for singletons: database clients, connection pools, configuration, SDKs, caches. Accessing them via
app.getDecoratoris cheap — there is no per-request overhead. - Context decorators (plain value) — registered once, shared reference attached to every
ctx. Functionally equivalent to an app decorator but accessible viactx.getinside handlers. Useful for read-only objects you want reachable from ctx for ergonomic reasons. - Context decorators (factory) — registered once, but the factory runs fresh for every incoming request. The resulting value is scoped to that request's
ctxand is garbage-collected when the request finishes. Use these for anything that holds request-scoped state: loggers with embedded request IDs, open database transactions, per-request auth principals, span tracers.
Quick reference
| Name | Type | Description |
|---|---|---|
| App decorators | Shared singleton | Set once at startup. Ideal for database clients, caches, config, and SDKs that are expensive to create and safe to reuse across requests. |
| Context decorators (value) | Shared reference on ctx | Same reference on every request ctx. Use only for immutable or read-only objects — mutable shared state causes cross-request bugs. |
| Context decorators (factory) | Per-request instance | Factory runs once per request. Ideal for loggers, transactions, principals, and any state that must be isolated per request. |
API reference#
App decorator methods
| Name | Type | Description |
|---|---|---|
| app.decorate(key, value) | void | Attaches value to the app under key. Throws if key is already set — use app.hasDecorator first to guard against double-decoration. |
| app.getDecorator<T>(key) | T | undefined | Retrieves a previously decorated value typed as T. Returns undefined if the key has not been set. |
| app.hasDecorator(key) | boolean | Returns true if a value has been decorated under key. Use in plugins to make decoration idempotent. |
Context decorator methods
| Name | Type | Description |
|---|---|---|
| app.decorateContext(key, value | factory) | void | Registers a context decoration. Pass a plain value to share one reference across all requests, or a factory (ctx) => T to produce a fresh instance per request. |
| ctx.get<T>(key) | T | Retrieves the context-decorated value for this request, typed as T. Available in handlers, middleware, and hooks. |