Elysia Dependency Injection: What We Measured on 1.4.16
Elysia 1.4.16 dependency injection, measured: an unnamed plugin used three times ran its derive 3 times, and a second .decorate kept the first value.
Elysia's documentation treats dependency injection as explicit declaration. Each instance is independent, and "when an instance needs to use another instance's service, you must explicitly declare the dependency" (key concepts). That page also covers deduplicating a plugin by name. The plugin page limits a scoped hook to one parent, and the page on extending context walks through state, decorate, derive and resolve. We won't repeat any of it.
Those pages give the rules. We wanted numbers, taken from a copy of our own wiring. What does a missing .as("global") do to a handler, at runtime and under tsc? Which routes pay for a global derive? And what happens when a test decorates a fake over a value a plugin already set? On the docs pages that came up for dependency, inject, singleton and override, we found nothing about that last case, or about how to swap a dependency in a test at all.
Our team ran the probes on 2026-10-25 against elysia 1.4.16, the version pinned in ShipKit's API, on Bun 1.4.2. Every probe calls app.handle(new Request(...)), so no network and no database show up in the numbers. Most of them copy the shape of our production wiring. That comes first.
The wiring we measured against
ShipKit's API has a single context plugin, ctx. It decorates the database and cache clients, pulls in the auth plugin and lifts the result to global scope:
export const ctx = new Elysia({
name: "@app/ctx",
})
.decorate("drizzle", drizzleDb)
.decorate("cache", cache)
.use(authContext)
.as('global');
authContext is a named plugin with one scoped derive. It tries the Better Auth session cookie first and falls back to an Authorization: Bearer sk_… API key:
export const authContext = new Elysia({ name: "auth-context" })
.derive({ as: "scoped" }, async ({ headers }) => {
const session = await auth.api.getSession({
headers: headers as unknown as Headers,
});
// …
const principal = await resolveApiKey(headers.authorization);
// …
});
Two more plugins, entitlementPlugin and rbacPlugin, also call .use(authContext). projectsController mounts ctx and entitlementPlugin side by side, so a single controller reaches the auth plugin by more than one path.
Fourteen controllers call .use(ctx). The /api/v1 group mounts fifteen.
Why does a scoped derive disappear two plugins up?
Our probes agree with the one-parent rule on 1.4.16. A derive with no as inside a plugin never reached the app that used it (P4 printed undefined). Mark it scoped and it climbed exactly one level. Plugin to middle plugin to app, and it was gone (P4b, undefined). Adding .as("global") to the middle plugin carried it the rest of the way (P4c printed scoped).
That middle plugin is our ctx. authContext sits two levels below the handlers, which is why ctx ends with .as('global').
So what happens when that call is missing? We ran a reduced copy of our shape without it:
const auth2 = new Elysia({ name: "auth2" }).derive({ as: "scoped" }, () => ({ user: { id: "u1" } }));
const ctx2 = new Elysia({ name: "ctx2" }).use(auth2);
const proj2 = new Elysia({ name: "p2" }).use(ctx2).get("/projects", ({ user }) => user.id);
const app2 = new Elysia().use(proj2);
GET /projects came back as:
500 undefined is not an object (evaluating 'user.id')
In P6, with the same shape, the derive ran 0 times on that route. TypeScript got there first. tsc --strict on the same code reported error TS2339: Property 'user' does not exist on type … for that handler, so a strict type check in CI catches this shape before any request does.
The global lift isn't free, and route order decides who pays. A derive runs even on routes that never read its value (P5). In ShipKit's shape (P6), a /health route registered on the app before the controller ran the derive 0 times. Register the same handler after the controller and it ran once. So did /projects.
One of our own routes sits in exactly that spot. The Polar webhook, POST /webhooks/polar, lives in checkoutController, after its .use(ctx). Going by P5 and P6, the auth derive runs on Polar webhook requests as well. We haven't measured that route on its own.
Does mounting the auth plugin three times cost three lookups?
It did when the plugin had no name. For a control probe we put a counter inside a scoped derive and mounted the plugin three times, directly on one app. One setup had a name. The other passed the same unnamed instance object to all three .use calls. Nothing else differed:
const req = (app: any, p = "/me") => app.handle(new Request("http://localhost" + p));
// …
const named = new Elysia({ name: "auth-context" }).derive({ as: "scoped" }, () => { c1++; return { user: { id: "u1" } }; });
await req(new Elysia().use(named).use(named).use(named).get("/me", ({ user }) => user.id));
// …
const shared = new Elysia().derive({ as: "scoped" }, () => { c3++; return { user: { id: "u1" } }; });
await req(new Elysia().use(shared).use(shared).use(shared).get("/me", ({ user }) => user.id));
After one request the named derive had run once (C1 = 1). The unnamed one ran 3 times (C3 = 3), even though every .use got the very same object.
Fresh unnamed instances from a factory also counted 3, whether we mounted them on the app (P1c) or inside one controller (C5). So at one level the name did the deduplicating, and reusing an instance did nothing on its own. Put a session lookup inside the derive and those unnamed setups pay for three lookups per request.
P1 copies our production shape: a named auth-context reaches one controller through ctx, entitlement and rbac. It also counted 1. Scope explains that count. An unnamed copy wired the same way ran once too (C2), because the entitlement and rbac copies sit two levels below the handler, where P4b lost a scoped derive.
A second .decorate loses, without a warning
Say you want a test double. The obvious first move is to mount the real plugin and decorate a fake under the same key:
const prod = new Elysia({ name: "deps" }).decorate("db", { kind: "real" });
const app3 = new Elysia()
.use(prod)
.decorate("db", { kind: "fake" })
.get("/", ({ db }) => db.kind);
The handler returned real.
No error, no warning. The fake just never got used. Putting both calls on one instance, as in .decorate("db", { kind: "real" }).decorate("db", { kind: "fake" }), changed nothing: the handler still got real (C6).
Two variants did swap it. Decorate the fake before .use(prod) and the handler returned fake, so the first write wins. Passing an override flag to the later call also returned fake:
.decorate({ as: "override" }, "db", { kind: "fake" })
A test written the first way runs against whatever prod decorated, and nothing in the output tells you. Either fix is one line.
What we decorate and never read
Next to all this, part of our own code looks odd. ctx decorates drizzle and cache. Yet at commit 56045c7 zero handlers in apps/api/src/modules or lib read either one from Context. We grepped for the bare identifiers outside imports and comments and got back only the two .decorate lines. They're still there.
The services import the client directly instead. Eleven files under apps/api/src/modules import drizzleDb. ProjectsService is typical:
export abstract class ProjectsService {
static async list(ownerId: string | null, limit = 50, offset = 0) {
if (!ownerId) return fail("UNAUTHORIZED", "Authentication required");
const rows = await drizzleDb
.select()
.from(projects)
// …
return { projects: rows };
}
That's the pattern on the Elysia best-practice page, where a service "handles business logic, decoupled from Elysia controller" and gets called as a static method. An architecture test scans our source tree and holds both sides of that line. It asserts that "controllers never import the database directly" and that "services never import the Elysia class or Context".
Here's what that means for tests. ProjectsService.list takes primitives and reads the imported drizzleDb, so a fake decorated on an Elysia app never reaches it. Our projects service test calls ProjectsService.list(null) and asserts the 401 from a guard branch that returns before any query. Paths that hit the database are integration-only.
The only thing our handlers read from ctx is what authContext derives per request, such as user in the projects controller.
Where this code comes from
ctx, authContext and ProjectsService above, and the architecture test we quoted, come from the API in ShipKit, our Bun + Elysia + Next.js SaaS starter ($149). The snippets are from commit 56045c7, unused decorates included.
FAQ
Is a value passed to Elysia's decorate a singleton?
In our probe, yes. One decorated object served five requests, and the handler saw the same instance every time (P2). The extends-context page lists decorate for "Additional functions, singletons, or immutable property to all handlers".
Derive or resolve: which one should hold auth?
We didn't probe resolve, so we can't answer this from measurement. The extends-context page says derive "assigns when request happens at transform lifecycle" and carries a warning: "Derive doesn't handle type integrity, you might want to use resolve instead." Our authContext uses derive and casts the raw headers (headers as unknown as Headers) before the session lookup.
How do I swap a dependency in an Elysia test?
On elysia 1.4.16, a plain .decorate after .use(plugin) does not replace the plugin's value. Decorate the fake before mounting the plugin, or pass { as: "override" } to the later .decorate. Both returned the fake in our probe.