Skip to content

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-ws protocol.
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, and PageInfo types. 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 .graphql files.
Input Type
A GraphQL type used exclusively for mutation/query arguments. Decorated with @InputType() in NestJS, enabling class-validator integration.
Directive
A GraphQL schema annotation (e.g., @deprecated, @key, @auth) that provides metadata to the execution engine or tooling without changing the resolver logic.