🚨 ¡Nueva review! ¡Mi teclado ideal! ⌨️ Perfecto para programar, el Logitech MX Keys S . ¡Échale un ojo! 👀

GraphQL Code-First con NestJS

Serie NestJS #22 : @nestjs/graphql, @Resolver, @Query, @Mutation, @Args, @ObjectType, @Field, subscriptions, dataloaders y REST vs GraphQL

Escrito por domin el 20 de abril de 2026

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.

le da el control al cliente, porque se puede pedir todo lo que necesitas en una sola request.

EA, amo al lío.

Diagrama de una API GraphQL con resolvers, object types y mutations conectados en NestJS.

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
📡 REST (posts 1-21)

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.

🔮 GraphQL (este post)

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:

EnfoqueCómo defines el schemaVentaja
Schema-First

Escribes .graphql files manualmente y luego creas las clases TypeScript que lo implementan

Control total del schema SDL

Code-First (este post)

Escribes clases TypeScript con decoradores. NestJS genera el schema .graphql automáticamente

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

Instalación de GraphQL para NestJS 0 / 1
$
Pulsa para ejecutar el siguiente comando

Paquetes necesarios:


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 {}

5. Object Types: definir qué datos expones

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

ObjectType ≠ Entity. El UserModel (GraphQL) no es la entidad User (TypeORM), son capas distintas. La entidad tiene password, 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 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 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:


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 :

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.
Instalación de DataLoader 0 / 1
$
Pulsa para ejecutar el siguiente comando

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:

Ojo con el scope: usar Scope.REQUEST en 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 el context del GraphQLModule.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 combinan GraphQL con WebSockets (del post 19):

Instalación de subscriptions 0 / 1
$
Pulsa para ejecutar el siguiente comando

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 PubSub in-memory solo funciona con una instancia del servidor. Si tienes múltiples instancias (load balancing), usa graphql-redis-subscriptions con 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:

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 ApolloServerPluginUsageReporting o devolver tus propios códigos en formatError.

Puedes personalizar los errores con un formatError :

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

Usa GraphQL
  • 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
Quédate con REST
  • 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 te da:

Nota : si ves ejemplos con playground: true, está deprecado desde Apollo Server v4. Usa graphiql: true en la configuración del GraphQLModule.


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:


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

🏷️ @ObjectType + @Field

Definen el schema desde TypeScript. Solo los campos con @Field se exponen. Tipos: ID, Int, Float, Boolean, String. nullable: true para opcionales.

🔮 @Query + @Mutation

@Query para leer, @Mutation para modificar. @Args extrae argumentos. @InputType para payloads complejos. ValidationPipe funciona igual que en REST.

🔗 @ResolveField

Resuelve campos anidados bajo demanda. Solo se ejecuta si el cliente pide el campo. Zero waste. Combinar con DataLoader para evitar N+1.

⚡ DataLoader

Agrupa queries individuales en batch. N+1 → 2 queries. Scope.REQUEST para cache por request. Resultado en el mismo orden que las keys.

📡 Subscriptions

Tiempo real con GraphQL sobre WebSockets. PubSub para publicar eventos. asyncIterableIterator para suscribirse. Filter para filtrar por criteria.

🤝 REST + GraphQL

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)?