Dependencies
import { dependency } from '@theoven/core'
const tenant = dependency('tenant', (ctx) => resolveTenant(ctx.header('host')))
const member = dependency('member', async (ctx, use) => { const current = await use(tenant) const found = await findMember(current.id, ctx.user.id) if (!found) throw new Forbidden('Not a member of this tenant.') return found})
export default route({ auth: true, deps: { member } }, (ctx) => ctx.deps.member.role)// ^ typedWhy not a brick
Section titled “Why not a brick”Bricks are app-level: built once at boot, present on every request. A dependency is the other scale.
| Brick | Dependency | |
|---|---|---|
| Lifetime | the app | one request |
| Applies to | every route | the routes that name it |
| Built | once, at boot | per request, cached within it |
| Teardown | onShutdown |
after the request |
| Reaches the handler as | ctx.<name> |
ctx.deps.<name> |
| Use for | a pool, a client, a service | a tenant, a permission, a transaction |
Before this, resolving something per request meant a brick — which then ran on every route in the
app — or repeating it at the top of every handler. ctx.deps.x rather than ctx.x keeps the two
namespaces apart, so “where did this come from” is answerable by reading the route (D31).
Composing
Section titled “Composing”A resolver’s second argument resolves other dependencies:
const db = dependency('db', (ctx) => ctx.db)const account = dependency('account', async (ctx, use) => findAccount(await use(db), ctx.user.id))const plan = dependency('plan', async (_ctx, use) => (await use(account)).plan)Each resolves once per request, however many others ask for it — use caches the promise, not
just the value, so two concurrent asks share one resolution rather than racing to start two.
Throwing from a resolver rejects the request before the handler runs, which is what makes
member above a guard as well as a value.
Teardown
Section titled “Teardown”An async generator yields its value and cleans up afterwards:
const tx = dependency('tx', async function* (ctx) { const handle = await begin(ctx.db) try { yield handle await handle.commit() } catch (error) { await handle.rollback() throw error }})
export default route({ deps: { tx } }, async (ctx) => { await ctx.deps.tx.insert(orders).values(order) return { ok: true }})When the request succeeds the generator is resumed normally. When it fails, the error is thrown
in at the yield — which is what lets catch tell a rollback from a commit.
Teardowns run in reverse order of resolution, so a dependency can rely on the ones it used still being alive while it cleans up.
A teardown that throws on a successful request makes that request a 500 — a commit that did
not commit means the response about to go out is a lie. On an already-failing request it is logged
instead, because replacing the original error with a cleanup error loses the thing worth reading.
For a whole group
Section titled “For a whole group”A router can declare dependencies for every route it holds — the
equivalent of FastAPI’s APIRouter(dependencies=[...]):
const admin = routerFor<typeof app>({ prefix: '/admin', deps: { currentTenant } })
admin.get('/usage', (ctx) => report(ctx.deps.currentTenant))They accumulate with a route’s own rather than replacing them, and a router dependency that throws refuses every route under it — which is a compact way to guard a group.
Testing
Section titled “Testing”app.override(tenant, () => ({ id: 'test-tenant', plan: 'pro' }))Overrides apply to sub-dependencies too, so replacing tenant also changes what member sees
without touching member. app.clearOverrides() puts the real ones back.
This is the seam dependency injection is supposed to buy: a test swaps what a route depends on without reaching into the route, and without a module mock.
When they run
Section titled “When they run”After validation and after the brick hooks,
before the handler. So a resolver can read ctx.params and ctx.body already validated, and
ctx.user is populated.
They resolve concurrently
Section titled “They resolve concurrently”A route’s declared dependencies start together:
export default route({ deps: { user, subscription, features } }, handler)Three dependencies each doing 10 ms of I/O cost ~11 ms, not 33 ms — a route waits for the slowest, not the sum. That is the usual shape: a dependency exists because something has to be fetched.
Sub-dependencies are still ordered by what needs what — use() inside a resolver is awaited
normally. To run two of those together, say so:
const both = dependency('both', async (_ctx, use) => Promise.all([use(profile), use(settings)]),)A failure is reported in declaration order, not by whichever rejected first. Two dependencies that both fail would otherwise produce different errors on different runs, which makes the same bug look like two.
Siblings still get torn down. A dependency that opened a transaction and a sibling that then threw will not leave the transaction open.
Cycles
Section titled “Cycles”A dependency that depends on itself, directly or through others, fails with the path rather than a stack overflow:
Dependency cycle: account -> plan -> account.It is a DependencyCycleError, so an application can tell a configuration mistake from a resolver
that threw. A diamond is not a cycle — two dependencies sharing a third is the normal case,
and it resolves the shared one once.
Nothing at all on a route that declares none — no scope, no map, no array.
| one dependency | ~590 ns |
| two or more | ~+400 ns on top, for the concurrency machinery |
| three 10 ms I/O dependencies | ~11 ms, against ~33 ms resolved one at a time |
The fixed cost of resolving concurrently is only paid by routes declaring more than one, and it is paid back many times over by anything that touches the network. If two dependencies are both synchronous, they were probably function calls in disguise. The cache and the teardown stack are allocated on first use rather than per request, because the common shape is one dependency with no sub-dependencies and no teardown.
Limitations
Section titled “Limitations”- A nested router’s dependencies are not typed for its own handlers. They resolve, but a child built before it was nested cannot see what its parent declares. Declare them on the child too if you want the types.
- Not in the OpenAPI document. A dependency that acts as a guard does not appear as a security
requirement; use
authwith a policy for anything that should be documented. - No app-wide dependencies. Every route names what it needs. That is the point, but it means
no equivalent to FastAPI’s
FastAPI(dependencies=[...]).
How it is verified
Section titled “How it is verified”The behaviours that carry risk each have a test, and the subtle ones were confirmed to fail when broken: caching (resolves once per request, and concurrent asks share one resolution), reverse teardown order, and the commit-versus-rollback distinction.
Two came out of tests that were wrong first. One asserted on a variable nothing ever assigned — it
passed while proving nothing, and rewriting it to observe real ordering exposed a leak: a
dependency that threw part-way through the list left the ones before it open, because resolution
ran outside the try that disposes them. The other was written with cleanup after yield and no
finally, and failing it is what pinned down the semantics now documented above.