Skip to content

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.

typescript
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.

typescript
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:

typescript
@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

DecoratorTargetPurpose
@Resolver(typeName?)classMarks the class injectable; typeName names the object type for @ResolveField methods
@Query(name?)methodMaps to a field of Query (defaults to the method name)
@Mutation(name?)methodMaps to a field of Mutation
@ResolveField(name?)methodMaps to a field of the resolver's object type
@Args(name?, schema?)parameterOne named argument, or the whole arguments object; optional Schema<T> validation
@Parent()parameterThe parent value in a @ResolveField method
@Context()parameterThe per-operation GraphqlContext

Configuration

GraphqlModule.forRoot({ ... }) configures GraphqlConfig:

FieldDefaultPurpose
path/graphqlRoute the endpoint mounts on
graphiqlon outside productionServe the GraphiQL IDE at /graphiql
introspectiontrueAllow 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 (including extend type Query with no base definition);
  • a class registered in GraphqlResolversPool without @Resolver();
  • @ResolveField on a class whose @Resolver() names no type, or a type missing from the schema;
  • a @Query / @Mutation / @ResolveField method mapping to a field the schema doesn't declare;
  • a schema Query / Mutation field 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.

typescript
// 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:

ThrownClient sees
ValidationExceptionextensions.code: 'VALIDATION_FAILED' + extensions.errors
NotFoundExceptionextensions.code: 'NOT_FOUND'
any other @modularityjs/exceptionits 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 / @ResolveField only; 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 @Controller pipeline, so it does not appear in HttpServer.getRoutes(), OpenAPI, or the dev console — same as the /mcp endpoint.