docs/content/reference/subscription-context.md
By default, a subscription resolver returns a plain channel:
func (r *subscriptionResolver) MessageAdded(ctx context.Context, room string) (<-chan *Message, error) {
ch := make(chan *Message, 1)
// populate ch from somewhere
return ch, nil
}
AroundResponses interceptors observe the subscription's request context for every payload — that is, the context that existed when the subscription started. There is no per-event surface.
@subscriptionContext is an opt-in schema directive that changes this for one field at a time. When a subscription field is annotated, the resolver returns <-chan graphql.Event[T] instead of <-chan T, and each Event carries its own context. The graphql executor threads that context into the ctx parameter that AroundResponses interceptors already receive — no new field on graphql.Response, no new interceptor signature.
Add the directive to your schema and to the schema's directive declarations:
directive @subscriptionContext on FIELD_DEFINITION
type Subscription {
messageAdded(room: String!): Message! @subscriptionContext
presenceChanged: Presence!
}
messageAdded gets the per-event treatment; presenceChanged keeps the existing shape. The opt-in is per field — other subscriptions in the same project remain unchanged.
Run gqlgen generate and the resolver interface for the marked field becomes:
type SubscriptionResolver interface {
MessageAdded(ctx context.Context, room string) (<-chan graphql.Event[*Message], error)
PresenceChanged(ctx context.Context) (<-chan *Presence, error)
}
To opt every subscription field into the same behavior without annotating each
field, set subscription_context_field: true in gqlgen.yml:
subscription_context_field: true
This is a project-wide shortcut over the same implementation used by
@subscriptionContext. It does not introduce a second runtime API: generated
subscription resolvers still return <-chan graphql.Event[T], and the event
context still flows through the ctx parameter received by AroundResponses
interceptors.
For example, with subscription_context_field: true, both fields below use
the event-context-aware resolver shape, even without field annotations:
type Subscription {
messageAdded(room: String!): Message!
presenceChanged: Presence!
}
type SubscriptionResolver interface {
MessageAdded(ctx context.Context, room string) (<-chan graphql.Event[*Message], error)
PresenceChanged(ctx context.Context) (<-chan graphql.Event[*Presence], error)
}
Use the directive when only some subscription fields need per-event context. Use the global config when all subscriptions in the schema should expose the same capability.
graphql.Event[T] is a plain struct with exported fields:
type Event[T any] struct {
Context context.Context
Value T
}
Build one per published event:
func (r *subscriptionResolver) MessageAdded(
ctx context.Context, room string,
) (<-chan graphql.Event[*Message], error) {
ch := make(chan graphql.Event[*Message], 1)
go func() {
defer close(ch)
for msg := range r.events {
eventCtx, span := tracer.Start(ctx, "subscription.event")
ch <- graphql.Event[*Message]{Context: eventCtx, Value: msg}
span.End()
}
}()
return ch, nil
}
Event.Context must be derived from the subscription request context (typically via context.WithValue or context.WithCancel). Replacing it with an unrelated context.Background() loses request-scoped values such as the authenticated user and trace IDs. The runtime does not enforce this; it is your contract with the engine.Event.Context is nil, the engine falls back to the subscription request context for that event. Set it explicitly to avoid surprises.The interceptor signature is unchanged:
srv.AroundResponses(func(ctx context.Context, next graphql.ResponseHandler) *graphql.Response {
// ctx is the per-event context that the resolver attached to this event.
// For unmarked subscriptions and for queries/mutations, ctx is the
// operation's request context.
span := trace.SpanFromContext(ctx)
// ... use span ...
return next(ctx)
})
The per-event context flows through ctx — the parameter the interceptor already has.
When a subscription field is marked, the resolver runs before the AroundResponses chain for each event. As a result:
ctx via next(ctx2). Subsequent links in the AroundResponses chain see ctx2.AroundFields or AroundRootFields.next(ctx2) does affect resolver context.Default <-chan T | Per-field @subscriptionContext | Global subscription_context_field: true | |
|---|---|---|---|
| Resolver return type | <-chan T | <-chan graphql.Event[T] | <-chan graphql.Event[T] for every subscription |
| AroundResponses ctx | Subscription request ctx | Per-event ctx attached by the resolver | Per-event ctx attached by the resolver |
| Schema opt-in | none | @subscriptionContext on each field | none required |
| Project-wide config | none | none | subscription_context_field: true |
| Generated code for unmarked fields | byte-identical | byte-identical | all subscription fields use the event-aware shape |
| Per-event tracing / metadata | not available | available for marked fields | available for all subscription fields |
@subscriptionContext does not currently compose with the SUBSCRIPTION-location directive middleware. A field cannot be both @subscriptionContext-marked AND have a custom @directive on SUBSCRIPTION in the same operation; the runtime will use the marked-field path and skip the SUBSCRIPTION middleware.Event.Context must derive from the subscription request context is documentation, not enforcement. The runtime cannot inspect the parent chain of an arbitrary context.Context.