GraphQL
Object-oriented, decorator-driven GraphQL for @di-framework/core. Your domain classes are the schema — there is no SDL document to keep in sync, no resolver map, and no field-by-field mapping layer. Decorators declare semantic exposure (@Field, @Action), ownership (@BoundedContext), and boundaries (@SemanticType({ boundary: true })), and the schema falls out of that.
Features
Objects, not resolvers: Behaviour lives on the class that owns the invariant.
@Actionon an entity becomes a root mutation that loads the entity first.Bounded contexts enforced: Cross-context references require explicit boundary types. Violations fail at build time.
Extend across the seam:
@Extendslets one context contribute fields to another's boundary type.Hydration: Plain repository rows are re-hydrated onto their class before resolution, so method fields keep working.
Built-in batching:
@Field({ batch })coalesces work across parents in the same tick — no DataLoader dependency.Subscriptions from the container: Pair with core
@Publisher; the service never learns GraphQL exists.SDL as an artifact: Print portable SDL from
@di-framework/graphql/corewithout importinggraphql.DI throughout: Portals and extensions are container-managed like any other component.
Installation
graphql is an optional peer dependency: you only need it to execute queries. Importing from @di-framework/graphql/core (decorators, registry, type graph, SDL printer) works without it.
Decorators need TypeScript 5 and experimentalDecorators. emitDecoratorMetadata is not required — types are declared with runtime markers.
Quick Start
api.sdl is the schema as SDL, api.schema is an executable graphql-js schema, and api.graph is the resolved semantic graph you can assert against.
Core Concepts
Semantic types
@SemanticType declares a class as a type in the schema. Use expose for constructor parameter properties (which cannot carry their own decorators). A type with a key always exposes it; boundary: true requires a key so other contexts can re-identify the object.
Portals
A portal is a root object registered with the DI container:
@Field→ Query fields@Action→ Mutation fields@Subscription→ Subscription fields
Portals cannot be used as field types. If no portal declares a query field, the schema synthesizes _contexts: [String!]! so it stays valid.
Entity actions
An @Action on a semantic type (not a portal) becomes a root mutation named <type><Method>, with an implicit key argument. The entity is loaded through @Lookup before the method runs.
That yields loanCheckIn(id: ID!): Loan!.
Bounded contexts and boundaries
@BoundedContext('Name') records who owns a class. By default (enforceBoundaries: true) a context may only reference or extend another context's types when those types declare boundary: true — otherwise SemanticBoundaryError is thrown at build time.
Build a subset of contexts for a deployment seam:
Extending another context's type
@Extends contributes fields to a boundary type owned elsewhere. The extension class is DI-managed and receives the parent through @Parent().
Batching
@Field({ batch }) takes:
Value | Meaning |
|---|---|
| De-duplicate and memoize per (parent, args) for the request. |
| Method with signature |
function | Same signature, inline. |
Batching is request-scoped; pass a fresh context per request (execute() defaults to {}).
Input objects
@InputType classes are rebuilt from the plain values GraphQL hands the resolver, so their methods are callable. An input type must declare at least one @Field.
Subscriptions
@Subscription reads the container's event bus (what core @Publisher writes to). Subscriptions may only be declared on portals.
Use api.subscribe(...) to get an AsyncIterableIterator of results.
Enums and scalars
Types are runtime markers — no reflect-metadata:
String, Number, Boolean, and Date also work and map to String, Float, Boolean, and DateTime.
Request context
Decorator | Injects |
|---|---|
| Per-request context |
| Parent object ( |
| GraphQL resolve info |
| GraphQL argument |
An undecorated parameter named ctx, context, _ctx, or _context is treated as the context; one named info as resolve info.
Building and Serving
Member | Purpose |
|---|---|
| Resolved, validated semantic graph |
| Executable |
| Same graph as SDL |
| Run a query/mutation |
| Async iterable of subscription results |
GET reads query/variables/operationName from the query string, POST from a JSON body. Subscriptions need a connection — drive api.subscribe() from a WebSocket or SSE endpoint. Prefer createGraphqlTransportWs from @di-framework/socket/graphql (see Sockets); the GraphQL example uses this helper.
SDL as a Build Artifact
@di-framework/graphql/core never imports graphql, so emitting the schema is cheap enough for CI:
With directives: true, ownership is recorded (@key, @context), which makes the artifact worth diffing in review. buildTypeGraph() also throws if a context has reached somewhere it should not.
Apollo Federation
A boundary type already declares the two things federation needs from an entity: a key that identifies it, and a @Lookup that turns that key back into an object. Turning a schema into a subgraph is therefore a flag, not a second set of annotations:
That adds _entities(representations: [_Any!]!) and _service { sdl } to Query, prints the federation @link header, declares _Any/_FieldSet/_Service/_Entity, and gives every boundary type a real @key. _entities resolves each representation through that type's @Lookup and hydrates the result, so the entity's own methods work on the way back out.
Two directive modes, deliberately separate
print.directives and federation both describe ownership, but they are not the same output and should not be mixed up:
|
| |
|---|---|---|
Audience | humans, in review | an Apollo gateway |
Vocabulary | this package's | Apollo Federation v2 |
Declares | yes — which context owns each type and field | no; federation has no such concept |
Adds | no | yes |
Portable SDL | yes, once the two directives are declared | only to a federation-aware gateway |
Reach for directives when the artifact exists so a reviewer can see a boundary move. Reach for federation when a gateway is going to compose the result.
Types this subgraph does not own are printed as stubs: the key field only, marked @external, plus whatever this subgraph contributes via @Extends.
Deployable Subgraphs
contexts: [...] slices a schema. What makes a slice deployable is that it still resolves references across the seam — through boundary stubs:
For SDL artifacts, buildContextSubgraphs() does the same on the graphql-free path:
What crosses the slice
A context sees another context's type only if that type is a boundary type, and then only as a stub. The rules are:
Stays inside the owning slice | Crosses as a stub | |
|---|---|---|
Non-boundary type | ✅ never leaves | — |
Boundary type's key | — | ✅ the whole contract |
Boundary type's other fields | ✅ owner only | ❌ |
| ✅ owner only | ❌ |
Fields added with | — | ✅ they belong to the extending context |
Portals | ✅ owner only | ❌ |
So Book in the Lending subgraph is { id, onLoan }: the key it is identified by, plus the field Lending itself contributed. title and bookReshelve belong to Catalog and never appear. Two services independently generated from the same domain therefore agree on the shared type by construction.
Stubs nothing references are pruned, so a slice does not carry types it never mentions. Set boundaryStubs: false to get the strict old behaviour, where a cross-context reference is a build error instead.
API Surface
Type decorators — @SemanticType, @Portal, @InputType, @BoundedContext, @Extends, registerEnum
Member decorators — @Field, @Action, @Subscription, @Lookup (static)
Parameter decorators — @Arg, @Ctx, @Parent, @Info
Scalars — ID, Int, Float, Str, Bool, DateTime, Json
Schema — buildSemanticSchema, createGraphQLHandler
Core (no graphql) — buildTypeGraph, printSDL, SemanticRegistry, getRegistry, setRegistry, SemanticSchemaError, SemanticBoundaryError
Fields and list items are non-null by default. A bare @Field() is assumed to be String unless you build with strictTypes: true.
Common Pitfalls
Types are runtime markers. Use
@Field(() => Int), never a TypeScript annotation alone.Pass argument names explicitly.
@Arg('id', () => ID)— names are parsed from the source and minification can rename them.Decorators write to the current registry at import time. Use
setRegistry()/buildSemanticSchema({ registry })when a process builds more than one schema (e.g. tests).Enums and classes are referenced through thunks.
() => Genre, notGenre.
Example
A three-context worked example — batching, boundaries, entity actions, subscriptions, and GraphiQL — lives in the graphql example package.