Skip to content

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).

ts
// src/di/currentUser.ts
import { di } from "clovejs"

export default di({
  lifetime: "session",
  value: null as User | null,
})

The three lifetimes

LifetimeCreatedDisposedTypical use
singletonOnce, at boot, before the server listensOn shutdownConfig, database clients, caches
sessionOn first access within a visitor's sessionWhen the session expires or is destroyedThe signed-in user, a cart
requestOn first access within one unit of workWhen that work finishesRequest 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:

ts
// 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:

ts
// 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:

ts
// 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:

ts
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:

ts
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:

ts
// 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.

Released under the MIT License.