Before / after
Here's the same projects feature, written two ways: idiomatic plain Nitro + awilix (the
"before"), and with roost (the "after"). They boot and behave identically — same
401/403/200/400, same request-scoped tenant isolation. That parity is the point: roost is an
ergonomics layer, not a capability gap. The difference is the wiring you stop writing.
1. The route file
Every protected, validated route repeats the whole dance by hand. With roost it's a decorated method — roost generates the delegate for you:
import { defineEventHandler, createError } from 'h3'
import { asValue } from 'awilix'
import { ZodError } from 'zod'
import { container } from '../../container'
import { buildCtx } from '../../ctx'
import { memberGuard } from '../../guards'
import { withAudit } from '../../audit'
import { ProjectsService } from '../../projects/projects.service'
import { createProjectSchema } from '../../projects/dto'
export default defineEventHandler(async (event) => {
const ctx = await buildCtx(event)
memberGuard(ctx) // guard, by hand
try {
return await withAudit(ctx, () => {
// audit wrap, by hand
const body = createProjectSchema.parse(ctx.body) // validate, by hand
const scope = container.createScope() // request scope, by hand
scope.register({ requestContext: asValue({ tenantId: ctx.tenantId, user: ctx.user }) })
return scope.resolve<ProjectsService>('projectsService').create(body.name)
})
} catch (err) {
if (err instanceof ZodError) throw createError({ statusCode: 400, data: err.issues }) // map, by hand
throw err
}
})
// Zero imports: the decorators/types AND your own MemberGuard / ProjectsService /
// createProjectSchema are all auto-imported (roost's runtime + the feature dir, like server/utils).
@Controller() // path defaults to the feature folder → /projects
@UseGuards(MemberGuard)
export class ProjectsController {
constructor(private readonly projects: ProjectsService) {}
@Post()
// @Body(schema) injects the VALIDATED body as the argument (Nest-style). Bad input ->
// ZodError -> 400 via the built-in filter, before this method runs. `dto` is typed by the DTO.
create(@Body(createProjectSchema) dto: CreateProjectDto) {
return this.projects.create(dto.name)
}
}
The guard is class-level (inherited by every method), the audit interceptor is one global config
line, and ZodError → 400 is the built-in filter. Forget the guard / audit / error-map on a
hand-written route and it silently misbehaves; here they can't be forgotten.
2. The DI composition root
The exact same three providers — same lifetimes, same dependency graph — expressed as hand-written registrations you maintain vs. a decorator on the class that owns the scope:
import { createContainer, asFunction, Lifetime } from 'awilix'
import { ProjectStore } from './projects/projects.store'
import { ProjectsRepo } from './projects/projects.repo'
import { ProjectsService } from './projects/projects.service'
export const container = createContainer({ strict: true })
container.register({
projectStore: asFunction(() => new ProjectStore(), { lifetime: Lifetime.SINGLETON }),
projectsRepo: asFunction(
({ projectStore, requestContext }) => new ProjectsRepo(projectStore, requestContext),
{ lifetime: Lifetime.SCOPED },
),
projectsService: asFunction(({ projectsRepo }) => new ProjectsService(projectsRepo), {
lifetime: Lifetime.SCOPED,
}),
// a second feature → six more lines, by hand…
})
// No imports — Injectable / Scoped and the RequestContext type are auto-imported.
@Injectable() // SINGLETON
export class ProjectStore {}
@Scoped() // tenant-bound, per request (shorthand for @Injectable({ scope: 'scoped' }))
export class ProjectsRepo {
constructor(
private readonly store: ProjectStore,
private readonly ctx: RequestContext,
) {}
}
@Scoped()
export class ProjectsService {
constructor(private readonly repo: ProjectsRepo) {}
}
There's no composition root to maintain: the lifetime and the dependency graph live on the classes, and roost aggregates the container. 4 providers or 400, nobody hand-edits it.
The delta
| Hand-rolled | roost | |
|---|---|---|
| Route files you write | one per endpoint, ~15–25 lines of wiring each | 0 (generated) |
| DI container you maintain | one file, edited on every provider change | 0 (generated) |
| Imports per feature file | every dependency, by hand | none — decorators/types + your own pieces are auto-imported |
| Cross-cutting (guard/audit/error-map) | threaded by hand into every handler | decorators + globals |
| Add an endpoint | new route file + wire guard/audit/scope/validate | add one decorated method |
| Forget a guard / audit on a route? | silently public / un-audited | class-level + global, can't be missed |
| Runtime reflection | none | none (AST read at build time) |
| Behavior | ← identical → | identical |
The tell
Notice the repeated guard / scope / audit / error-map in every hand-written handler. The obvious
next step is to factor that shared dance into a runRequest(token, method, opts) helper — at
which point you've hand-written roost's route(), and the container is roost's generated DI
manifest. roost is what you converge on; it just generates it instead of asking you to maintain it.