Values and lifetime scopes
Files in di/ inject plain values onto ctx. Each declares how long it lives: singleton (the whole process), session (one visitor), or request (one unit of work).
// src/di/currentUser.ts
import { di } from "clovejs"
export default di({
lifetime: "session",
value: null as User | null,
})The three lifetimes
| Lifetime | Created | Disposed | Typical use |
|---|---|---|---|
singleton | Once, at boot, before the server listens | On shutdown | Config, database clients, caches |
session | On first access within a visitor's session | When the session expires or is destroyed | The signed-in user, a cart |
request | On first access within one unit of work | When that work finishes | Request id, per-request transaction |
Declaring any session-scoped value turns sessions on for the app. request scope needs no setup.
request means one unit of work, not strictly one HTTP request: a scope opens for an HTTP request, a socket, an MCP call and a message delivery alike, so one file serves all of them. WebSocket connections each get their own request-scoped container, disposed when the socket closes.
Knowing what opened the scope
When one request-lifetime value serves several kinds of work and has to know which it got, the factory's second argument carries trigger, a discriminated union:
// src/di/workSource.ts
export default di({
lifetime: "request",
value(_ctx, { trigger }) {
switch (trigger?.kind) {
case "http":
return `${trigger.req.method} ${trigger.req.path}`
case "delivery":
return `${trigger.bus}/${trigger.channel} → ${trigger.consumer}`
default:
return "unknown"
}
},
})kind is "http", "ws", "mcp" or "delivery", each narrowing to what that kind actually carries. trigger is undefined for singleton and session factories, which outlive any single unit of work.
Assigning from a middleware
Writing to ctx writes into the scope the value was declared with, so a session value assigned during one request is still there on the next:
// src/middlewares/authenticate.1.ts
import { middleware } from "clovejs"
export default middleware(async ({ handler, req, ctx }) => {
ctx.currentUser = await ctx.auth.verify(req.cookie.token)
return handler.execute()
})Computed values
A value can be a factory, with access to other dependencies and to teardown hooks:
// src/di/db.ts
import { di } from "clovejs"
import { Client } from "pg"
export default di({
lifetime: "singleton",
async value(ctx, { onDestroy }) {
const config = ctx.config.db
const client = new Client({ user: config.user, password: config.password })
await client.connect()
onDestroy(async () => client.end())
return client
},
})The distinction is made by type: if value is a function, it is treated as a factory. To inject a function as a value, wrap it — value: () => myFn — or return it from a service instead.
Resolution rules
Singletons are all resolved before the server accepts traffic, so reading ctx.db from a handler or a service method is synchronous and safe.
Inside a factory, await anything you depend on — await on a plain value is harmless, so awaiting uniformly is always correct:
async value(ctx) {
const db = await ctx.db // another factory: await it
const config = ctx.config // a plain value: already resolved
}Session- and request-scoped factories resolve on first access within their scope, so the first read returns a promise:
export default get(async (req, res, ctx) => {
const tx = await ctx.transaction // request-scoped factory
return tx.query("select 1")
})Rule of thumb
In a handler, await anything that is not a singleton. In a factory, await everything.
Configuration values
The most common di/ file is plain config, read from the environment once:
// src/di/config.ts
import { di } from "clovejs"
export default di({
lifetime: "singleton",
value: {
url: process.env.URL ?? "http://localhost:3000",
db: {
user: process.env.DB_USER ?? "postgres",
password: process.env.DB_PASSWORD ?? "",
},
},
})ctx.config is typed from this file's inferred type — no interface to maintain. See Typed context.
Teardown ordering
onDestroy hooks run in reverse registration order when their scope is disposed, so a dependency is always torn down after the things that used it.