Bridge Training

Service Context

The ctx argument every user-scoped service method takes last, what it carries, why it is required on id-scoped methods and where it does not go.

Every method of a user-scoped service in packages/training/src/application/<module> takes ctx: ServiceContext as its last argument, whether the method is user-scoped (list) or id-scoped (getById, update). The type is exported by the package root.

Prop

Type

service-context.ts
const ctx: ServiceContext = { userId: "3f0c…" }; // readonly userId, nothing else

Today the services following the rule are BoardService, DealCollectionsService, GroupsService, GroupSessionsService, SharingGroupsService, StudentsService and ThemesService. Services that are not user-scoped (public player reads, billing, webhook ingestion, catalogue lookups, admin services) take a userId as a plain parameter where they need one, or nothing at all. Migrating a module's remaining services is done module by module.

What the context carries

Only execution metadata: userId today, room for a trace or request identifier tomorrow. It never carries authorization: no role, isAdmin, onBehalfOf or permissions. The source states the contract in one sentence: authorization is decided by the consuming app before the call and never reaches the domain.

ConsumerHow the context is built
apps/apiThe require-auth middleware names the effective identity, the impersonation target when a valid token is presented, and the authenticated middleware resolves it to its account in c.var.userId. Handlers call getCtx(c), which returns { userId: c.var.userId }, and pass it through.
apps/adminThe auth gate resolves the acting account. Admin surfaces call services that take the target userId as data; when an admin acts on a user through a ctx-taking service, the gate resolves the final target first and the service receives { userId }. The domain never sees the caller-versus-target split.
TestsA literal { userId } built from the fixture owner.

userId is server-derived, never client-supplied. A handler never builds a context from a request body or a query parameter.

Why ctx even on id-scoped methods

  1. Service-layer authorization. With ctx.userId available, an id-scoped mutation can check ownership itself and throw AccessDeniedError: the only guard, since every app reads and writes on the owner pool. Every mutation of DealCollectionsService does so through its internal loadOwned helper, which loads the row, throws NotFoundError when it is missing and AccessDeniedError when collection.userId !== ctx.userId. BoardService mutations look the boards up among the caller's own the same way, and its reads are scoped to the boards ctx.userId may read. SharingGroupsService checks that the caller owns the group before any member read or group change, and refuses an owner leaving their own group. GroupSessionsService reads a session on the owner of its group (ownerId) before acting on it, and throws AccessDeniedError when the session, its group or a linked collection belongs to another account. GroupsService and StudentsService count the caller's row before acting on an id, and throw AccessDeniedError when the row exists for another account. ThemesService loads a theme with its owner before changing it, and throws AccessDeniedError for a system theme or another account's.
  2. Audit and observability. "User X archived collection Y" can be logged inside the service; a future trace identifier propagates with no call-site churn.
  3. No micro-decision per method. Every method of a user-scoped service takes ctx, last. Nobody decides per PR whether this one needs it.

Shapes

The context is always last and always required. A parameter before it that used to be optional is typed T | undefined and passed explicitly, as list(undefined, ctx) shows.

service-context.ts
// Every method of a user-scoped service: inputs first, ctx last.
type Shape = Pick<DealCollectionsService, "list" | "toggleArchive" | "create">;
// list(includeArchived: boolean | undefined, ctx: ServiceContext)
// toggleArchive(collectionId: string, ctx: ServiceContext)
// create(dto: CreateDealCollectionDTO, ctx: ServiceContext)
service-context.ts
export async function archiveCollection(
  ports: Pick<
    DealCollectionsServiceDeps,
    "collections" | "collectionsRead" | "collectionBoards" | "uow"
  >,
  boardsRead: BoardReadRepository,
  overflow: StockOverflowResolver,
  collectionId: string,
  ctx: ServiceContext
) {
  const collections = createDealCollectionsService({
    ...ports,
    boardsRead,
    overflow,
    entitlements: allowAllEntitlements,
  });
  // A parameter before ctx that used to be optional is passed explicitly.
  const all = await collections.list(undefined, ctx);
  const archived = await collections.toggleArchive(collectionId, ctx);
  return { count: all.length, archived };
}

An id-scoped call keeps the same shape:

service-context.ts
// Id-scoped too: ctx lets the service check ownership itself.
export function removeStudent(
  deps: StudentsServiceDeps,
  studentId: number,
  ctx: ServiceContext
) {
  return createStudentsService(deps).remove(studentId, ctx);
}

In an apps/api handler the same call reads getApi(c).dealCollectionsService.create(body, getCtx(c)).

Where ctx does not go

  • Pure domain functions (computeBoardDerivedFields, assertBoardsAccessible, assertNoDuplicateBoards, …). They are not operations and carry no execution context.
  • Repositories and other ports. They take userId as data: a WHERE filter, the owner of an INSERT. GroupQuery.empty().byOwner(userId) on a read and the ownerId of a PersistableGroupWrite are the pattern. A tracing slot at that layer is a separate decision.
  • Services outside the rule. Public reads, billing, events and telemetry ingestion, entitlements and the admin services take explicit parameters. Do not add a ctx to one of them without migrating the whole module.