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
const ctx: ServiceContext = { userId: "3f0c…" }; // readonly userId, nothing elseToday 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.
| Consumer | How the context is built |
|---|---|
apps/api | The 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/admin | The 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. |
| Tests | A 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
- Service-layer authorization. With
ctx.userIdavailable, an id-scoped mutation can check ownership itself and throwAccessDeniedError: the only guard, since every app reads and writes on the owner pool. Every mutation ofDealCollectionsServicedoes so through its internalloadOwnedhelper, which loads the row, throwsNotFoundErrorwhen it is missing andAccessDeniedErrorwhencollection.userId !== ctx.userId.BoardServicemutations look the boards up among the caller's own the same way, and its reads are scoped to the boardsctx.userIdmay read.SharingGroupsServicechecks that the caller owns the group before any member read or group change, and refuses an owner leaving their own group.GroupSessionsServicereads a session on the owner of its group (ownerId) before acting on it, and throwsAccessDeniedErrorwhen the session, its group or a linked collection belongs to another account.GroupsServiceandStudentsServicecount the caller's row before acting on an id, and throwAccessDeniedErrorwhen the row exists for another account.ThemesServiceloads a theme with its owner before changing it, and throwsAccessDeniedErrorfor a system theme or another account's. - Audit and observability. "User X archived collection Y" can be logged inside the service; a future trace identifier propagates with no call-site churn.
- 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.
// 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)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:
// 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
userIdas data: aWHEREfilter, the owner of anINSERT.GroupQuery.empty().byOwner(userId)on a read and theownerIdof aPersistableGroupWriteare 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
ctxto one of them without migrating the whole module.