Skip to content

Koa Adapter

@inferdi/koa is Koa v3 middleware. It creates one request scope, exposes it as ctx.state.di, and disposes it after the Node response finishes or closes.

Install

bash
pnpm add @inferdi/inferdi @inferdi/koa koa
pnpm add -D @types/koa
ts
import Koa from 'koa'
import { Container } from '@inferdi/inferdi'
import { inferdiKoa, type InferdiKoaState } from '@inferdi/koa'

Request Scope

ts
type RequestContext = {
  requestId: string
  userId?: string
  ip: string
}

class Users {
  constructor(readonly request: RequestContext) {}

  profile(id: string) {
    return { id, userId: this.request.userId }
  }
}

const root = new Container()
  .declareScopeInputs<{ request: RequestContext }>()
  .registerClass('users', Users, ['request'], 'scoped')
const openRequestScope = (request: RequestContext) =>
  root.createScope({ request })
type RequestScope = ReturnType<typeof openRequestScope>

declare module 'koa' {
  interface DefaultState {
    di: RequestScope
  }
}

const app = new Koa()

app.use(inferdiKoa({
  container: root,
  createScope: (_root, ctx) => openRequestScope({
    requestId: crypto.randomUUID(),
    userId: ctx.get('x-user-id') || undefined,
    ip: ctx.ip
  })
}))

app.use(async (ctx) => {
  const id = ctx.path.split('/').pop() ?? ''
  ctx.body = await ctx.state.di.get('users').profile(id)
})

Custom State Key

ts
import type { DefaultState, ParameterizedContext } from 'koa'
import { type InferdiKoaState } from '@inferdi/koa'

type AppState =
  & DefaultState
  & InferdiKoaState<RequestScope, 'container'>

type AppContext = ParameterizedContext<AppState>

app.use(inferdiKoa({
  container: root,
  key: 'container',
  createScope: (_root, ctx) => openRequestScope({
    requestId: crypto.randomUUID(),
    userId: ctx.get('x-user-id') || undefined,
    ip: ctx.ip
  })
}))

app.use(async (ctx: AppContext) => {
  ctx.body = await ctx.state.container.get('users').profile('42')
})

Options

OptionDefaultDescription
containerrequiredRoot container. Never disposed by this middleware.
key'di'Koa state key.
createScoperoot.createScope()Custom request scope creation.
setupScopenoneRuns after creation and before downstream middleware.
disposeScopescope.dispose()Custom disposal.
autoDisposetruefalse or predicate false transfers ownership.
onDisposeErrorctx.app.emit('error')Cleanup failure sink.

Streaming

Normal Koa stream bodies do not need a skip. The adapter waits for finish or close.

Use skipInferdiDispose(ctx) only when application code intentionally keeps the scope beyond the HTTP response boundary, such as background work:

ts
import { skipInferdiDispose } from '@inferdi/koa'

app.use(async (ctx) => {
  skipInferdiDispose(ctx)
  const scope = ctx.state.di

  queue.add(async () => {
    try {
      await scope.get('jobs').run()
    } finally {
      await scope.dispose()
    }
  })

  ctx.body = { status: 'queued' }
})

A downstream error always disposes the scope; successful skipped requests become application-owned.

A single skipInferdiDispose(ctx) call applies to every InferDI middleware instance on that request, including instances with different state keys. Application code must dispose every retained scope.