Skip to content

GraphQL Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the graphql-wiring recipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.

Schema-first: the app owns the SDL, resolver classes bind methods to schema fields. Two packages, same split as HTTP:

  • GraphqlModule (contract) — owns GraphqlTypeDefsPool (value pool of SDL fragments), GraphqlResolversPool (class pool), GraphqlConfig (path default /graphql, graphiql default on outside production, introspection default true). Decorators: @Resolver, @Query, @Mutation, @ResolveField, @Args, @Parent, @Context.
  • HttpFastifyGraphqlModule (extension) — composes the SDL fragments, validates every decorated resolver method against the built schema in afterLoad, and mounts a mercurius endpoint at GraphqlConfig.path. Requires HttpFastifyModule (it reaches into the same Fastify instance).

Exactly one SDL 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.

Unlike the WebSocket pool, wiring mistakes fail boot loudly: a pooled class without @Resolver(), a @Query() method mapping to a field the schema doesn't declare, a schema Query/Mutation field with no resolver method, or two methods claiming the same field all abort startup with a message naming the class, the method, and the fix. Plain object-type fields (e.g. User.name served straight off the returned object) need no resolver — graphql default resolvers cover them; @ResolveField is for computed/lazy fields.

typescript
import { Inject, Injectable } from '@modularityjs/di';
import { NotFoundException } from '@modularityjs/exception';
import type { GraphqlContext } from '@modularityjs/graphql';
import {
  Args,
  Context,
  GraphqlModule,
  GraphqlResolversPool,
  GraphqlTypeDefsPool,
  Mutation,
  Parent,
  Query,
  ResolveField,
  Resolver,
} from '@modularityjs/graphql';
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { HttpFastifyGraphqlModule } from '@modularityjs/http-fastify-graphql';
import { Module } from '@modularityjs/modularity';

interface User {
  id: string;
  name: string;
}

const USERS_TYPE_DEFS = `
type Query {
  user(id: ID!): User
}

type Mutation {
  createUser(name: String!): User!
}

type User {
  id: ID!
  name: String!
  greeting: String!
}
`;

@Injectable()
class UserService {
  private readonly users = new Map<string, User>([
    ['1', { id: '1', name: 'Ada' }],
  ]);

  find(id: string): User {
    const user = this.users.get(id);
    if (!user) throw new NotFoundException(`User "${id}" not found.`);
    return user;
  }

  create(name: string): User {
    const user = { id: String(this.users.size + 1), name };
    this.users.set(user.id, user);
    return user;
  }
}

@Resolver('User') // type name required only because of @ResolveField below
class UserResolver {
  constructor(@Inject(UserService) private readonly users: UserService) {}

  @Query('user') // maps to Query.user; @Query() would default to the method name
  user(@Args('id') id: string): User {
    return this.users.find(id); // NotFoundException -> errors[0].extensions.code === 'NOT_FOUND'
  }

  @Mutation()
  createUser(
    @Args('name') name: string,
    @Context() context: GraphqlContext,
  ): User {
    void context.identity; // identity populated by http-auth when wired, else undefined
    return this.users.create(name);
  }

  @ResolveField() // maps to User.greeting, receives the parent User
  greeting(@Parent() user: User): string {
    return `Hello, ${user.name}!`;
  }
}

@Module({
  name: 'users',
  imports: [GraphqlModule],
  providers: [UserService, UserResolver],
  pools: [
    { pool: GraphqlTypeDefsPool, key: 'users', useValue: USERS_TYPE_DEFS }, // value pool
    { pool: GraphqlResolversPool, key: 'users', useClass: UserResolver }, // class pool
  ],
})
class UsersModule {}

// A second feature module contributes its slice with `extend`:
// { pool: GraphqlTypeDefsPool, key: 'billing', useValue: 'extend type Query { invoices: [String!]! }' }

// modules: [
//   ModularityModule,
//   HttpModule.forRoot({ port: 3000 }),
//   HttpFastifyModule,                    // required — the GraphQL extension reuses the Fastify instance
//   GraphqlModule,                        // or GraphqlModule.forRoot({ path: '/api/graphql', graphiql: false })
//   HttpFastifyGraphqlModule,
//   UsersModule,
// ]

Resolver exceptions never leak internals: a thrown @modularityjs/exception subclass surfaces as a GraphQL error carrying its stable extensions.code (plus errors for ValidationException, context when set); any other throw is masked as "Internal server error" / INTERNAL_SERVER_ERROR and reported via process.emitWarning (ModularityJsGraphqlResolverError).

@Args('input', schema) accepts an optional Schema<T> from @modularityjs/validation-schema (a primitive — no module wiring needed): the value is parsed before the method runs, and a failed parse becomes a VALIDATION_FAILED error with extensions.errors. @Args() without a name injects the whole arguments object.

When http-auth is wired, its onRequest hook runs before the GraphQL handler, so @Context() exposes the caller as context.identity ({ id, attributes }) with zero extra configuration — the GraphQL packages do not depend on http-auth.

GraphiQL is served at /graphiql when GraphqlConfig.graphiql is on (default: only outside production) and requires introspection (config validation rejects the combination graphiql: true, introspection: false). The endpoint is mounted outside the @Controller pipeline — it does not appear in HttpServer.getRoutes() or OpenAPI, same as the /mcp endpoint. Subscriptions are not supported yet — @Query/@Mutation/@ResolveField only.