Skip to content
result-rpc
Esc
navigateopen⌘Jpreview
On this page

Services and request context

Two kinds of context: a process-lifetime service graph and request middleware composed by requirement.

A procedure sees one context, but two different things feed it, with different lifetimes and failure rules:

Services Request middleware
Examples database pool, worker bindings, API clients session, viewer, organization
Lifetime process request
Shape dependency graph ordered chain
Can fail with a wire error no — a broken service is a broken process yes — failures join the operation union
Owned by defineService / resolveServices middleware

Services

If you have read about Effect: this is its service/dependency-injection idea at its useful core — declare what each resource needs, resolve the graph once, memoize by identity — without fibers, without Effect.gen, without a runtime. It is a small feature, not a religion.

import { defineService, resolveServices } from "result-rpc"

const Db = defineService("db", {
  create: () => createPool(env.DATABASE_URL),
})

const Mailer = defineService("mailer", {
  needs: { db: Db },
  create: ({ db }) => createMailer(db),
})

const services = await resolveServices({ db: Db, mailer: Mailer })

The graph is resolved once at process start and memoized by definition reference — a service two others depend on is constructed exactly once. The sharp edge, stated plainly: identity is by reference, so store definitions in module constants; two defineService calls are two services.

The resolved record becomes the root context that every request closes over:

export const handleRpc = createFetchHandler({
  router: appRouter,
  createContext: ({ request }) => ({ ...services, request }),
})

Nothing pulls services per call — the auth middleware reads context.db because the root context guarantees it, and swapping the whole record for a test double is one argument to createContext.

Middleware composes by requirement, not by ordering

The footgun: middleware order as tribal knowledge — session must run before requireViewer, enforced by a comment. Here a middleware declares what it runs after; the dependency’s output becomes its input, the dependency’s errors join the union, and any .use() site pulls the whole chain in dependency order:

const session = app.middleware<{ viewer: User | null }>()
  .use(async ({ context, next }) =>
    next({ context: { ...context, viewer: await userFromCookie(context) } }))

const requireViewer = app.middleware<{ viewer: User }>()
  .after(session)                        // handler sees viewer: User | null
  .errors({ Unauthorized })
  .use(({ context, errors, next }) =>
    context.viewer === null
      ? err(errors.Unauthorized({}))
      : next({ context: { ...context, viewer: context.viewer } }))

A mutation then demands exactly one thing:

export const renameDoc = app.procedure()
  .input(RenameInput)
  .output(DocCodec)
  .use(requireViewer)                    // session comes along, in order
  .mutation(({ context, input }) =>      // context.viewer: User
    context.db.docs.rename(input, context.viewer))

.use(session) followed by .use(requireViewer) still runs session once — composition is deduplicated by reference identity, the same rule as services (module constants, not inline builds). A middleware whose input demands context the procedure cannot supply is a type error, so requirements are checked, not hoped for.

Was this page helpful?