Vaaaaaaaaaaaaaamos con el post número 22 de la serie NestJS y el primero del bloque de Escala Enterprise. Los seis bloques anteriores nos dieron la base completa: estructura (posts 1-5), base de datos (posts 6-9), seguridad (posts 10-12), funcionalidades avanzadas (posts 13-16), eventos y tiempo real (posts 17-19) y testing (posts 20-21) y todo con REST. Ahora añadimos otra forma de exponer nuestra API con GraphQL.
REST es genial para la mayoría de cosas, pero tiene un problema porque el servidor decide qué datos devuelve. Si el endpoint /users/1 devuelve 15 campos y tu mobile app solo necesita 3, estás enviando 12 campos que nadie pidió. Y si necesitas datos de un usuario y sus posts y los comentarios de cada post, son 3 requests HTTP separados.
💡 GraphQL Lenguaje de consulta para APIs creado por Meta (Facebook) en 2015. El cliente define exactamente qué datos necesita en cada request. Un solo endpoint (/graphql) con un esquema tipado que describe todas las operaciones posibles. Elimina el over-fetching y el under-fetching de REST. Más info → le da el control al cliente, porque se puede pedir todo lo que necesitas en una sola request.
EA, amo al lío.

1. REST vs GraphQL: el problema real
REST : El servidor manda todo:
GET /users/1
→ { id, email, name, password, roles, avatar, bio, createdAt, updatedAt, ... }
(El mobile solo necesitaba name y avatar)
GET /users/1/posts
→ [{ id, title, content, tags, createdAt, ... }, ...]
(Solo necesitaba title)
GET /posts/42/comments
→ [{ id, text, author, createdAt, ... }, ...]
(3 requests para montar una pantalla)
GraphQL : El cliente pide solo lo que necesita:
POST /graphql
query {
user(id: "1") {
name
avatar
posts {
title
comments {
text
}
}
}
}
→ 1 request, solo los campos pedidos, datos anidados
Endpoints fijos dónde el servidor decide los datos con múltiples requests para datos relacionados. Simple, predecible y cacheable con HTTP. Ideal para la mayoría de APIs.
Un solo endpoint dónde el cliente decide los datos. Datos anidados en un request con esquema tipado, introspección, Playground. Ideal cuando los clientes tienen necesidades muy distintas.
2. Code-First vs Schema-First
NestJS soporta dos enfoques para GraphQL:
| Enfoque | Cómo defines el schema | Ventaja |
|---|---|---|
| Schema-First | Escribes | Control total del schema SDL |
Code-First (este post) | Escribes clases TypeScript con decoradores. NestJS genera el schema | Una sola fuente de verdad. Los tipos TypeScript SON el schema |
Vamos con Code-First porque encaja perfectamente con la filosofía de decoradores de NestJS y evita la duplicación entre schema SDL y tipos TypeScript. Escribes las clases una vez y NestJS lo genera todo.
3. Instalación
Paquetes necesarios:
@nestjs/graphql: Decoradores y módulo de NestJS para GraphQL.@nestjs/apollo: Driver de Apollo Server Apollo Server El servidor GraphQL más popular para Node.js. Maneja la ejecución de queries, mutations, subscriptions, validación del schema, GraphiQL, métricas y plugins. NestJS lo integra como driver. .@apollo/server: El core de Apollo Server v5.graphql: La implementación de referencia de GraphQL en JavaScript.
4. Configuración del módulo
// src/app.module.ts
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
// Generar el schema automáticamente desde los decoradores
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
// Ordenar los tipos alfabéticamente en el schema
sortSchema: true,
// Habilitar GraphiQL en desarrollo (sustituto moderno del Playground)
graphiql: process.env.NODE_ENV !== 'production',
// Pasar el request al contexto de los resolvers
context: ({ req }) => ({ req }),
}),
// ... otros módulos
],
})
export class AppModule {}
autoSchemaFile: NestJS generaschema.gqlautomáticamente desde tus decoradores. Si no quieres un archivo físico, usaautoSchemaFile: truepara generarlo solo en memoria.graphiql: La interfaz web para probar queries enhttp://localhost:3000/graphql. Desactívala en producción.playgroundestá deprecado en Apollo Server v4+ : GraphiQL es el sustituto oficial.context: ({ req }): Pasa el request HTTP al contexto. Necesario para autenticación (extraer el JWT del header).
5. Object Types: definir qué datos expones
Los 💡 Object Types Clases decoradas con @ObjectType que definen la forma de los datos que GraphQL puede devolver. Cada propiedad con @Field se expone en el schema. Son el equivalente de las entidades de TypeORM pero para la capa de presentación. No expongas la entidad directamente : crea un ObjectType separado. Más info → definen la estructura de los datos en el schema:
// src/users/models/user.model.ts
import { ObjectType, Field, ID, registerEnumType } from '@nestjs/graphql';
import { Role } from '../enums/role.enum';
import { PostModel } from '../../posts/models/post.model';
// Registrar el enum para que GraphQL lo reconozca
registerEnumType(Role, {
name: 'Role',
description: 'Roles de usuario disponibles en el sistema',
});
@ObjectType({ description: 'Representa un usuario del sistema' })
export class UserModel {
@Field(() => ID, { description: 'Identificador único UUID' })
id: string;
@Field({ description: 'Email del usuario' })
email: string;
@Field({ description: 'Nombre completo' })
name: string;
// ❌ NO exponemos el password : no tiene @Field
// password: string;
@Field(() => [Role], { description: 'Roles asignados' })
roles: Role[];
@Field({ nullable: true, description: 'URL del avatar' })
avatar?: string;
@Field({ description: 'Fecha de creación' })
createdAt: Date;
@Field({ description: 'Fecha de última actualización' })
updatedAt: Date;
// Relación: los posts de este usuario
// La callback () => [PostModel] evita el problema de referencia circular:
// PostModel se evalúa en runtime (cuando los dos módulos ya están cargados),
// no al parsear el decorador. TypeScript sí necesita el import arriba.
@Field(() => [PostModel], {
nullable: true,
description: 'Posts publicados por el usuario',
})
posts?: PostModel[];
}
// src/posts/models/post.model.ts
import { ObjectType, Field, ID, Int } from '@nestjs/graphql';
import { UserModel } from '../../users/models/user.model';
@ObjectType({ description: 'Representa un post del blog' })
export class PostModel {
@Field(() => ID)
id: string;
@Field()
title: string;
@Field()
content: string;
@Field(() => [String])
tags: string[];
@Field(() => Int, { description: 'Número de likes' })
likesCount: number;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => UserModel, { description: 'Autor del post' })
author: UserModel;
}
Vamos a ver un par de cosibiris:
@Field(): Solo las propiedades con@Fieldaparecen en el schema. ElpasswordNO tiene@Field→ no existe en GraphQL.() => ID: Los tipos escalares de GraphQL:ID,String,Int,Float,Boolean.IDindica un identificador único.nullable: true: Por defecto, todos los campos son obligatorios (non-null).nullable: truelos hace opcionales.() => [PostModel]: Un array de PostModel. Las relaciones se definen aquí, no en la entidad. La callback (función flecha) es obligatoria con ObjectTypes que se referencian mutuamente (UserModel↔PostModel) porque resuelve la referencia en runtime, cuando ambos módulos ya están cargados. Si pusieras@Field(PostModel, ...)directamente, tendrías unundefineden tiempo de carga.
ObjectType ≠ Entity. El
UserModel(GraphQL) no es la entidadUser(TypeORM), son capas distintas. La entidad tienepassword,deletedAt, columnas internas. El model expone solo lo que el cliente puede ver. Separación limpia.
6. Input Types: lo que el cliente envía
Los Input Types Input Types Clases decoradas con @InputType que definen la estructura de los datos que el cliente envía en mutations y queries con argumentos. Equivalentes a los DTOs de REST. Soportan validación con class-validator. son los DTOs de GraphQL:
// src/users/dto/create-user.input.ts
import { InputType, Field } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, IsString, MinLength, MaxLength } from 'class-validator';
@InputType({ description: 'Datos para crear un nuevo usuario' })
export class CreateUserInput {
@Field({ description: 'Email del usuario' })
@IsEmail()
@IsNotEmpty()
readonly email: string;
@Field({ description: 'Nombre completo' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
readonly name: string;
@Field({ description: 'Contraseña' })
@IsString()
@MinLength(8)
@MaxLength(64)
readonly password: string;
}
// src/users/dto/update-user.input.ts
import { InputType, Field, PartialType } from '@nestjs/graphql';
import { CreateUserInput } from './create-user.input';
@InputType({ description: 'Datos para actualizar un usuario' })
export class UpdateUserInput extends PartialType(CreateUserInput) {
// Todos los campos de CreateUserInput, pero opcionales
// PartialType de @nestjs/graphql genera los @Field con nullable: true
}
PartialType funciona igual que en REST (lo vimos en el post 6) pero importado de @nestjs/graphql en vez de @nestjs/mapped-types. Genera un InputType con todos los campos opcionales.
Los decoradores de class-validator (@IsEmail, @MinLength, etc.) funcionan exactamente igual que en REST. GraphQL valida la estructura del query, class-validator valida el contenido.
7. El Resolver: el corazón de GraphQL
El 💡 Resolver Clase decorada con @Resolver que define las operaciones GraphQL disponibles. Es el equivalente de un Controller en REST. @Query para leer datos, @Mutation para modificar, @Subscription para tiempo real. Soporta DI, guards, interceptors y pipes igual que los controllers. Más info → es como un Controller pero para GraphQL:
// src/users/users.resolver.ts
import { Resolver, Query, Mutation, Args, ID, ResolveField, Parent } from '@nestjs/graphql';
import { UseGuards, Logger } from '@nestjs/common';
import { UserModel } from './models/user.model';
import { PostModel } from '../posts/models/post.model';
import { CreateUserInput } from './dto/create-user.input';
import { UpdateUserInput } from './dto/update-user.input';
import { UsersService } from './users.service';
import { PostsService } from '../posts/posts.service';
import { GqlAuthGuard } from '../auth/guards/gql-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { type User } from './entities/user.entity';
@Resolver(() => UserModel)
export class UsersResolver {
private readonly logger = new Logger(UsersResolver.name);
constructor(
private readonly usersService: UsersService,
private readonly postsService: PostsService
) {}
@Query(() => UserModel, {
name: 'user',
description: 'Obtener un usuario por ID',
})
@UseGuards(GqlAuthGuard)
async getUser(@Args('id', { type: () => ID }) id: string): Promise<User> {
return this.usersService.findOne(id);
}
@Query(() => [UserModel], {
name: 'users',
description: 'Listar todos los usuarios',
})
@UseGuards(GqlAuthGuard)
async getUsers(): Promise<User[]> {
return this.usersService.findAll();
}
@Query(() => UserModel, {
name: 'me',
description: 'Obtener el usuario autenticado',
})
@UseGuards(GqlAuthGuard)
async getMe(@CurrentUser() user: User): Promise<User> {
return this.usersService.findOne(user.id);
}
@Mutation(() => UserModel, {
description: 'Crear un nuevo usuario',
})
async createUser(@Args('input') input: CreateUserInput): Promise<User> {
return this.usersService.create(input);
}
@Mutation(() => UserModel, {
description: 'Actualizar un usuario existente',
})
@UseGuards(GqlAuthGuard)
async updateUser(
@Args('id', { type: () => ID }) id: string,
@Args('input') input: UpdateUserInput,
@CurrentUser() currentUser: User
): Promise<User> {
return this.usersService.update(id, input, currentUser.id);
}
@Mutation(() => Boolean, {
description: 'Eliminar un usuario (soft delete)',
})
@UseGuards(GqlAuthGuard)
async deleteUser(@Args('id', { type: () => ID }) id: string, @CurrentUser() currentUser: User): Promise<boolean> {
await this.usersService.remove(id, currentUser.id);
return true;
}
// Resolver de campo: cargar los posts del usuario bajo demanda
@ResolveField(() => [PostModel], { name: 'posts' })
async getPosts(@Parent() user: User): Promise<PostModel[]> {
return this.postsService.findByUserId(user.id);
}
}
Desglose de todo lo anterior:
@Resolver(() => UserModel): Vincula esta clase al tipoUserModel. Los@ResolveFieldse aplican a este tipo.@Query(() => UserModel, { name: 'user' }): Define una query en el schema.namees cómo el cliente la llama.@Mutation(() => UserModel): Define una mutation (operación que modifica datos).@Args('id', { type: () => ID }): Extrae un argumento del query. Equivalente a@Param('id')en REST.@Args('input'): Extrae un InputType completo. Equivalente a@Body()en REST.@ResolveField(() => [PostModel]): Resuelve el campopostsdelUserModel. Solo se ejecuta si el cliente pide el campoposts. Si no lo pide, no se ejecuta. Zero waste.@Parent(): Inyecta el objeto padre (elUser). AsígetPostssabe de qué usuario cargar los posts.
8. ¿Cómo se usa desde el cliente?
Con el resolver anterior, el cliente puede hacer estas queries:
# Obtener solo nombre y email
query {
user(id: "uuid-1") {
name
email
}
}
# → { "data": { "user": { "name": "Juan", "email": "juan@test.com" } } }
# Obtener usuario CON sus posts (1 request, datos anidados)
query {
user(id: "uuid-1") {
name
posts {
title
tags
}
}
}
# → { "data": { "user": { "name": "Juan", "posts": [{ "title": "...", "tags": [...] }] } } }
# Crear un usuario
mutation {
createUser(input: { email: "nuevo@test.com", name: "Nuevo Usuario", password: "SecurePass123!" }) {
id
email
name
}
}
# El usuario decide qué campos quiere en la respuesta de la mutation
mutation {
updateUser(id: "uuid-1", input: { name: "Nombre Nuevo" }) {
id
name
updatedAt
}
}
El cliente siempre decide qué campos recibir. Si solo quiere name, solo recibe name. Si quiere posts, el @ResolveField se ejecuta. Si no quiere posts, no se ejecuta, easy.
9. Autenticación: GqlAuthGuard
Los guards HTTP no funcionan directamente con GraphQL porque el ExecutionContext es diferente. Necesitas un adaptador:
// src/auth/guards/gql-auth.guard.ts
import { Injectable, type ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GqlExecutionContext } from '@nestjs/graphql';
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
// Devuelve el request HTTP del contexto GraphQL
return ctx.getContext().req;
}
}
Y el decorador @CurrentUser adaptado a GraphQL:
// src/auth/decorators/current-user.decorator.ts
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { type User } from '../../users/entities/user.entity';
export const CurrentUser = createParamDecorator((_data: unknown, context: ExecutionContext): User => {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req.user;
});
El truco es que GqlExecutionContext.create(context) transforma el ExecutionContext genérico en uno de GraphQL. Desde ahí, ctx.getContext().req devuelve el request HTTP donde Passport puso el user autenticado.
Así los guards del post 10 y el RBAC del post 11 funcionan con GraphQL sin reescribirlos y solo necesitas el adaptador.
10. Paginación tipada
En REST usamos query params (?page=1&limit=10) y en GraphQL usamos un patrón de paginación tipado:
// src/common/graphql/pagination.types.ts
import { ObjectType, Field, Int, ArgsType } from '@nestjs/graphql';
import { type Type } from '@nestjs/common';
import { Min, Max } from 'class-validator';
@ArgsType()
export class PaginationArgs {
@Field(() => Int, { defaultValue: 1 })
@Min(1)
page: number;
@Field(() => Int, { defaultValue: 10 })
@Min(1)
@Max(50)
limit: number;
}
@ObjectType()
export class PaginationMeta {
@Field(() => Int)
total: number;
@Field(() => Int)
page: number;
@Field(() => Int)
limit: number;
@Field(() => Int)
totalPages: number;
@Field()
hasNextPage: boolean;
@Field()
hasPreviousPage: boolean;
}
// Factory genérica: crea un tipo paginado para cualquier ObjectType
export function Paginated<T>(classRef: Type<T>) {
@ObjectType({ isAbstract: true })
abstract class PaginatedType {
@Field(() => [classRef])
data: T[];
@Field(() => PaginationMeta)
meta: PaginationMeta;
}
return PaginatedType as Type<PaginatedType>;
}
// src/posts/models/paginated-posts.model.ts
import { ObjectType } from '@nestjs/graphql';
import { Paginated } from '../../common/graphql/pagination.types';
import { PostModel } from './post.model';
@ObjectType({ description: 'Posts paginados' })
export class PaginatedPosts extends Paginated(PostModel) {}
Uso en el resolver:
// src/posts/posts.resolver.ts
@Query(() => PaginatedPosts, { name: 'posts' })
@UseGuards(GqlAuthGuard)
async getPosts(
@Args() pagination: PaginationArgs,
): Promise<PaginatedPosts> {
const [data, total] = await this.postsService.findAllPaginated(
pagination.page,
pagination.limit,
);
const totalPages = Math.ceil(total / pagination.limit);
return {
data,
meta: {
total,
page: pagination.page,
limit: pagination.limit,
totalPages,
hasNextPage: pagination.page < totalPages,
hasPreviousPage: pagination.page > 1,
},
};
}
La función Paginated<T> es una factory genérica que crea un tipo paginado para cualquier ObjectType. Paginated(PostModel) genera PaginatedPosts con data: PostModel[] y meta: PaginationMeta. Creas PaginatedUsers, PaginatedComments, etc. sin duplicar código.
Query del cliente:
query {
posts(page: 1, limit: 5) {
data {
id
title
author {
name
}
}
meta {
total
totalPages
hasNextPage
}
}
}
11. El problema N+1 y DataLoaders
El problema más famoso de GraphQL. Con nuestro @ResolveField:
query {
posts(page: 1, limit: 10) {
data {
title
author {
# ← Se ejecuta @ResolveField para CADA post
name
}
}
}
}
Sin DataLoader quedaría así:
1 query: SELECT * FROM posts LIMIT 10 → 10 posts
10 queries: SELECT * FROM users WHERE id = ? → 1 por cada post
= 11 queries para 10 posts. Con 100 posts = 101 queries. N+1.
Con 💡 DataLoader Librería de Meta que agrupa y deduplica peticiones a la base de datos. En vez de hacer N queries individuales (una por post), acumula todos los IDs y hace UNA sola query con WHERE id IN (...). Reduce N+1 a 2 queries. Más info → :
1 query: SELECT * FROM posts LIMIT 10
1 query: SELECT * FROM users WHERE id IN ('id1', 'id2', ..., 'id10')
= 2 queries. Siempre 2, sin importar cuántos posts.
Implementación:
// src/users/loaders/users.loader.ts
import { Injectable, Scope } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import DataLoader from 'dataloader';
import { User } from '../entities/user.entity';
@Injectable({ scope: Scope.REQUEST })
export class UsersLoader {
private readonly loader: DataLoader<string, User>;
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>
) {
this.loader = new DataLoader<string, User>(async (userIds: readonly string[]) => {
// UNA sola query para todos los IDs
const users = await this.usersRepository.find({
where: { id: In([...userIds]) },
});
// DataLoader requiere que el resultado esté en el mismo orden que los IDs
const usersMap = new Map(users.map((user) => [user.id, user]));
return userIds.map((id) => usersMap.get(id) ?? new Error(`User ${id} not found`));
});
}
async load(userId: string): Promise<User> {
return this.loader.load(userId);
}
async loadMany(userIds: string[]): Promise<Array<User | Error>> {
return this.loader.loadMany(userIds);
}
}
Y en el resolver:
// src/posts/posts.resolver.ts
@Resolver(() => PostModel)
export class PostsResolver {
constructor(
private readonly postsService: PostsService,
private readonly usersLoader: UsersLoader
) {}
@ResolveField(() => UserModel, { name: 'author' })
async getAuthor(@Parent() post: Post): Promise<User> {
// En vez de: return this.usersService.findOne(post.userId);
// Usa el DataLoader: agrupa todas las llamadas
return this.usersLoader.load(post.userId);
}
}
Puntos clave:
Scope.REQUEST: NestJS instancia unUsersLoadernuevo por cada request HTTP. Sin esto, el cache del DataLoader se mantendría vivo entre requests y devolverías datos cacheados de un usuario a otro — un bug de aislamiento grave. El DataLoader cachea a nivel de petición, no a nivel de aplicación.In([...userIds]): TypeORM generaWHERE id IN ('id1', 'id2', ...). Una sola query.- Mismo orden : DataLoader exige que los resultados estén en el mismo orden que las keys de entrada.
usersMap.get(id)garantiza esto. loader.load(id): El DataLoader acumula todas las llamadas.load()del mismo tick del event loop y las ejecuta en batch.
Ojo con el scope: usar
Scope.REQUESTen un provider hace que toda su cadena de dependencias (servicios, repositorios que inyecta) también se instancie por request. Es el precio a pagar por el aislamiento correcto del DataLoader. Alternativa: crear el loader manualmente en elcontextdelGraphQLModule.forRoot(context: ({ req }) => ({ req, usersLoader: new DataLoader(...) })) y leerlo desde@Context()en el resolver. Más control, más boilerplate.
12. Subscriptions: GraphQL en tiempo real
Las 💡 Subscriptions Operaciones GraphQL que mantienen una conexión persistente (WebSocket) y envían datos al cliente cuando ocurre un evento. Son el equivalente GraphQL de los WebSockets del post 19. El cliente se suscribe a un evento y recibe actualizaciones en tiempo real. Más info → combinan GraphQL con WebSockets (del post 19):
graphql-subscriptions aporta el PubSub in-memory y graphql-ws es el protocolo WebSocket moderno (sustituye al antiguo subscriptions-transport-ws, ya abandonado).
Actualiza la configuración del módulo:
// src/app.module.ts
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
graphiql: process.env.NODE_ENV !== 'production',
context: ({ req }) => ({ req }),
subscriptions: {
'graphql-ws': true, // Protocolo moderno de subscriptions
},
}),
El PubSub y la subscription:
// src/common/graphql/pubsub.provider.ts
import { PubSub } from 'graphql-subscriptions';
// En producción, usa RedisPubSub de graphql-redis-subscriptions
// para soportar múltiples instancias
export const pubSub = new PubSub();
export const SubscriptionEvents = {
POST_CREATED: 'postCreated',
POST_UPDATED: 'postUpdated',
POST_DELETED: 'postDeleted',
NOTIFICATION: 'notification',
} as const;
// src/posts/posts.resolver.ts (añadir al resolver existente)
import { Subscription } from '@nestjs/graphql';
import { pubSub, SubscriptionEvents } from '../common/graphql/pubsub.provider';
@Resolver(() => PostModel)
export class PostsResolver {
// ... queries y mutations existentes
@Mutation(() => PostModel)
@UseGuards(GqlAuthGuard)
async createPost(@Args('input') input: CreatePostInput, @CurrentUser() user: User): Promise<Post> {
const post = await this.postsService.create(input, user.id);
// Publicar el evento para las subscriptions
pubSub.publish(SubscriptionEvents.POST_CREATED, {
postCreated: post,
});
return post;
}
@Subscription(() => PostModel, {
description: 'Suscribirse a nuevos posts',
// Filtrar: solo recibir posts de un autor específico (opcional)
filter: (payload, variables) => {
if (variables.authorId) {
return payload.postCreated.userId === variables.authorId;
}
return true;
},
})
postCreated(
@Args('authorId', { type: () => ID, nullable: true })
_authorId?: string
) {
return pubSub.asyncIterableIterator(SubscriptionEvents.POST_CREATED);
}
}
El cliente se suscribe:
subscription {
postCreated {
id
title
author {
name
}
}
}
# Con filtro por autor
subscription {
postCreated(authorId: "uuid-1") {
id
title
}
}
Cada vez que se ejecuta createPost, pubSub.publish() emite el evento. Todos los clientes suscritos a postCreated reciben el nuevo post en tiempo real sin polling.
En producción: el
PubSubin-memory solo funciona con una instancia del servidor. Si tienes múltiples instancias (load balancing), usagraphql-redis-subscriptionscon el mismo Redis del post 18.
13. Validación en GraphQL
Los mismos decoradores de class-validator del post 6 funcionan con GraphQL. Solo necesitas activar el ValidationPipe:
// src/main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
})
);
Si el cliente envía:
mutation {
createUser(input: { email: "not-an-email", name: "", password: "123" }) {
id
}
}
GraphQL responde con errores de validación:
{
"errors": [
{
"message": "Bad Request Exception",
"extensions": {
"response": {
"message": [
"email must be an email",
"name should not be empty",
"password must be longer than or equal to 8 characters"
]
}
}
}
]
}
14. Manejo de errores en GraphQL
Los errores de GraphQL van en el array errors de la respuesta. En Apollo Server v4+, el código HTTP depende del tipo de error:
HTTP 200: Errores dentro de la ejecución de un resolver (excepciones lanzadas, validaciones de datos, etc.).HTTP 400: Errores de parseo o validación del query (sintaxis mal formada, campos inexistentes en el schema).HTTP 401: Errores concode: 'UNAUTHENTICATED'.
Apollo v3 devolvía siempre 200. Desde v4, el comportamiento se ajustó para encajar mejor con los estándares HTTP. Si prefieres forzar 200 siempre (por compatibilidad con clientes antiguos), puedes usar el plugin
ApolloServerPluginUsageReportingo devolver tus propios códigos enformatError.
Puedes personalizar los errores con un formatError formatError Función de Apollo Server que intercepta los errores antes de enviarlos al cliente. Permite ocultar detalles internos, mapear excepciones y estandarizar el formato de error. :
// src/app.module.ts
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
formatError: (error) => {
// No exponer detalles internos en producción
const originalError = error.extensions?.originalError as
| { message: string | string[]; statusCode: number }
| undefined;
return {
message: Array.isArray(originalError?.message)
? originalError.message[0]
: error.message,
statusCode: originalError?.statusCode ?? 500,
// No exponer stackTrace en producción
...(process.env.NODE_ENV !== 'production' && {
path: error.path,
}),
};
},
}),
15. Registrar el módulo
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { UsersResolver } from './users.resolver';
import { UsersLoader } from './loaders/users.loader';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService, UsersResolver, UsersLoader],
exports: [UsersService, UsersLoader],
})
export class UsersModule {}
El UsersResolver se registra como provider, no como controller. NestJS detecta los decoradores @Resolver, @Query, @Mutation y los conecta al schema automáticamente.
16. REST + GraphQL: coexistencia
No tienes que elegir uno u otro. Puedes tener ambos en la misma app:
// src/users/users.controller.ts : REST
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
}
// src/users/users.resolver.ts : GraphQL
@Resolver(() => UserModel)
export class UsersResolver {
constructor(private readonly usersService: UsersService) {}
@Query(() => UserModel, { name: 'user' })
getUser(@Args('id', { type: () => ID }) id: string) {
return this.usersService.findOne(id);
}
}
Ambos usan el mismo UsersService. La lógica de negocio está en el service. El controller y el resolver son solo adaptadores de protocolo. Si cambias la lógica, cambias en un sitio.
- Múltiples clientes con necesidades muy distintas (mobile, web, admin, third-party)
- Frontends que necesitan datos muy anidados (user → posts → comments → author)
- Over-fetching crónico: endpoints REST que devuelven 50 campos y el cliente usa 5
- Equipos frontend que quieren autonomía para definir qué datos piden sin esperar cambios en el backend
- API simple con pocos endpoints y clientes homogéneos
- APIs públicas con caché HTTP agresivo (CDN). REST + ETags es más sencillo
- File uploads (GraphQL puede, pero REST/multipart es más natural)
- APIs donde la mayoría de endpoints devuelven exactamente lo que el cliente necesita
17. GraphiQL
Con la app levantada, abre http://localhost:3000/graphql en el navegador. GraphiQL GraphiQL Interfaz web interactiva oficial para explorar y ejecutar queries GraphQL. Sustituye al Apollo Playground (deprecado en Apollo Server v4). Muestra el schema completo, autocompletado de campos, documentación generada automáticamente y historial de queries. te da:
- Autocompletado : Escribe
query { usy sugiereuser,users. - Documentación : Todas tus descripciones de
@ObjectType,@Field,@Query,@Mutationaparecen aquí. - Schema explorer : Navega por todos los tipos, queries y mutations del schema.
- Variables : Define variables para reutilizar queries con datos diferentes.
Nota : si ves ejemplos con
playground: true, está deprecado desde Apollo Server v4. Usagraphiql: trueen la configuración delGraphQLModule.
18. Estructura de archivos
src/
├── common/
│ └── graphql/
│ ├── pagination.types.ts ← PaginationArgs, PaginationMeta, Paginated<T>
│ └── pubsub.provider.ts ← PubSub + SubscriptionEvents
├── users/
│ ├── users.module.ts
│ ├── users.service.ts ← Lógica de negocio (compartida REST + GraphQL)
│ ├── users.controller.ts ← Endpoints REST
│ ├── users.resolver.ts ← Queries y Mutations GraphQL
│ ├── entities/
│ │ └── user.entity.ts ← Entidad TypeORM (BD)
│ ├── models/
│ │ └── user.model.ts ← ObjectType GraphQL (presentación)
│ ├── dto/
│ │ ├── create-user.input.ts ← InputType GraphQL
│ │ └── update-user.input.ts
│ └── loaders/
│ └── users.loader.ts ← DataLoader para N+1
├── posts/
│ ├── posts.module.ts
│ ├── posts.resolver.ts ← Queries, Mutations, Subscriptions
│ ├── models/
│ │ ├── post.model.ts
│ │ └── paginated-posts.model.ts
│ └── dto/
│ └── create-post.input.ts
├── auth/
│ ├── guards/
│ │ └── gql-auth.guard.ts ← Guard adaptado a GraphQL
│ └── decorators/
│ └── current-user.decorator.ts ← @CurrentUser para GraphQL
└── schema.gql ← Generado automáticamente
Tres capas:
entities/: TypeORM. Todo lo de la BD.models/: GraphQL ObjectTypes. Lo que el cliente ve.dto/: InputTypes. Lo que el cliente envía.
19. Errores comunes
Error 1: Exponer la entidad directamente como ObjectType
// ❌ La entidad tiene password, deletedAt, columnas internas
@ObjectType()
@Entity()
export class User {
@Field()
@Column()
password: string; // Exposición de datos sensibles
}
// ✅ Separar entidad (BD) de model (GraphQL)
@Entity()
export class User {
@Column()
password: string; // Solo en la BD
}
@ObjectType()
export class UserModel {
// Sin @Field para password → no existe en GraphQL
}
Error 2: No usar DataLoader en ResolveField
// ❌ N+1: una query por cada post en la lista
@ResolveField(() => UserModel)
async getAuthor(@Parent() post: Post): Promise<User> {
return this.usersService.findOne(post.userId); // Query individual
}
// ✅ DataLoader: agrupa todas las queries en una
@ResolveField(() => UserModel)
async getAuthor(@Parent() post: Post): Promise<User> {
return this.usersLoader.load(post.userId); // Batch automático
}
Error 3: Olvidar GqlExecutionContext en guards y decoradores
// ❌ El guard HTTP intenta getRequest() del contexto HTTP
// En GraphQL, el contexto es diferente → falla silenciosamente
@Injectable()
export class AuthGuard {
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest(); // undefined en GraphQL
}
}
// ✅ Crear GqlExecutionContext y extraer el request
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req; // El request HTTP del contexto GraphQL
}
}
Error 4: PubSub in-memory con múltiples instancias
// ❌ PubSub en memoria: si tienes 3 instancias, las subscriptions
// de la instancia 2 no reciben eventos de la instancia 1
const pubSub = new PubSub();
// ✅ En producción, usar Redis PubSub
import { RedisPubSub } from 'graphql-redis-subscriptions';
const pubSub = new RedisPubSub({
connection: { host: 'redis', port: 6379 },
});
20. Recapitulando
Definen el schema desde TypeScript. Solo los campos con @Field se exponen. Tipos: ID, Int, Float, Boolean, String. nullable: true para opcionales.
@Query para leer, @Mutation para modificar. @Args extrae argumentos. @InputType para payloads complejos. ValidationPipe funciona igual que en REST.
Resuelve campos anidados bajo demanda. Solo se ejecuta si el cliente pide el campo. Zero waste. Combinar con DataLoader para evitar N+1.
Agrupa queries individuales en batch. N+1 → 2 queries. Scope.REQUEST para cache por request. Resultado en el mismo orden que las keys.
Tiempo real con GraphQL sobre WebSockets. PubSub para publicar eventos. asyncIterableIterator para suscribirse. Filter para filtrar por criteria.
Coexisten en la misma app. Mismo service, dos adaptadores (controller + resolver). No tienes que elegir uno u otro.
En el próximo post nos metemos con Microservicios: patrones y transporte: @nestjs/microservices, request-response, event-based, transportes (TCP, Redis, RabbitMQ), hybrid apps y Docker Compose multi-servicio.
EA, nos vemos en los bares!! 🍺
Pon a prueba lo aprendido
1. ¿Cuál es la principal ventaja de GraphQL sobre REST?
2. ¿Qué hace @ResolveField en un Resolver?
3. ¿Qué problema resuelve DataLoader y cómo lo resuelve?
4. ¿Por qué necesitas GqlExecutionContext.create(context) en los guards de GraphQL?
5. ¿Por qué se recomienda separar la entidad TypeORM (@Entity) del ObjectType GraphQL (@ObjectType)?