graphql · git:20260610.47ec162 · 2026-06-10 · sha256 46826e0459856ba7
graphql git:20260610.47ec162A
Immutable. This exact content never changes and is served at /api/v1/blob/46826e0459856ba7.
---
name: graphql
description: How to use graphql with Nestjs and Typescript on a code-first approach
---
# GraphQL Skill
## Scope
Apply this skill for any task involving GraphQL schema design, resolver implementation, performance patterns, authorization, or testing. This skill assumes the implementation stack is **NestJS + TypeScript** using the `@nestjs/graphql` module with `ApolloDriver`.
**The code-first approach is mandatory.** The GraphQL schema is always generated from TypeScript code — decorators on classes and fields drive the schema. Never write `.graphql` SDL files by hand. Never use the schema-first approach. The source of truth is the TypeScript code; the generated schema file is an output artifact, not an input.
For federation-specific concerns (subgraphs, entities, `@key`, `@extends`, `@requires`, Apollo Router), load `agentic/skills/graphql-federation/SKILL.md` in addition to this file.
---
## Code-First Approach
### What Code-First Means
In code-first, the GraphQL schema is an **artifact generated from TypeScript classes and decorators** — not a document you author and maintain separately. NestJS reads the decorator metadata at startup and emits a valid `schema.graphql` file automatically.
This keeps a single source of truth: the TypeScript code. Types, fields, nullability, descriptions, and deprecations are all declared once in code and reflected in the schema. You never have to keep a `.graphql` file and a TypeScript class in sync.
### Module Configuration
```typescript
// graphql.module.ts
GraphQLModule.forRoot<ApolloFederationDriverConfig>({
driver: ApolloFederationDriver,
autoSchemaFile: {
path: join(process.cwd(), 'src/schema.gql'), // generated — do not edit manually
federation: 2,
},
sortSchema: true, // deterministic output — avoids noisy diffs in version control
buildSchemaOptions: {
dateScalarMode: 'timestamp', // consistent DateTime serialization
},
}),
```
The generated `schema.gql` file must be committed to version control so that schema diffs are visible in pull requests. It must never be edited by hand — any manual edit will be overwritten on the next application start.
Add a CI check that regenerates the schema and fails if the committed file differs from the generated output:
```bash
# In CI — verifies the committed schema matches the code
npx ts-node src/generate-schema.ts
git diff --exit-code src/schema.gql
```
### The Decorator Vocabulary
Every GraphQL construct has a corresponding NestJS decorator. Use the right decorator for the right purpose — never approximate with a close-enough one.
| Construct | Decorator | Usage |
|---|---|---|
| Output type | `@ObjectType()` | Classes returned from resolvers |
| Input type | `@InputType()` | Classes received as mutation/query arguments |
| Enum | `registerEnumType()` | TypeScript enums exposed in the schema |
| Interface | `@InterfaceType()` | Abstract types implemented by multiple object types |
| Union | `createUnionType()` | Result types with multiple possible shapes |
| Field on a type | `@Field()` | Properties on `@ObjectType` or `@InputType` classes |
| Query | `@Query()` | Read operations on a `@Resolver` class |
| Mutation | `@Mutation()` | Write operations on a `@Resolver` class |
| Subscription | `@Subscription()` | Real-time operations on a `@Resolver` class |
| Field resolver | `@ResolveField()` | Computed or related fields resolved separately |
| Resolver arg | `@Args()` | Individual arguments on a query, mutation, or field resolver |
| Parent object | `@Parent()` | The parent object in a `@ResolveField` |
| Context | `@Context()` | The GraphQL context object (use sparingly — prefer `@CurrentUser()`) |
### Declaring Types
**Object types** — every field must have an explicit `@Field()` decorator with an explicit type function. Never rely on type inference for the schema — always declare the type explicitly:
```typescript
@ObjectType({ description: 'Represents a finalized customer invoice.' })
export class Invoice {
@Field(() => ID, { description: 'Unique identifier for the invoice.' })
id: string;
@Field(() => String, { description: 'Human-readable invoice reference number.' })
invoiceNumber: string;
@Field(() => Int, { description: 'Invoice total in the smallest currency unit (e.g. cents).' })
totalAmountInCents: number;
@Field(() => InvoiceStatus)
status: InvoiceStatus;
@Field(() => Date)
createdAt: Date;
// Nullable field — explicitly documented why
@Field(() => Date, {
nullable: true,
description: 'The date this invoice was paid. Null if payment has not been received.',
})
paidAt: Date | null;
}
```
**The `() => Type` arrow function syntax is mandatory** — do not use the shorthand `@Field()` without a type function, as TypeScript metadata reflection is unreliable for complex types, arrays, and nullable values:
```typescript
// Wrong — relies on metadata reflection, breaks for arrays and nullable types
@Field()
invoiceNumber: string;
// Correct — explicit, reliable, always generates the expected schema type
@Field(() => String)
invoiceNumber: string;
// Correct — arrays require the wrapper syntax
@Field(() => [LineItem])
lineItems: LineItem[];
// Correct — nullable fields declare their base type inside the arrow function
@Field(() => Date, { nullable: true })
paidAt: Date | null;
```
**Input types** — mirror the object type pattern but use `@InputType()`. Input types and object types are never the same class — they have different validation, nullability, and shape requirements:
```typescript
@InputType()
export class CreateInvoiceInput {
@Field(() => ID)
@IsUUID()
customerId: string;
@Field(() => [CreateLineItemInput])
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateLineItemInput)
lineItems: CreateLineItemInput[];
}
```
**Descriptions are mandatory on all public types and fields.** The generated schema is consumed by client teams and tooling — undescribed fields force readers to trace the implementation to understand intent:
```typescript
// Wrong — no description
@Field(() => Int)
totalAmountInCents: number;
// Correct
@Field(() => Int, {
description: 'Invoice total expressed in the smallest currency unit. Divide by 100 for display in major units.',
})
totalAmountInCents: number;
```
### Deprecating Fields
Use the `deprecationReason` option to mark fields for removal — this propagates into the generated schema and is surfaced by client tooling:
```typescript
@Field(() => Float, {
deprecationReason: 'Use totalAmountInCents instead. Will be removed in API version 3.',
})
totalAmount: number;
```
### Custom Scalars
Register custom scalars for types that have no native GraphQL equivalent. Define them once and reference them consistently:
```typescript
// scalars/date.scalar.ts
@Scalar('Date', () => Date)
export class DateScalar implements CustomScalar<number, Date> {
description = 'A UTC timestamp represented as milliseconds since Unix epoch.';
parseValue(value: number): Date {
return new Date(value);
}
serialize(value: Date): number {
return value.getTime();
}
parseLiteral(ast: ValueNode): Date {
if (ast.kind === Kind.INT) {
return new Date(parseInt(ast.value, 10));
}
throw new Error('Date scalar expects an integer timestamp.');
}
}
```
Register in the module and reference in types:
```typescript
@Field(() => Date) // Uses the registered DateScalar
createdAt: Date;
```
---
## Schema Design
The schema is a public contract. Every type, field, and argument you expose is a promise to every consumer. Design it deliberately — schemas are painful to change once clients depend on them.
### Naming Conventions
- Types are `PascalCase` nouns that represent domain concepts: `Invoice`, `CustomerProfile`, `PaymentMethod`
- Fields are `camelCase` and named for what they **mean**, not what they store: `totalAmountInCents` not `amount`, `isEligibleForDiscount` not `eligible`
- Query fields are named after what they return: `invoice(id: ID!)`, `invoicesByCustomer(customerId: ID!)`
- Mutation fields are named after the action performed: `createInvoice`, `cancelSubscription`, `updatePaymentMethod`
- Subscription fields describe the event: `invoiceStatusChanged`, `paymentProcessed`
- Enum values are `SCREAMING_SNAKE_CASE`: `PaymentStatus { PENDING, PROCESSING, COMPLETED, FAILED }`
- Input types are suffixed with `Input`: `CreateInvoiceInput`, `UpdatePaymentMethodInput`
### Type Design
**Prefer specific types over generic ones**
```graphql
# Bad — caller must know the string format
type Invoice {
createdAt: String!
totalAmount: String!
}
# Good — intent is explicit
type Invoice {
createdAt: DateTime!
totalAmountInCents: Int!
currency: CurrencyCode!
}
```
**Non-null by default, nullable by exception**
A field is non-null (`!`) unless there is a deliberate reason it may be absent. Document why a field is nullable — it is a contract that the client must handle a missing value.
```typescript
@ObjectType()
export class Invoice {
@Field(() => ID)
id: string;
@Field(() => String)
invoiceNumber: string;
// Nullable — draft invoices have no due date until they are finalized
@Field(() => Date, { nullable: true })
dueDate: Date | null;
}
```
**Use enums for constrained value sets**
```typescript
registerEnumType(PaymentStatus, {
name: 'PaymentStatus',
description: 'Represents the lifecycle state of a payment attempt.',
valuesMap: {
PENDING: { description: 'Payment has been initiated but not yet processed.' },
PROCESSING: { description: 'Payment is being processed by the payment provider.' },
COMPLETED: { description: 'Payment was successfully processed.' },
FAILED: { description: 'Payment was declined or encountered an error.' },
},
});
```
**Model errors as part of the schema, not as exceptions**
Business errors are not exceptional — they are expected outcomes. Model them explicitly using a union return type rather than relying on GraphQL errors:
```typescript
@ObjectType()
export class InvoiceNotFoundError {
@Field(() => String)
message: string;
@Field(() => ID)
requestedInvoiceId: string;
}
export const CreateInvoiceResult = createUnionType({
name: 'CreateInvoiceResult',
types: () => [Invoice, InvoiceNotFoundError, ValidationError] as const,
});
@Mutation(() => CreateInvoiceResult)
async createInvoice(@Args('input') input: CreateInvoiceInput): Promise<typeof CreateInvoiceResult> {
return this.invoiceService.create(input);
}
```
### Input Validation
Validate inputs at the resolver boundary — never let invalid data reach the service layer. Use `class-validator` decorators on Input types, which NestJS integrates with the `ValidationPipe`:
```typescript
@InputType()
export class CreateInvoiceInput {
@Field(() => ID)
@IsUUID()
customerId: string;
@Field(() => [LineItemInput])
@ArrayMinSize(1, { message: 'An invoice must contain at least one line item.' })
@ValidateNested({ each: true })
@Type(() => LineItemInput)
lineItems: LineItemInput[];
@Field(() => Date, { nullable: true })
@IsOptional()
@IsDateString()
dueDate?: string;
}
```
---
## Resolvers
### Resolver Responsibilities
A resolver has exactly one job: translate a GraphQL operation into a domain action and map the result back to a GraphQL type. It does not contain business logic.
```typescript
@Resolver(() => Invoice)
export class InvoiceResolver {
constructor(private readonly invoiceService: InvoiceService) {}
@Query(() => Invoice, { nullable: true })
async invoice(@Args('id', { type: () => ID }) invoiceId: string): Promise<Invoice | null> {
return this.invoiceService.findById(invoiceId);
}
@Mutation(() => CreateInvoiceResult)
async createInvoice(
@Args('input') input: CreateInvoiceInput,
@CurrentUser() requestingUser: AuthenticatedUser,
): Promise<typeof CreateInvoiceResult> {
return this.invoiceService.create(input, requestingUser);
}
}
```
### Field Resolvers
Use `@ResolveField()` for fields that require their own resolution logic — computed fields, fields that require an additional data fetch, or fields that load related entities:
```typescript
@Resolver(() => Invoice)
export class InvoiceResolver {
constructor(
private readonly invoiceService: InvoiceService,
private readonly customerService: CustomerService,
) {}
// Resolved separately — customer lives in another service or requires its own fetch
@ResolveField(() => Customer)
async customer(@Parent() invoice: Invoice): Promise<Customer> {
return this.customerService.findById(invoice.customerId);
}
// Computed field — not stored, derived from line items
@ResolveField(() => Int)
totalAmountInCents(@Parent() invoice: Invoice): number {
return invoice.lineItems.reduce(
(runningTotal, lineItem) => runningTotal + lineItem.unitPriceInCents * lineItem.quantity,
0,
);
}
}
```
### N+1 Problem — DataLoader Pattern
Every `@ResolveField` that fetches a related entity is a potential N+1 query. Solve this with DataLoader — batch and cache individual lookups into a single query per request.
**Never resolve related entities in a `@ResolveField` without a DataLoader when the parent can be a list.**
```typescript
// dataloader/customer.loader.ts
@Injectable()
export class CustomerLoader {
private readonly loader: DataLoader<string, Customer>;
constructor(private readonly customerRepository: CustomerRepository) {
this.loader = new DataLoader<string, Customer>(
async (customerIds: readonly string[]) => {
const customers = await this.customerRepository.findByIds([...customerIds]);
const customerMap = new Map(customers.map(c => [c.id, c]));
// Return in the same order as the input keys — DataLoader requirement
return customerIds.map(id => customerMap.get(id) ?? new Error(`Customer ${id} not found`));
},
{ cache: true }, // Cache within a single request lifecycle
);
}
load(customerId: string): Promise<Customer> {
return this.loader.load(customerId);
}
}
// In the resolver
@ResolveField(() => Customer)
async customer(@Parent() invoice: Invoice): Promise<Customer> {
return this.customerLoader.load(invoice.customerId);
}
```
Scope `DataLoader` instances to the **request lifecycle** — register the loader provider with `Scope.REQUEST` to ensure the cache does not leak between requests:
```typescript
@Injectable({ scope: Scope.REQUEST })
export class CustomerLoader { ... }
```
---
## Authentication and Authorization
### Authentication
Attach the authenticated user to the GraphQL context in the Apollo driver configuration. Resolvers access it via a `@CurrentUser()` decorator — never from a global or ambient source.
```typescript
// graphql.module.ts
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
context: ({ req }) => ({ user: req.user }), // populated by AuthGuard
}),
```
```typescript
// decorators/current-user.decorator.ts
export const CurrentUser = createParamDecorator(
(_, context: ExecutionContext): AuthenticatedUser => {
const graphqlContext = GqlExecutionContext.create(context).getContext();
return graphqlContext.user;
},
);
```
### Authorization
Apply authorization at the resolver level using Guards. Never rely on business logic inside services to enforce access control — that is the resolver's boundary responsibility.
```typescript
@UseGuards(GqlAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Mutation(() => Invoice)
async cancelInvoice(
@Args('id', { type: () => ID }) invoiceId: string,
@CurrentUser() requestingUser: AuthenticatedUser,
): Promise<Invoice> {
return this.invoiceService.cancel(invoiceId, requestingUser);
}
```
For field-level authorization (hiding specific fields from certain roles), use a custom `FieldMiddleware`:
```typescript
const sensitiveFieldMiddleware: FieldMiddleware = async (ctx, next) => {
const { info, context } = ctx;
if (!context.user?.roles.includes(UserRole.FINANCE)) {
return null; // Field is hidden for non-finance users
}
return next();
};
@Field(() => Int, { middleware: [sensitiveFieldMiddleware] })
internalCostInCents: number;
```
---
## Performance
### Query Complexity and Depth Limits
Unprotected GraphQL APIs are vulnerable to deeply nested queries that produce exponential database load. Apply complexity and depth limits:
```typescript
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
validationRules: [
depthLimit(10),
createComplexityRule({
maximumComplexity: 1000,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
}),
],
}),
```
Annotate expensive fields with their complexity cost:
```typescript
@Field(() => [LineItem], {
complexity: (options) => options.args.limit * options.childComplexity,
})
lineItems(@Args('limit', { defaultValue: 20 }) limit: number): LineItem[] { ... }
```
### Persisted Queries
For production, use **Automatic Persisted Queries (APQ)** to reduce request payload size and enable CDN caching of query strings.
### Pagination
Never return unbounded lists. Every field that returns a collection is paginated. Use **cursor-based pagination** (the Relay Connection spec) for stable ordering and efficient large dataset traversal:
```typescript
@ObjectType()
export class InvoiceEdge {
@Field(() => String)
cursor: string;
@Field(() => Invoice)
node: Invoice;
}
@ObjectType()
export class InvoiceConnection {
@Field(() => [InvoiceEdge])
edges: InvoiceEdge[];
@Field(() => PageInfo)
pageInfo: PageInfo;
}
@Query(() => InvoiceConnection)
async invoices(
@Args() paginationArgs: ConnectionArgs,
@CurrentUser() user: AuthenticatedUser,
): Promise<InvoiceConnection> {
return this.invoiceService.findPaginated(paginationArgs, user);
}
```
Use offset pagination only for admin interfaces where jumping to a specific page is required.
---
## Testing GraphQL in NestJS
### Unit Testing Resolvers
Resolvers are thin — test the service layer, not the resolver. When you do test a resolver directly, test that it correctly delegates to its service and maps the result:
```typescript
describe('InvoiceResolver', () => {
let resolver: InvoiceResolver;
let invoiceService: DeepMocked<InvoiceService>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
InvoiceResolver,
{ provide: InvoiceService, useValue: createMock<InvoiceService>() },
],
}).compile();
resolver = module.get(InvoiceResolver);
invoiceService = module.get(InvoiceService);
});
describe('invoice query', () => {
it('returns the invoice when it exists', async () => {
const existingInvoice = buildInvoice({ id: 'inv_123' });
invoiceService.findById.mockResolvedValue(existingInvoice);
const result = await resolver.invoice('inv_123');
expect(result).toEqual(existingInvoice);
});
it('returns null when the invoice does not exist', async () => {
invoiceService.findById.mockResolvedValue(null);
const result = await resolver.invoice('inv_does_not_exist');
expect(result).toBeNull();
});
});
});
```
### Integration Testing the Full GraphQL Stack
Test through the full NestJS GraphQL stack using a real in-memory server — this catches schema mismatches, middleware issues, and auth failures that unit tests miss:
```typescript
describe('Invoice GraphQL API', () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(InvoiceRepository)
.useValue(createMock<InvoiceRepository>())
.compile();
app = module.createNestApplication();
app.useGlobalPipes(new ValidationPipe());
await app.init();
});
afterAll(() => app.close());
it('returns a 200 with invoice data for an authenticated request', async () => {
const query = `
query GetInvoice($id: ID!) {
invoice(id: $id) {
id
invoiceNumber
totalAmountInCents
}
}
`;
const response = await request(app.getHttpServer())
.post('/graphql')
.set('Authorization', `Bearer ${generateTestToken()}`)
.send({ query, variables: { id: 'inv_123' } })
.expect(200);
expect(response.body.data.invoice).toMatchObject({
id: 'inv_123',
invoiceNumber: expect.any(String),
totalAmountInCents: expect.any(Number),
});
});
it('returns an UNAUTHENTICATED error for requests without a valid token', async () => {
const response = await request(app.getHttpServer())
.post('/graphql')
.send({ query: `query { invoice(id: "inv_123") { id } }` })
.expect(200); // GraphQL always returns 200; errors are in the body
expect(response.body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
});
});
```
---
## Common Pitfalls
- **Never return raw ORM entities from resolvers** — map to GraphQL types explicitly. Leaking persistence models into the schema couples your schema to your database structure.
- **Never use `any` for resolver return types** — type every resolver return precisely. `Promise<Invoice | null>` not `Promise<any>`.
- **Never ignore the N+1 problem** — every `@ResolveField` on a type that appears in a list needs a DataLoader.
- **Never use offset pagination for large datasets** — cursors are stable under concurrent writes; page numbers are not.
- **Never expose internal error messages to clients** — catch infrastructure errors at the resolver boundary and return domain-appropriate messages.