GraphQL Patterns -- Deep Dive¶
GraphQL provides a single endpoint where clients declare exactly the data they need, eliminating over-fetching and under-fetching. NestJS's code-first approach with @nestjs/graphql generates the SDL schema from TypeScript decorators. DataLoader prevents the N+1 query problem, Redis-backed PubSub powers real-time subscriptions, and cursor-based pagination (Relay spec) handles large result sets reliably. This article covers all of these with working code and the SQL to prove it.
1. Schema-First vs Code-First¶
| Approach | Schema Source | Pros | Cons |
|---|---|---|---|
| Schema-first | .graphql SDL files |
Language agnostic, single source of truth, great for API contracts | Manual type sync, codegen step needed |
| Code-first | TypeScript decorators | Type-safe, co-located with resolvers, no codegen | Coupled to NestJS/TypeGraphQL, schema harder to review in isolation |
Warmwind uses code-first because the backend team owns the schema and TypeScript type safety prevents drift between resolvers and schema.
Coming from Sangria
In Scala's Sangria, you define ObjectType[Ctx, Val] instances and wire
them with fields[Ctx, Val] combinators. NestJS code-first replaces this
with decorators: @ObjectType() = Sangria ObjectType,
@Field(() => String) = Sangria Field. The Ctx type parameter becomes
NestJS's ExecutionContext or injected services. If you have used Rust's
async-graphql crate, @ObjectType = #[derive(SimpleObject)] and
@ResolveField = #[graphql(complex)] with async fn resolvers.
2. Object Types and Enums¶
Define your domain model as decorated TypeScript classes. The GraphQL schema is generated automatically at startup.
import {
ObjectType,
Field,
ID,
registerEnumType,
Int,
} from '@nestjs/graphql';
export enum ContainerStatus {
PENDING = 'PENDING',
RUNNING = 'RUNNING',
STOPPED = 'STOPPED',
FAILED = 'FAILED',
}
registerEnumType(ContainerStatus, {
name: 'ContainerStatus',
description: 'Lifecycle state of a managed Docker container',
});
@ObjectType({ description: 'A managed Docker container instance' })
export class Container {
@Field(() => ID)
id: string;
@Field({ description: 'Docker image used to create this container' })
image: string;
@Field()
name: string;
@Field(() => ContainerStatus)
status: ContainerStatus;
@Field(() => Int, { description: 'Allocated CPU cores' })
cpus: number;
@Field(() => Int, { description: 'Allocated memory in MB' })
memoryMb: number;
@Field()
createdAt: Date;
@Field({ nullable: true })
stoppedAt?: Date;
// This field is resolved by a @ResolveField -- not stored in the DB row.
@Field(() => [AgentEvent], { nullable: true })
events?: AgentEvent[];
// Owner relationship -- resolved via DataLoader to avoid N+1
@Field(() => User)
owner: User;
}
@ObjectType()
export class AgentEvent {
@Field(() => ID)
id: string;
@Field()
containerId: string;
@Field()
type: string;
@Field()
message: string;
@Field()
timestamp: Date;
}
Coming from Sangria
registerEnumType() is the equivalent of Sangria's EnumType with
values = .... In Sangria you might write
val ContainerStatusType = EnumType("ContainerStatus", ...).
The decorator approach is more concise but less flexible for runtime
schema manipulation -- a trade-off NestJS accepts for type safety.
3. Resolvers -- Code-First with Real Decorators¶
A resolver is the GraphQL equivalent of a REST controller. Each @Query, @Mutation, and @ResolveField maps to a schema field.
graph LR
Client -->|"query { containers { events, owner } }"| GQL["GraphQL Engine"]
GQL --> QR["containers() Query Resolver"]
QR --> SVC["ContainerService"]
SVC --> PG[("PostgreSQL")]
GQL --> FR1["events() Field Resolver"]
FR1 --> DL1["EventsByContainerLoader (DataLoader)"]
GQL --> FR2["owner() Field Resolver"]
FR2 --> DL2["UserByIdLoader (DataLoader)"]
DL1 --> PG
DL2 --> PG
import {
Resolver,
Query,
Mutation,
ResolveField,
Parent,
Args,
ID,
} from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlAuthGuard } from '../auth/gql-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Container } from './container.model';
import { ContainerService } from './container.service';
import { ContainerConnection } from './container-connection.model';
import { PaginationArgs } from '../common/pagination.args';
import { StartContainerInput } from './dto/start-container.input';
import { EventsByContainerLoader } from './loaders/events-by-container.loader';
import { UserByIdLoader } from '../user/loaders/user-by-id.loader';
import { AgentEvent } from './agent-event.model';
import { User } from '../user/user.model';
import { JwtPayload } from '../auth/jwt-payload.interface';
@Resolver(() => Container)
@UseGuards(GqlAuthGuard)
export class ContainerResolver {
constructor(
private readonly containerService: ContainerService,
private readonly eventLoader: EventsByContainerLoader,
private readonly userLoader: UserByIdLoader,
) {}
// ── Queries ───────────────────────────────────────────────
@Query(() => ContainerConnection, {
description: 'Paginated list of containers owned by the current user',
})
async containers(
@CurrentUser() user: JwtPayload,
@Args() pagination: PaginationArgs,
): Promise<ContainerConnection> {
return this.containerService.findAllPaginated(user.id, pagination);
}
@Query(() => Container, { nullable: true })
async container(
@Args('id', { type: () => ID }) id: string,
@CurrentUser() user: JwtPayload,
): Promise<Container | null> {
return this.containerService.findOneOwned(id, user.id);
}
// ── Mutations ─────────────────────────────────────────────
@Mutation(() => Container)
async startContainer(
@Args('input') input: StartContainerInput,
@CurrentUser() user: JwtPayload,
): Promise<Container> {
return this.containerService.start(user.id, input);
}
@Mutation(() => Boolean)
async stopContainer(
@Args('id', { type: () => ID }) id: string,
@CurrentUser() user: JwtPayload,
): Promise<boolean> {
await this.containerService.stop(id, user.id);
return true;
}
// ── Field Resolvers (DataLoader-backed) ───────────────────
@ResolveField(() => [AgentEvent], {
description: 'Agent events for this container, batched via DataLoader',
})
async events(@Parent() container: Container): Promise<AgentEvent[]> {
return this.eventLoader.load(container.id);
}
@ResolveField(() => User)
async owner(@Parent() container: Container): Promise<User> {
return this.userLoader.load(container.ownerId);
}
}
3.1 Input Types and Validation¶
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsString, IsInt, Min, Max } from 'class-validator';
@InputType()
export class StartContainerInput {
@Field()
@IsString()
image: string;
@Field(() => Int, { defaultValue: 1 })
@IsInt()
@Min(1)
@Max(8)
cpus: number;
@Field(() => Int, { defaultValue: 512 })
@IsInt()
@Min(256)
@Max(16384)
memoryMb: number;
}
Coming from Sangria
In Sangria, input types are InputObjectType with InputField entries
that must be manually validated in the resolver. NestJS combines
@InputType() with class-validator decorators so validation is
declarative and runs automatically via the ValidationPipe. This is
closer to how Caliban (ZIO-based) handles validation via ZIO Schema.
4. DataLoader -- Per-Request N+1 Prevention¶
4.1 The N+1 Problem Visualized¶
Without DataLoader, fetching 50 containers with their events generates 51 SQL queries:
-- 1 query for containers
SELECT * FROM containers WHERE owner_id = $1 ORDER BY created_at DESC LIMIT 50;
-- 50 queries for events (one per container!)
SELECT * FROM agent_events WHERE container_id = 'ctr-001';
SELECT * FROM agent_events WHERE container_id = 'ctr-002';
-- ... 48 more
With DataLoader, this collapses to 2 queries:
-- 1 query for containers
SELECT * FROM containers WHERE owner_id = $1 ORDER BY created_at DESC LIMIT 50;
-- 1 batched query for ALL events
SELECT * FROM agent_events WHERE container_id IN ('ctr-001', 'ctr-002', ..., 'ctr-050');
4.2 DataLoader Implementation¶
DataLoader must be request-scoped. A shared (singleton) DataLoader would cache stale data across requests and leak data between users.
import { Injectable, Scope } from '@nestjs/common';
import DataLoader from 'dataloader';
import { AgentEvent } from '../agent-event.model';
import { EventService } from '../event.service';
@Injectable({ scope: Scope.REQUEST })
export class EventsByContainerLoader {
private readonly loader: DataLoader<string, AgentEvent[]>;
constructor(private readonly eventService: EventService) {
this.loader = new DataLoader<string, AgentEvent[]>(
async (containerIds: readonly string[]) => {
// Single batched query for all container IDs
const events = await this.eventService.findByContainerIds(
[...containerIds],
);
// Group events by containerId to maintain DataLoader's key-order contract
const grouped = new Map<string, AgentEvent[]>();
for (const event of events) {
const list = grouped.get(event.containerId) ?? [];
list.push(event);
grouped.set(event.containerId, list);
}
// CRITICAL: return in the same order as the input keys.
// Missing keys must return empty arrays, not undefined.
return containerIds.map((id) => grouped.get(id) ?? []);
},
{
// Optional: cache within a single request (default true)
cache: true,
// Optional: max batch size to avoid huge IN clauses
maxBatchSize: 100,
},
);
}
load(containerId: string): Promise<AgentEvent[]> {
return this.loader.load(containerId);
}
}
4.3 The Service Layer (SQL)¶
@Injectable()
export class EventService {
constructor(
@InjectRepository(AgentEventEntity)
private readonly repo: Repository<AgentEventEntity>,
) {}
/**
* Batch-fetch events for multiple containers in a single query.
* Used exclusively by the DataLoader.
*/
async findByContainerIds(ids: string[]): Promise<AgentEvent[]> {
if (ids.length === 0) return [];
return this.repo
.createQueryBuilder('event')
.where('event.container_id IN (:...ids)', { ids })
.orderBy('event.timestamp', 'DESC')
.getMany();
}
}
Coming from Sangria
Sangria's Fetcher is the exact DataLoader equivalent. If you defined
Fetcher.caching(...) with a fetch: Seq[Id] => Future[Seq[Result]],
the NestJS DataLoader constructor takes the same signature:
keys: readonly K[] => Promise<V[]>. The contract is identical:
output must be in the same order as input keys, and missing entries
must return a sentinel value (empty array, null). Sangria's DeferredResolver
is the GraphQL-level batching that DataLoader handles at the service level.
5. Subscriptions -- Real-Time via Redis PubSub¶
Subscriptions maintain a persistent WebSocket connection (using graphql-ws protocol) for server-pushed updates. Redis PubSub ensures events are delivered across all NestJS nodes.
graph LR
Worker["BullMQ Worker"] -->|"publish('CONTAINER_STATUS')"| Redis["Redis PubSub"]
Redis --> N1["Node 1: GraphQL Subscriptions"]
Redis --> N2["Node 2: GraphQL Subscriptions"]
N1 -->|"graphql-ws"| Browser1["Browser Client 1"]
N2 -->|"graphql-ws"| Browser2["Browser Client 2"]
5.1 PubSub Provider¶
import { Global, Module } from '@nestjs/common';
import { RedisPubSub } from 'graphql-redis-subscriptions';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
const PUB_SUB_TOKEN = 'PUB_SUB';
@Global()
@Module({
providers: [
{
provide: PUB_SUB_TOKEN,
useFactory: (config: ConfigService) => {
const redisOptions = {
host: config.get('REDIS_HOST', 'localhost'),
port: config.get('REDIS_PORT', 6379),
password: config.get('REDIS_PASSWORD', undefined),
};
return new RedisPubSub({
publisher: new Redis(redisOptions),
subscriber: new Redis(redisOptions),
// Serialize/deserialize with JSON (default), but you can use
// protobuf or msgpack for lower latency in production.
});
},
inject: [ConfigService],
},
],
exports: [PUB_SUB_TOKEN],
})
export class PubSubModule {}
5.2 Publishing Events¶
import { Inject, Injectable } from '@nestjs/common';
import { RedisPubSub } from 'graphql-redis-subscriptions';
import { OnEvent } from '@nestjs/event-emitter';
@Injectable()
export class ContainerEventPublisher {
constructor(@Inject('PUB_SUB') private readonly pubSub: RedisPubSub) {}
@OnEvent('container.started')
async onStarted(payload: { containerId: string; userId: string }) {
await this.pubSub.publish('CONTAINER_STATUS', {
containerStatusChanged: {
containerId: payload.containerId,
status: 'RUNNING',
timestamp: new Date(),
},
});
}
@OnEvent('container.stopped')
async onStopped(payload: { containerId: string }) {
await this.pubSub.publish('CONTAINER_STATUS', {
containerStatusChanged: {
containerId: payload.containerId,
status: 'STOPPED',
timestamp: new Date(),
},
});
}
}
5.3 Subscription Resolver¶
import { Resolver, Subscription, Args, ID } from '@nestjs/graphql';
import { Inject, UseGuards } from '@nestjs/common';
import { RedisPubSub } from 'graphql-redis-subscriptions';
import { GqlAuthGuard } from '../auth/gql-auth.guard';
@ObjectType()
export class ContainerStatusUpdate {
@Field()
containerId: string;
@Field(() => ContainerStatus)
status: ContainerStatus;
@Field()
timestamp: Date;
}
@Resolver()
@UseGuards(GqlAuthGuard)
export class ContainerSubscriptionResolver {
constructor(@Inject('PUB_SUB') private readonly pubSub: RedisPubSub) {}
@Subscription(() => ContainerStatusUpdate, {
description: 'Emits when a container changes state',
filter: (payload, variables, context) => {
const update = payload.containerStatusChanged;
// Only deliver events for the requested container
if (variables.containerId) {
return update.containerId === variables.containerId;
}
// Or filter by ownership (requires context from connection params)
return update.userId === context.req?.user?.id;
},
})
containerStatusChanged(
@Args('containerId', { type: () => ID, nullable: true })
_containerId?: string,
) {
return this.pubSub.asyncIterableIterator('CONTAINER_STATUS');
}
}
Coming from Sangria
Sangria subscriptions use Akka Streams Source[T, NotUsed] piped through
a PreparedQuery. The NestJS equivalent replaces the Akka Source with
an AsyncIterableIterator from graphql-subscriptions. The filter
option is equivalent to Sangria's FilteredEventStream. If you used
akka.stream.scaladsl.Source.actorRef, the Redis PubSub channel is the
distributed equivalent of that actor's mailbox.
6. Cursor-Based Pagination (Relay Spec)¶
Offset-based pagination breaks when rows are inserted/deleted during navigation. Cursor-based pagination uses an opaque pointer (typically a base64-encoded primary key or timestamp) to guarantee stable page boundaries.
6.1 Relay Connection Types¶
import {
ObjectType,
Field,
ArgsType,
Int,
ID,
} from '@nestjs/graphql';
import { Type } from '@nestjs/common';
import { Min, Max, IsOptional, IsString } from 'class-validator';
// ── Generic Connection/Edge/PageInfo ─────────────────────
@ObjectType()
export class PageInfo {
@Field()
hasNextPage: boolean;
@Field()
hasPreviousPage: boolean;
@Field({ nullable: true })
startCursor?: string;
@Field({ nullable: true })
endCursor?: string;
}
// Factory function: creates Connection and Edge types for any node type
export function Paginated<T>(classRef: Type<T>) {
@ObjectType(`${classRef.name}Edge`)
abstract class EdgeType {
@Field(() => String)
cursor: string;
@Field(() => classRef)
node: T;
}
@ObjectType(`${classRef.name}Connection`, { isAbstract: true })
abstract class ConnectionType {
@Field(() => [EdgeType])
edges: EdgeType[];
@Field(() => PageInfo)
pageInfo: PageInfo;
@Field(() => Int)
totalCount: number;
}
return ConnectionType as Type<ConnectionType>;
}
// Concrete connection type
@ObjectType()
export class ContainerConnection extends Paginated(Container) {}
// ── Pagination Arguments ─────────────────────────────────
@ArgsType()
export class PaginationArgs {
@Field({ nullable: true, description: 'Cursor of the last item on the previous page' })
@IsOptional()
@IsString()
after?: string;
@Field({ nullable: true, description: 'Cursor of the first item on the next page' })
@IsOptional()
@IsString()
before?: string;
@Field(() => Int, { defaultValue: 20, description: 'Number of items to fetch' })
@Min(1)
@Max(100)
first: number;
}
6.2 Cursor Encoding/Decoding¶
/**
* Opaque cursor encoding. We use base64(id) so cursors are URL-safe
* and opaque to the client. The client must NOT parse or construct cursors.
*/
export function encodeCursor(id: string): string {
return Buffer.from(id, 'utf-8').toString('base64url');
}
export function decodeCursor(cursor: string): string {
return Buffer.from(cursor, 'base64url').toString('utf-8');
}
6.3 Service Implementation (TypeORM)¶
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan, MoreThan } from 'typeorm';
import { ContainerEntity } from './container.entity';
import { PaginationArgs } from '../common/pagination.args';
import { encodeCursor, decodeCursor } from '../common/cursor';
@Injectable()
export class ContainerService {
constructor(
@InjectRepository(ContainerEntity)
private readonly repo: Repository<ContainerEntity>,
) {}
async findAllPaginated(
userId: string,
args: PaginationArgs,
): Promise<ContainerConnection> {
const qb = this.repo
.createQueryBuilder('c')
.where('c.owner_id = :userId', { userId })
.orderBy('c.created_at', 'DESC')
.addOrderBy('c.id', 'DESC'); // tiebreaker for stable ordering
// Apply cursor filter
if (args.after) {
const afterId = decodeCursor(args.after);
const afterRow = await this.repo.findOneByOrFail({ id: afterId });
qb.andWhere(
'(c.created_at < :afterDate OR (c.created_at = :afterDate AND c.id < :afterId))',
{ afterDate: afterRow.createdAt, afterId },
);
}
// Fetch one extra to determine hasNextPage
const limit = args.first + 1;
const rows = await qb.take(limit).getMany();
const hasNextPage = rows.length > args.first;
const nodes = hasNextPage ? rows.slice(0, -1) : rows;
// Total count (for UI "showing X of Y")
const totalCount = await this.repo.count({
where: { ownerId: userId },
});
const edges = nodes.map((node) => ({
cursor: encodeCursor(node.id),
node,
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!args.after,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
totalCount,
};
}
}
The generated SQL for page 2:
SELECT c.*
FROM containers c
WHERE c.owner_id = $1
AND (c.created_at < $2 OR (c.created_at = $2 AND c.id < $3))
ORDER BY c.created_at DESC, c.id DESC
LIMIT 21; -- first + 1 to detect hasNextPage
7. Error Handling¶
7.1 Formatted Errors¶
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { GraphQLModule } from '@nestjs/graphql';
import { GraphQLFormattedError } from 'graphql';
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true, // code-first: generate schema in memory
subscriptions: {
'graphql-ws': true, // modern graphql-ws protocol
},
formatError: (error: GraphQLFormattedError) => ({
message: error.message,
code: error.extensions?.code ?? 'INTERNAL_SERVER_ERROR',
path: error.path,
// Strip stack traces in production
...(process.env.NODE_ENV === 'development' && {
locations: error.locations,
extensions: error.extensions,
}),
}),
});
7.2 Custom Business Exceptions¶
import { GraphQLError } from 'graphql';
export class ContainerLimitExceededError extends GraphQLError {
constructor(userId: string, limit: number) {
super(`User ${userId} has reached the container limit of ${limit}`, {
extensions: {
code: 'CONTAINER_LIMIT_EXCEEDED',
limit,
},
});
}
}
// In resolver:
if (activeCount >= plan.containerLimit) {
throw new ContainerLimitExceededError(user.id, plan.containerLimit);
}
8. Apollo Federation Basics¶
Federation composes multiple independently deployed subgraph services into a single supergraph that clients query through a router (Apollo Router or Apollo Gateway).
graph LR
Client --> Router["Apollo Router"]
Router --> SG1["Container Subgraph"]
Router --> SG2["User Subgraph"]
Router --> SG3["Billing Subgraph"]
SG1 --> PG1[("containers DB")]
SG2 --> PG2[("users DB")]
SG3 --> PG3[("billing DB")]
8.1 Defining a Subgraph Entity¶
import { Directive, ObjectType, Field, ID } from '@nestjs/graphql';
@ObjectType()
@Directive('@key(fields: "id")') // Federation entity key
export class Container {
@Field(() => ID)
id: string;
@Field()
name: string;
@Field(() => ContainerStatus)
status: ContainerStatus;
}
// Reference resolver: called by the router when another subgraph
// references a Container by its key fields.
@Resolver(() => Container)
export class ContainerResolver {
@ResolveReference()
resolveReference(reference: { __typename: string; id: string }) {
return this.containerService.findById(reference.id);
}
}
8.2 Extending an Entity from Another Subgraph¶
// In the Billing subgraph, extend Container defined in Container subgraph
@ObjectType()
@Directive('@extends')
@Directive('@key(fields: "id")')
export class Container {
@Field(() => ID)
@Directive('@external')
id: string;
// Field added by this subgraph
@Field(() => BillingInfo, { nullable: true })
billing?: BillingInfo;
}
Coming from Sangria
Sangria does not have built-in federation support. If you used Caliban
(ZIO-based), its @GQLKey annotation maps directly to @Directive('@key(...)').
Federation is fundamentally a gateway concern -- each subgraph is a normal
GraphQL server with extra directives. The router handles query planning
and cross-subgraph joins.
9. Module Configuration¶
Putting it all together in the module:
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { ContainerResolver } from './container.resolver';
import { ContainerSubscriptionResolver } from './container-subscription.resolver';
import { ContainerService } from './container.service';
import { EventsByContainerLoader } from './loaders/events-by-container.loader';
import { UserByIdLoader } from '../user/loaders/user-by-id.loader';
import { ContainerEventPublisher } from './container-event.publisher';
import { PubSubModule } from '../pubsub/pubsub.module';
@Module({
imports: [
PubSubModule,
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
subscriptions: { 'graphql-ws': true },
context: ({ req, connection }) => ({
req,
// Pass connection context for subscription auth
...(connection && { req: { user: connection.context.user } }),
}),
}),
],
providers: [
ContainerResolver,
ContainerSubscriptionResolver,
ContainerService,
EventsByContainerLoader,
UserByIdLoader,
ContainerEventPublisher,
],
})
export class ContainerGraphQLModule {}
What's new (2025--2026)
Apollo Server v4 is now the baseline for @nestjs/apollo.
graphql-ws replaces the deprecated subscriptions-transport-ws as the
default subscription protocol. Apollo Federation v2 supports @override,
@inaccessible, and progressive @override for incremental subgraph
migrations. The Apollo Router (Rust-based) replaces the Node.js
Gateway for production federation deployments.
Glossary¶
Glossary
- Resolver
- Function that populates a single field in a GraphQL schema, mapping it to a data source. Analogous to a REST controller method but scoped to one field.
- Field Resolver
- A
@ResolveField()method that resolves a nested/computed field on a parent type. Runs once per parent object in the result set. - DataLoader
- Utility that batches and caches individual
.load(key)calls within a single event-loop tick to collapse N+1 queries into a single batched query. Must be request-scoped. - N+1 Problem
- Performance anti-pattern where fetching a list of N parents triggers N additional queries to fetch each parent's children, resulting in N+1 total queries instead of 2.
- Subscription
- GraphQL operation type maintaining a persistent WebSocket connection for server-pushed real-time updates. Uses the
graphql-wsprotocol. - PubSub
- Publish-subscribe pattern used to distribute domain events between services and subscription resolvers. Redis PubSub enables cross-node event delivery.
- Cursor-Based Pagination
- Pagination using an opaque pointer (cursor) to the last item rather than a numeric offset. Stable under concurrent inserts/deletes. Defined by the Relay Connection spec.
- Relay Connection Spec
- A convention for GraphQL pagination defining
Connection,Edge, andPageInfotypes. Originally from Facebook Relay but widely adopted as a GraphQL best practice. - Federation
- Architecture pattern where multiple GraphQL subgraphs (each owning a domain) compose into a single unified supergraph via a router. Enables team autonomy and independent deployability.
- Subgraph
- A single GraphQL service in a federated architecture that defines and owns a subset of the supergraph schema.
- Code-First
- Schema generation approach where the GraphQL SDL is derived from TypeScript class decorators rather than hand-written
.graphqlfiles. - Input Type
- A GraphQL type used exclusively for mutation/query arguments. Decorated with
@InputType()in NestJS, enablingclass-validatorintegration. - Directive
- A GraphQL schema annotation (e.g.,
@deprecated,@key,@auth) that provides metadata to the execution engine or tooling without changing the resolver logic.