---
name: graphql-experto
description: >
  GraphQL: schema design, resolvers, DataLoader para N+1, subscriptions,
  paginación Relay, federation y Apollo Server/Client. Cargar cuando se diseñe
  un schema GraphQL, se implementen resolvers o se configure Apollo.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para APIs REST — GraphQL y REST son paradigmas de diseño de API distintos; si el proyecto usa REST cargar `api-rest-diseno` o el skill del framework backend."
  - "No cargar para configurar Apollo Client en el frontend React/Next.js sin contexto del schema — si la pregunta es solo sobre configuración del cliente, no del schema o resolvers del servidor."
  - "No cargar para diseñar el esquema relacional de la base de datos que subyace al schema GraphQL — el schema GraphQL y el esquema SQL son capas distintas; para el esquema SQL cargar `postgresql-experto` o `dbml-experto`."
  - "No cargar para errores de TypeScript en tipos generados por GraphQL Codegen — esos son errores de generación de código; revisar el schema GraphQL fuente y la configuración de codegen."
evolvable: true  # default para skill estandar
---
# GraphQL Experto — Schema Design y Resolvers

GraphQL es un contrato entre cliente y servidor. Un schema bien diseñado permite
evolucionar la API sin versionar. Este skill cubre schema-first vs code-first,
patrones de resolvers, DataLoader para el problema N+1, paginación con cursor
y autenticación en el contexto de Apollo Server.

## Cuándo cargar este skill

Invoca `Skill("graphql-experto")` cuando:

- Se diseñe un schema GraphQL nuevo (tipos, queries, mutations, subscriptions)
- Se implementen resolvers con acceso a BD
- Se configure Apollo Server o un servidor GraphQL similar
- Se solucione el problema N+1 en resolvers
- Se implemente paginación cursor-based o Relay spec
- Se configure Apollo Client en el frontend

## Cuándo NO cargar

- El proyecto usa REST — GraphQL y REST son paradigmas distintos; si el proyecto usa endpoints REST cargar `api-rest-diseno` o el skill del framework backend.
- La pregunta es solo sobre configurar Apollo Client en el frontend sin contexto del schema — si no hay diseño de schema o resolvers del servidor, este skill aporta poco.
- La pregunta es sobre diseñar el esquema relacional de la BD que subyace al schema GraphQL — el schema GraphQL y el esquema SQL son capas distintas; para el esquema SQL cargar `postgresql-experto`.
- Los errores son de tipos generados por GraphQL Codegen — esos son errores de generación de código; revisar el schema GraphQL fuente y la configuración de codegen.

## Conceptos clave

### Schema-first vs Code-first

Schema-first: se escribe el SDL (Schema Definition Language) primero, los resolvers
lo implementan. Ventaja: el contrato es claro desde el inicio. Code-first
(ej: TypeGraphQL, Pothos): el schema se genera desde las clases/funciones.
Ventaja: un solo source of truth en TypeScript.

### DataLoader — solución al N+1

Cada campo resuelto en GraphQL puede generar queries independientes. DataLoader
agrupa múltiples resoluciones del mismo tipo en una sola query batch, ejecutada
al final del tick de evento. Es la solución estándar al N+1 en GraphQL.

### Contexto de Apollo Server

El contexto se crea por request y se pasa a todos los resolvers. Es el lugar
correcto para poner: el usuario autenticado, instancias de DataLoader, y el
cliente de BD. Los DataLoaders DEBEN crearse en el contexto (nuevo por request),
no como singletons globales.

## Reglas obligatorias

### DataLoader OBLIGATORIO para resolver relaciones en listas

```typescript
// MAL — N+1: por cada post se hace una query para el autor
const resolvers = {
    Post: {
        autor: async (post) => {
            return db.usuario.findUnique({ where: { id: post.autorId } });
        },
    },
};

// BIEN — DataLoader agrupa los IDs en una sola query batch
// En el contexto:
const context = async ({ req }) => ({
    usuarioLoader: new DataLoader(async (ids: readonly string[]) => {
        const usuarios = await db.usuario.findMany({
            where: { id: { in: [...ids] } },
        });
        const mapa = new Map(usuarios.map(u => [u.id, u]));
        return ids.map(id => mapa.get(id) ?? null);
    }),
});

// En el resolver:
Post: {
    autor: (post, _, { usuarioLoader }) => usuarioLoader.load(post.autorId),
},
```

### Autenticación en el contexto — nunca en cada resolver individualmente

```typescript
// MAL — verificar auth en cada resolver
const resolvers = {
    Query: {
        miPerfil: async (_, __, { req }) => {
            const token = req.headers.authorization;
            const usuario = verificarToken(token); // duplicado en cada resolver
            if (!usuario) throw new GraphQLError('No autenticado');
        },
    },
};

// BIEN — una vez en el contexto
const context = async ({ req }) => {
    const token = req.headers.authorization?.split(' ')[1];
    const usuario = token ? verificarToken(token) : null;
    return { usuario, db, usuarioLoader: crearUsuarioLoader() };
};

// Resolver usa el usuario del contexto
Query: {
    miPerfil: (_, __, { usuario }) => {
        if (!usuario) throw new GraphQLError('No autenticado',
            { extensions: { code: 'UNAUTHENTICATED' } });
        return usuario;
    },
},
```

### Validación de inputs con tipos estrictos en el schema

```graphql
# MAL — input sin restricciones
input CrearProductoInput {
  nombre: String     # permite null, string vacio, etc.
  precio: Float
}

# BIEN — tipos no-nullable donde aplica, scalares custom para restricciones
scalar PositiveFloat
scalar NonEmptyString

input CrearProductoInput {
  nombre:      NonEmptyString!
  precio:      PositiveFloat!
  categoriaId: ID!
}
```

### Paginación cursor-based (Relay spec) para listas grandes

```graphql
type ProductoConnection {
  edges: [ProductoEdge!]!
  pageInfo: PageInfo!
}

type ProductoEdge {
  node: Producto!
  cursor: String!
}

type PageInfo {
  hasNextPage:     Boolean!
  hasPreviousPage: Boolean!
  startCursor:     String
  endCursor:       String
}

type Query {
  productos(first: Int, after: String, last: Int, before: String): ProductoConnection!
}
```

## Patrones recomendados

### Schema SDL con organización por dominio

```graphql
# schema/producto.graphql
type Producto {
  id:          ID!
  nombre:      String!
  precio:      Float!
  stock:       Int!
  categoria:   Categoria!
  resenas:     [Resena!]!
  creadoEn:    DateTime!
}

type Query {
  producto(id: ID!): Producto
  productos(filtros: FiltrosProductoInput, paginacion: PaginacionInput): ProductoConnection!
}

type Mutation {
  crearProducto(input: CrearProductoInput!): CrearProductoPayload!
  actualizarProducto(id: ID!, input: ActualizarProductoInput!): ActualizarProductoPayload!
}
```

### Error handling con union types

```graphql
# En lugar de lanzar excepciones, modelar errores como tipos
union CrearProductoPayload = ProductoCreado | ErrorValidacion | ErrorAutorizacion

type ProductoCreado { producto: Producto! }
type ErrorValidacion { campo: String!, mensaje: String! }
type ErrorAutorizacion { mensaje: String! }
```

```typescript
// Resolver retorna el tipo correcto segun el caso
Mutation: {
    crearProducto: async (_, { input }, { usuario }) => {
        if (!usuario?.esAdmin) return { __typename: 'ErrorAutorizacion', mensaje: 'Sin permiso' };
        const validacion = validarInput(input);
        if (!validacion.ok) return { __typename: 'ErrorValidacion', ...validacion };
        const producto = await db.producto.create({ data: input });
        return { __typename: 'ProductoCreado', producto };
    },
},
```

### Subscriptions con filtrado por usuario

```typescript
const resolvers = {
    Subscription: {
        pedidoActualizado: {
            subscribe: withFilter(
                () => pubsub.asyncIterator(['PEDIDO_ACTUALIZADO']),
                (payload, variables, context) =>
                    payload.pedidoActualizado.usuarioId === context.usuario?.id
            ),
        },
    },
};
```

### Depth limiting y complexity limits

```typescript
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
    typeDefs,
    resolvers,
    validationRules: [
        depthLimit(7),                        // Maximo 7 niveles de anidamiento
        createComplexityLimitRule(1000),       // Score de complejidad maximo
    ],
});
```

## Anti-patrones conocidos

### Resolver con lógica de negocio — usar services

```typescript
// MAL — logica de negocio en el resolver
Mutation: {
    crearPedido: async (_, { items }, { usuario, db }) => {
        // 40 lineas de calculo de descuentos, validacion de stock, etc.
    },
},

// BIEN — resolver delega a un service
Mutation: {
    crearPedido: async (_, { items }, { usuario, pedidoService }) => {
        return pedidoService.crear({ usuario, items });
    },
},
```

### Mutations sin payload tipado

```graphql
# MAL — mutation que solo retorna Boolean o el objeto
type Mutation {
  eliminarProducto(id: ID!): Boolean
}

# BIEN — payload con información útil y manejo de errores
type Mutation {
  eliminarProducto(id: ID!): EliminarProductoPayload!
}
union EliminarProductoPayload = ProductoEliminado | ErrorNotFound
```

### DataLoader como singleton global

```typescript
// MAL — comparte cache entre requests de distintos usuarios
const usuarioLoader = new DataLoader(batchFn); // modulo global

// BIEN — nuevo por request en el contexto
const context = async ({ req }) => ({
    usuarioLoader: new DataLoader(batchFn),
});
```

## Gotchas / Errores comunes no obvios

**DataLoader retorna `null` para IDs no encontrados pero el resolver espera el objeto**: si la función batch de DataLoader retorna `ids.map(id => mapa.get(id) ?? null)` y el resolver tipado espera `User` (no nullable), GraphQL lanza un error de tipo en runtime aunque el campo del schema sea `User` (no `User!`). Causa: un `null` en DataLoader se propaga como valor del campo aunque no se haya declarado como nullable en el schema. Fix: si el campo es non-nullable en el schema (`User!`), la función batch debe lanzar un error para IDs no encontrados en lugar de retornar `null`, o usar `notFound: true` si la librería lo soporta.

**`context` del Apollo Server no se re-crea entre operaciones en subscriptions**: en queries y mutations, la función `context()` se llama una vez por request. En subscriptions WebSocket, el contexto se crea cuando el cliente conecta y persiste mientras dura la conexión — no por cada mensaje. Causa: WebSocket es una conexión persistente, no un request/response. Fix: si el contexto necesita datos frescos por operación (ej: validar token en cada mensaje), usar el hook `onOperation` del transporte WebSocket para re-validar.

**Mutations con `__typename` en union types no funciona si el resolver no lo retorna explícitamente**: `{ __typename: 'ErrorValidacion', campo: 'email', mensaje: '...' }` puede fallar si Apollo no puede resolver el `__typename` automáticamente del objeto retornado sin `resolveType`. Causa: para union types, Apollo necesita saber a qué tipo mapear el objeto — lo detecta por `__typename` en el objeto o por la función `__resolveType` en el resolver. Fix: siempre incluir `__typename` explícitamente en el objeto retornado o definir `__resolveType` en el resolver de la union.

**`depthLimit(N)` cuenta fragmentos anidados una vez, no por uso**: si un fragmento `F` se usa 3 veces en una query pero el fragmento en sí tiene profundidad 3, `depthLimit` cuenta la profundidad del fragmento una vez, no multiplica. Pero inline fragments y `... on Tipo` sí cuentan en la profundidad. Causa: la librería `graphql-depth-limit` analiza el documento AST con reglas específicas. Fix: complementar depth limit con complexity limit para capturar queries que abusan de fragmentos reutilizados para multiplicar la carga sin exceder la profundidad.

## Checklist de verificación

- [ ] DataLoader para cada relación que se resuelve en listas
- [ ] DataLoaders creados en el contexto (uno por request, no globales)
- [ ] Autenticación verificada en el contexto, no en cada resolver
- [ ] Inputs con tipos no-nullable donde el campo es requerido
- [ ] Paginación cursor-based para colecciones grandes
- [ ] Depth limit y complexity limit configurados
- [ ] Error handling con union types, no solo excepciones

## Referencias

- [GraphQL Spec](https://spec.graphql.org) | [Apollo Server](https://www.apollographql.com/docs/apollo-server) | [DataLoader](https://github.com/graphql/dataloader)
