Skip to content

Writing a brick

A brick is what other frameworks call a plugin. Bricks build the oven: add one and a capability appears on the request context, fully typed. Take it away and using it is a compile error.

This page is the contract, for anyone writing one. For the bricks Oven ships and how to use them, see the catalogue.

The name is the only piece of invented vocabulary in Oven, and it is deliberate — a brick is a specific thing with a specific contract, not the grab-bag “plugin” has become elsewhere.

A brick’s setup() runs once at boot and returns the thing it provides. That value becomes ctx.<name>, and its type flows through .use() into every handler.

const app = createApp()
.use(storage({ driver: 's3', bucket: 'uploads' }))
.use(queue({ driver: 'redis' }))
app.post('/upload', async (ctx) => {
const { url } = await ctx.storage.upload('key', file) // typed
await ctx.queue.dispatch('resize', { url }) // typed
return { url }
})

Leave the queue brick out and ctx.queue is a compile error, not a crash at 3am. That is the whole point, and the test suite asserts it: registering the brick makes the @ts-expect-error on that line unnecessary, which fails the build.

A brick contributes in two places, and the difference is the whole reason ctx.user can exist:

Runs Contributes Cost per request
setup() once, at boot a shared service none — it lives on the prototype
request() every request per-request state only what you compute
const auth = {
name: 'auth',
setup: () => createClerkClient(...), // shared, once
request: async (ctx) => ({ // per request
user: await verifyToken(ctx.token),
}),
}
app.get('/me', (ctx) => {
ctx.auth.users.get(...) // the shared client, typed
ctx.user?.id // per-request state, typed
})

request() runs after routing, so ctx.params is available, and before middleware, the handler and validation. Throwing rejects the request — which is how an auth brick turns a bad token into a 401 without a handler ever seeing it.

It is also handed the matched route:

request: (ctx, route) => {
if (route.schema?.auth) requireSignedIn(ctx)
}

Core never interprets auth: 'admin' on a route. It carries it, and the brick decides what it means — which is what keeps auth replaceable instead of built in.

import type { Brick } from '@theoven/core'
export function cache(options: { ttl: number }): Brick<'cache', CacheClient> {
return {
name: 'cache',
setup: async () => connect(options),
onShutdown: (client) => client.disconnect(),
}
}
Field Purpose
name the property added to the context
setup(context) builds the value; runs once, at boot
dependsOn brick names that must be set up first
onRequest(ctx) per-request work, such as loading a session
onShutdown(value) release pools, workers, connections

setup()’s return value lives on the context prototype, so it is shared across requests and costs nothing per request. Anything genuinely per-request — a session, a transaction — belongs in onRequest, which runs with the context in hand.

{
name: 'auth',
dependsOn: ['db'],
setup: (context) => makeAuth(context.resolved.db),
}

Declaring the dependency rather than relying on registration order means the auth brick finds the database however the user chose to write their config. A cycle throws at boot, naming both bricks — it cannot be resolved at runtime, so failing early beats deadlocking.

setup: (context) => {
context.route('GET', '/_docs', () => renderDocs())
return { mounted: true }
}

This is how /auth/* and the docs UI will mount themselves.

Every first-party service exposes the thing underneath it:

ctx.storage.raw // the Bun.S3Client, or the directory path
ctx.queue.raw // the Bun.RedisClient, or the SQL client
ctx.cache.raw // likewise
ctx.auth // the provider itself
ctx.db // not an escape hatch — `ctx.db` *is* the Drizzle client (D16)

This is what makes a small contract acceptable. A contract is only worth having if it is the genuine intersection of its implementations — the moment it grows a method that one driver has to fake, it has started lying. So anything a single driver can do and the others cannot stays off the contract, and raw is how you reach it:

// S3 can copy an object server-side. Disk, Bunny and ImageKit cannot, so `copy` is not on the
// contract — and reaching through `raw` is how you use it anyway.
const s3 = ctx.storage.raw as Bun.S3Client
await s3.file('archive/report.pdf').write(s3.file('reports/2026-q3.pdf'))

user.raw is the same idea with one difference: it is typed per adapter rather than unknown, because an auth provider’s shape is known at the point you register it. See user.raw.

Declared capabilities, when it is not a one-off

Section titled “Declared capabilities, when it is not a one-off”

raw is for reaching past the contract occasionally. When a capability is real but not universal, the answer is to declare it and check at boot rather than to make everyone reach:

if (!ctx.storage.canPresign) throw new ServiceUnavailable('Direct upload is not configured.')

That is D19, and it is why auth-clerk can say out loud that it cannot sign anyone out instead of offering a signOut() that quietly does nothing.

defineConfig puts the same thing in one declarative file:

oven.config.ts
export default defineConfig({
trustProxy: 1,
cookies: { secret: process.env.COOKIE_SECRET },
bricks: [storage({ driver: 's3' }), queue({ driver: 'redis' })],
})
  • A duplicate brick name.
  • A name that collides with a built-in context property, which would shadow the real thing in a way that surfaces far from the brick responsible.
  • A missing or circular dependency.
  • Registering a brick after the first request, since setup runs at boot.