Recommended reading: The request context problem — Why
reqbecame a junk drawer and what Wooks does differently.
In the previous article, I argued that request context is where backend frameworks quietly go wrong.
This one is the practical follow-up: what do you actually do instead?
Short answer: write composables for app logic.
Not for everything. Not instead of every low-level hook. But for a surprising amount of work that keeps ending up in middleware: current user, tenant, pagination, filters, parsed body, locale, permissions.
That kind of code is just app logic that needs access to request context.
Middleware became the default answer
In Express and Fastify, the default answer to most backend needs is still:
"Add middleware. Put the result on the request. Read it later."
Which is how apps end up with chains like this:
app.use(authMiddleware)
app.use(tenantMiddleware)
app.use(paginationMiddleware)
app.use(filterMiddleware)
app.get('/projects', (req, res) => {
return res.json(listProjects({
user: req.user,
tenant: req.tenant,
pagination: req.pagination,
filters: req.filters,
}))
})
This works. But it also means:
- the handler depends on state that appeared from somewhere above
- some of that work runs even when the route does not need it
- testing means mocking a weirdly expanded request object
- reusable app logic stays glued to HTTP conventions
At some point, middleware stops being an abstraction and starts being a storage strategy.
This does not belong in middleware
Some things genuinely belong in middleware: CORS, compression, transport logging, proxy handling, security headers. Boundary-level work. Middleware is fine there.
But "resolve the current user", "read pagination from query params", and "figure out the current tenant" are not boundary concerns. They are request-scoped values the app wants to ask for.
Look at it that way, and a lot of middleware starts looking like composables in disguise.
Ask for the context when you need it
Here is the same handler in Wooks:
app.get('/projects', async () => {
const user = await useCurrentUser().requireUser()
const tenant = useTenant().current()
const pagination = usePagination().value()
const filters = useProjectFilters().value()
return listProjects({
user,
tenant,
pagination,
filters,
})
})
Dependencies are finally visible where they are used. The handler does not care whether useCurrentUser() reads a token, hits a cache, or loads from a database. It asks for what it needs. And if it never asks for body parsing, body parsing never happens.
The whole model: ask for what you need, when you need it.
h3 deserves credit here too — it explored the same problem space in parallel. h3 went with a dedicated event object; Wooks went with AsyncLocalStorage. Both escape the old req mutation model. The difference is that in Wooks, app code calls composables directly instead of threading event through every helper.
Write your own composables
The built-in ones — useBody(), useCookies(), useAuthorization(), useUrlParams() — are useful on their own.
But the real payoff is writing app-level composables on top of them.
Here is a realistic useCurrentUser():
import { cached, defineWook } from '@wooksjs/event-core'
import { useAuthorization } from '@wooksjs/event-http'
const userSlot = cached(async (ctx) => {
const { is, credentials } = useAuthorization(ctx)
if (!is('bearer')) return null
const token = credentials()
if (!token) return null
return findUserByToken(token)
})
export const useCurrentUser = defineWook((ctx) => ({
getUser: () => ctx.get(userSlot),
requireUser: async () => {
const user = await ctx.get(userSlot)
if (!user) throw new Error('Unauthorized')
return user
},
}))
Exactly the kind of logic that usually ends up in middleware. As a composable, it gains better properties:
- reusable across any handler
- request-scoped by default
- the expensive part is cached automatically
- invoked only when the handler actually needs it
Some composables are even simpler. Pagination, for example:
import { defineWook } from '@wooksjs/event-core'
import { useUrlParams } from '@wooksjs/event-http'
export const usePagination = defineWook((ctx) => {
const search = useUrlParams(ctx).params()
const page = Math.max(1, Number(search.get('page') ?? '1'))
const limit = Math.min(100, Math.max(1, Number(search.get('limit') ?? '20')))
return {
value: () => ({ page, limit }),
}
})
Just app logic living in the right place.
The beauty of lazy slots
Easy to miss if you only look at the handler API: what makes this pattern work is not defineWook() alone. It is the event core underneath — especially cached().
That userSlot above is not just a helper variable. It is a lazy, request-scoped slot:
- if nobody asks for the current user, nothing runs
- if three different layers ask for it, the lookup still runs once
- the slot belongs to the current event, not some global singleton
Lazy by design, not by convention.
And you can layer bigger logic on top:
import { cached, defineWook } from '@wooksjs/event-core'
const permissionsSlot = cached(async (ctx) => {
const user = await useCurrentUser(ctx).getUser()
if (!user) return []
return loadPermissions(user.id)
})
export const usePermissions = defineWook((ctx) => ({
list: () => ctx.get(permissionsSlot),
can: async (name: string) => {
const permissions = await ctx.get(permissionsSlot)
return permissions.includes(name)
},
}))
Not a performance trick glued on later — this is the default model. The permissions lookup stays lazy, shared, and request-scoped. And because it is just another composable, the handler code stays clean.
Reuse composables across layers
The same composable works from different layers without turning into a global singleton mess:
async function requireAdmin() {
const user = await useCurrentUser().requireUser()
if (user.role !== 'admin') throw new Error('Forbidden')
}
app.get('/admin/projects', async () => {
await requireAdmin()
const user = await useCurrentUser().requireUser()
const pagination = usePagination().value()
return listProjects({
ownerId: user.id,
pagination,
})
})
useCurrentUser() resolves against the same request context in both places. No two disconnected lookups just because two parts of the call stack asked for the same thing.
This is where composables stop being "nicer middleware" and become a genuinely better unit of backend reuse.
Composables are easier to test
This is where things get practical fast. A composable like usePagination() can be tested directly:
import { expect, it } from 'vitest'
import { prepareTestHttpContext } from '@wooksjs/event-http'
it('reads page and limit from query params', () => {
const run = prepareTestHttpContext({
url: '/projects?page=3&limit=50',
})
run(() => {
expect(usePagination().value()).toEqual({
page: 3,
limit: 50,
})
})
})
No fake middleware chain. No custom req type gymnastics. No "did middleware X already run before middleware Y?"
Just test the thing you wrote.
Not only HTTP
Once app logic lives in composables backed by lazy event-scoped slots, the HTTP adapter stops being the center of the design.
Current user, permissions, event ID, logging, derived context — these stop looking like "HTTP framework features" and start looking like event-scoped building blocks.
That is where Wooks opens up. The next question is no longer "how do I do this in an HTTP handler?" It becomes: what happens when the same pattern works for WebSocket handlers, CLI commands, and workflows too?
Read next: One pattern across events — Same composables, same context model — across HTTP, CLI, WebSocket, and workflows.



Top comments (0)