GraphQL
Contract
@modularityjs/graphql provides schema-first GraphQL: the application owns the SDL, and resolver classes bind methods to schema fields with decorators. The contract carries no GraphQL server library — @modularityjs/http-fastify-graphql mounts the executable endpoint via mercurius.
SDL fragments
Modules contribute SDL fragments to GraphqlTypeDefsPool (a value pool); the driver concatenates all fragments and builds one schema. Exactly one fragment defines the base type Query (and type Mutation if used); every other fragment uses extend type Query. Fragment order never matters — graphql-js applies extend type nodes document-wide.
const USERS_TYPE_DEFS = `
type Query {
user(id: ID!): User
}
type User {
id: ID!
name: String!
greeting: String!
}
`;
// A second feature module extends the root type:
const BILLING_TYPE_DEFS = `
extend type Query {
invoices: [String!]!
}
`;Resolvers
Resolver classes are ordinary DI classes: @Resolver() makes them injectable, method decorators map them to schema fields, and parameter decorators inject the operation inputs.
import { Inject } from '@modularityjs/di';
import {
Args,
Context,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@modularityjs/graphql';
import type { GraphqlContext } from '@modularityjs/graphql';
@Resolver('User') // type name required only when the class has @ResolveField methods
class UserResolver {
constructor(@Inject(UserService) private readonly users: UserService) {}
@Query('user') // maps to Query.user; @Query() defaults to the method name
user(@Args('id') id: string) {
return this.users.find(id);
}
@Mutation()
createUser(@Args('name') name: string, @Context() context: GraphqlContext) {
return this.users.create(name, context.identity?.id);
}
@ResolveField() // maps to User.greeting, receives the parent User
greeting(@Parent() user: { name: string }) {
return `Hello, ${user.name}!`;
}
}Plain fields served straight off the returned object (User.id, User.name) need no resolver — graphql default resolvers cover them. @ResolveField is for computed or lazily-loaded fields.
Register both contributions from the feature module:
@Module({
name: 'users',
imports: [GraphqlModule],
providers: [UserService, UserResolver],
pools: [
{ pool: GraphqlTypeDefsPool, key: 'users', useValue: USERS_TYPE_DEFS },
{ pool: GraphqlResolversPool, key: 'users', useClass: UserResolver },
],
})
class UsersModule {}Decorators
| Decorator | Target | Purpose |
|---|---|---|
@Resolver(typeName?) | class | Marks the class injectable; typeName names the object type for @ResolveField methods |
@Query(name?) | method | Maps to a field of Query (defaults to the method name) |
@Mutation(name?) | method | Maps to a field of Mutation |
@ResolveField(name?) | method | Maps to a field of the resolver's object type |
@Args(name?, schema?) | parameter | One named argument, or the whole arguments object; optional Schema<T> validation |
@Parent() | parameter | The parent value in a @ResolveField method |
@Context() | parameter | The per-operation GraphqlContext |
Configuration
GraphqlModule.forRoot({ ... }) configures GraphqlConfig:
| Field | Default | Purpose |
|---|---|---|
path | /graphql | Route the endpoint mounts on |
graphiql | on outside production | Serve the GraphiQL IDE at /graphiql |
introspection | true | Allow introspection queries (GraphiQL requires it) |
Config validation rejects graphiql: true with introspection: false at boot.
Boot-time validation
Wiring mistakes fail boot loudly — the driver validates every decorated method against the built schema in afterLoad:
- an empty
GraphqlTypeDefsPool, or SDL that fails to build (includingextend type Querywith no base definition); - a class registered in
GraphqlResolversPoolwithout@Resolver(); @ResolveFieldon a class whose@Resolver()names no type, or a type missing from the schema;- a
@Query/@Mutation/@ResolveFieldmethod mapping to a field the schema doesn't declare; - a schema
Query/Mutationfield with no resolver method; - two methods claiming the same field.
Every failure names the class, the method, and the fix.
Driver
@modularityjs/http-fastify-graphql mounts a mercurius endpoint on the shared Fastify instance in afterLoad (the same pattern as the MCP endpoint). mercurius and graphql-js are fully encapsulated — the app never imports either.
// modules: [
// ModularityModule,
// HttpModule.forRoot({ port: 3000 }),
// HttpFastifyModule, // required — the extension reuses the Fastify instance
// GraphqlModule, // or GraphqlModule.forRoot({ path: '/api/graphql' })
// HttpFastifyGraphqlModule,
// UsersModule,
// ]Errors
Resolver exceptions never leak internals:
| Thrown | Client sees |
|---|---|
ValidationException | extensions.code: 'VALIDATION_FAILED' + extensions.errors |
NotFoundException | extensions.code: 'NOT_FOUND' |
any other @modularityjs/exception | its stable extensions.code (+ extensions.context when set) |
anything else (Error, non-errors) | "Internal server error", extensions.code: 'INTERNAL_SERVER_ERROR' |
Masked errors are reported operator-side via process.emitWarning with type ModularityJsGraphqlResolverError.
Argument validation
@Args('input', schema) accepts a Schema<T> from @modularityjs/validation-schema — a zero-dependency primitive, so no validation driver needs to be wired. The value is parsed before the method runs; a failed parse surfaces as a VALIDATION_FAILED error with the parse errors in extensions.errors.
Authentication
When @modularityjs/http-auth is wired, its onRequest hook resolves the caller before the GraphQL handler runs, and @Context() exposes it as context.identity ({ id, attributes }). The GraphQL packages do not depend on http-auth — the identity is read structurally off the request.
Not yet supported
- Subscriptions —
@Query/@Mutation/@ResolveFieldonly; the metadata model reserves the kind for a future version. - Custom scalars / code-first schemas — the app never hands graphql-js objects across the package boundary in v1.
- The endpoint is mounted outside the
@Controllerpipeline, so it does not appear inHttpServer.getRoutes(), OpenAPI, or the dev console — same as the/mcpendpoint.