Preguntas de entrevista de GraphQL — 35 con código y respuestas
Si te preparás para una entrevista backend o full-stack y mencionás GraphQL en tu CV, esperá que el entrevistador vaya fondo. No alcanza con saber que "es una alternativa a REST" — necesitás entender el modelo de ejecución, los patrones reales de producción y dónde duele. Esta guía cubre 35 preguntas con respuestas completas y código en Apollo Server, Node.js y TypeScript.
Fundamentos: REST vs GraphQL
1. ¿Cuál es la diferencia central entre REST y GraphQL?
REST expone múltiples endpoints, cada uno representando un recurso. El servidor decide qué datos devuelve. GraphQL expone un único endpoint y el cliente especifica exactamente qué campos necesita.
Esto resuelve dos problemas clásicos de REST:
- Over-fetching: recibís más datos de los que necesitás (una respuesta
/users/:idque devuelve 40 campos cuando solo necesitás el nombre). - Under-fetching: necesitás hacer múltiples requests para ensamblar la vista (
/users/:id, luego/users/:id/posts, luego/posts/:id/comments).
// REST: tres llamadas para mostrar un perfil
GET /users/42
GET /users/42/posts
GET /posts/7/comments
// GraphQL: una sola query
query {
user(id: "42") {
name
email
posts {
title
comments {
body
author { name }
}
}
}
}Lo que busca el entrevistador: que entiendas el problema real que resuelve GraphQL, no solo la definición de libro. Mencioná over-fetching y under-fetching; son las palabras clave que esperan escuchar.
2. ¿En qué casos seguirías usando REST en lugar de GraphQL?
GraphQL no es la bala de plata. Seguirías con REST cuando:
- La API es pública y simple (payloads bien definidos, sin necesidad de flexibilidad en el cliente).
- Necesitás caché HTTP nativo sin complejidad adicional — REST usa métodos GET con URLs cacheables directamente en CDNs; GraphQL sobre HTTP POST no tiene eso out of the box.
- El equipo es chico y el overhead de schemas, resolvers y el tooling no justifica el costo.
- El cliente es siempre el mismo (ej: una API interna que consume un solo servicio).
REST también gana en uploads de archivos — GraphQL no tiene soporte nativo para multipart y requiere workarounds como graphql-upload.
3. ¿Qué es el SDL (Schema Definition Language)?
El SDL es el lenguaje de texto que usás para definir el schema de GraphQL — los tipos, sus campos, las relaciones y las operaciones disponibles.
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
}
type Post {
id: ID!
title: String!
body: String
author: User!
}
type Query {
user(id: ID!): User
posts: [Post!]!
}
type Mutation {
createPost(title: String!, body: String!): Post!
}El ! marca un campo como non-null. [Post!]! significa: un array no nulo que solo contiene elementos no nulos.
Queries y Mutations
4. ¿Qué diferencia hay entre una Query y una Mutation?
Semánticamente, una Query es de solo lectura y una Mutation modifica datos. GraphQL garantiza que las Queries pueden ejecutarse en paralelo; las Mutations se ejecutan en serie (una tras otra en el orden en que las escribís).
# Query — lectura
query GetUser($id: ID!) {
user(id: $id) {
name
email
}
}
# Mutation — escritura
mutation CreatePost($title: String!, $body: String!) {
createPost(title: $title, body: $body) {
id
title
createdAt
}
}En el servidor con Apollo:
const resolvers = {
Query: {
user: async (_: unknown, { id }: { id: string }, context: Context) => {
return context.db.users.findById(id);
},
},
Mutation: {
createPost: async (
_: unknown,
{ title, body }: { title: string; body: string },
context: Context
) => {
if (!context.currentUser) throw new AuthenticationError("No autenticado");
return context.db.posts.create({
title,
body,
authorId: context.currentUser.id,
});
},
},
};5. ¿Qué son los fragments y para qué los usás?
Los fragments son piezas reutilizables de una query. Te evitan repetir la selección de campos cuando la misma estructura aparece en múltiples lugares.
fragment UserBasic on User {
id
name
email
}
query GetUsers {
admins {
...UserBasic
role
}
editors {
...UserBasic
department
}
}En el front, los frameworks como Relay los usan mucho para colocar las necesidades de datos junto al componente que los consume (co-location).
6. ¿Qué son las variables en GraphQL y por qué son importantes para la seguridad?
Las variables permiten parametrizar las operaciones. En vez de interpolar valores directamente en la query string (lo que abriría la puerta a injection), se pasan por separado.
# ❌ Mal — interpolación manual (nunca hagás esto)
query {
user(id: "${userId}") { name }
}
# ✅ Bien — variable tipada
query GetUser($id: ID!) {
user(id: $id) { name }
}Variables en la llamada HTTP:
{
"query": "query GetUser($id: ID!) { user(id: $id) { name } }",
"variables": { "id": "42" }
}Seguridad: GraphQL valida las variables contra el tipo definido en el schema antes de ejecutar los resolvers. No podés pasar un objeto malicioso donde se espera un ID.
7. ¿Qué son los aliases en GraphQL?
Los aliases te permiten renombrar el resultado de un campo en la respuesta. Son útiles cuando necesitás llamar al mismo campo dos veces con distintos argumentos.
query ComparePosts {
published: posts(status: PUBLISHED) {
id
title
}
drafts: posts(status: DRAFT) {
id
title
}
}Sin aliases, el servidor devolvería dos claves posts y la segunda pijaría a la primera.
Subscriptions en tiempo real
8. ¿Cómo funcionan las Subscriptions en GraphQL?
Las Subscriptions son operaciones de larga duración. En lugar del ciclo request/response, el cliente establece una conexión persistente (generalmente WebSocket) y el servidor pushea actualizaciones cada vez que ocurre un evento relevante.
subscription OnNewMessage($channelId: ID!) {
messageSent(channelId: $channelId) {
id
body
sender { name }
createdAt
}
}En Apollo Server con graphql-ws:
import { createServer } from "http";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
import { PubSub } from "graphql-subscriptions";
const pubsub = new PubSub();
const MESSAGE_SENT = "MESSAGE_SENT";
const resolvers = {
Mutation: {
sendMessage: async (_: unknown, { channelId, body }: any, ctx: Context) => {
const message = await ctx.db.messages.create({ channelId, body, senderId: ctx.currentUser.id });
await pubsub.publish(MESSAGE_SENT, { messageSent: message, channelId });
return message;
},
},
Subscription: {
messageSent: {
subscribe: (_: unknown, { channelId }: { channelId: string }) =>
pubsub.asyncIterator([MESSAGE_SENT]),
resolve: (payload: any) => payload.messageSent,
},
},
};Trampa común: PubSub de graphql-subscriptions es in-memory, solo funciona con una instancia del proceso. En producción necesitás graphql-redis-subscriptions o un broker externo para deployments con múltiples pods.
9. ¿Cuándo usarías una Subscription vs polling?
| Criterio | Subscription | Polling |
|---|---|---|
| Frecuencia de eventos | Alta o impredecible | Baja y predecible |
| Latencia requerida | Baja (tiempo real) | Tolerable (segundos) |
| Complejidad infra | Mayor (WebSockets, broker) | Menor |
| Escalabilidad | Requiere sticky sessions o pub/sub | Stateless, escala fácil |
Para un chat o un tablero de precios en tiempo real → Subscription. Para "refrescar cada 30 segundos si hay notificaciones" → polling con una Query ordinaria.
Diseño de schema: Types, Interfaces y Unions
10. ¿Cuál es la diferencia entre Interface y Union en GraphQL?
- Una Interface define un contrato de campos que todos los tipos que la implementan deben tener.
- Una Union agrupa tipos que no necesariamente comparten campos.
# Interface — todos los tipos tienen id y createdAt
interface Node {
id: ID!
createdAt: String!
}
type User implements Node {
id: ID!
createdAt: String!
email: String!
}
type Post implements Node {
id: ID!
createdAt: String!
title: String!
}
# Union — tipos sin campos en común
union SearchResult = User | Post | Comment
type Query {
node(id: ID!): Node
search(query: String!): [SearchResult!]!
}Al consultar una Union necesitás inline fragments para acceder a campos específicos:
query Search($q: String!) {
search(query: $q) {
__typename
... on User { name email }
... on Post { title body }
... on Comment { body author { name } }
}
}11. ¿Qué son los Input Types y por qué no reutilizás los tipos regulares para argumentos?
Los Input Types son versiones de los tipos de datos pensadas específicamente para usarse como argumentos de mutations. Los tipos regulares pueden tener resolvers, referencias circulares complejas o campos que no tiene sentido recibir del cliente (como id en una creación).
input CreateUserInput {
name: String!
email: String!
password: String!
}
input UpdateUserInput {
name: String
email: String
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
}Error común en entrevistas: decir que podés usar un type donde va un input. GraphQL no lo permite — los tipos de output y de input son mundos separados.
12. ¿Qué son los Enums y los Scalars personalizados?
Enums definen un conjunto cerrado de valores válidos:
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
type Post {
status: PostStatus!
}Scalars personalizados permiten tipos que GraphQL no tiene por defecto (Date, Email, URL, JSON):
import { GraphQLScalarType, Kind } from "graphql";
const DateScalar = new GraphQLScalarType({
name: "Date",
description: "Fecha en formato ISO 8601",
serialize(value: unknown) {
if (value instanceof Date) return value.toISOString();
throw new Error("DateScalar solo serializa objetos Date");
},
parseValue(value: unknown) {
if (typeof value === "string") return new Date(value);
throw new Error("DateScalar espera un string");
},
parseLiteral(ast) {
if (ast.kind === Kind.STRING) return new Date(ast.value);
throw new Error("DateScalar espera un string literal");
},
});Resolvers y Context
13. ¿Qué son los cuatro argumentos de un resolver?
Todo resolver recibe exactamente cuatro argumentos:
type Resolver<Parent = unknown, Args = Record<string, unknown>, Context = unknown, Return = unknown> = (
parent: Parent, // resultado del resolver padre
args: Args, // argumentos de la query/mutation
context: Context, // objeto compartido (db, usuario autenticado, etc.)
info: GraphQLResolveInfo // metadata del AST de la query
) => Return | Promise<Return>;Ejemplo práctico:
const resolvers = {
User: {
// parent = objeto User del resolver padre
posts: async (parent: User, _args: unknown, ctx: Context) => {
return ctx.loaders.postsByAuthor.load(parent.id);
},
},
Query: {
// parent = undefined (resolver raíz)
user: async (_parent: unknown, { id }: { id: string }, ctx: Context) => {
return ctx.db.users.findById(id);
},
},
};14. ¿Cómo construís el Context y qué va ahí?
El context es el único mecanismo correcto para inyectar dependencias compartidas entre resolvers sin globals. Va ahí:
- La conexión a base de datos o el ORM
- El usuario autenticado (decodificado del JWT)
- DataLoaders (un DataLoader por request)
- Loggers, tracers
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import jwt from "jsonwebtoken";
interface Context {
currentUser: User | null;
db: Database;
loaders: ReturnType<typeof createLoaders>;
}
const server = new ApolloServer<Context>({ typeDefs, resolvers });
app.use(
"/graphql",
expressMiddleware(server, {
context: async ({ req }): Promise<Context> => {
const token = req.headers.authorization?.replace("Bearer ", "");
let currentUser: User | null = null;
if (token) {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
currentUser = await db.users.findById(payload.userId);
} catch {
// token inválido — no tiramos error, resolvemos como anónimo
}
}
return {
currentUser,
db,
loaders: createLoaders(db), // nuevo DataLoader por request
};
},
})
);Importante: los DataLoaders se crean frescos en cada request. Si los reutilizás entre requests, el caché interno va a devolver datos stale.
15. ¿Qué son los resolvers de campo y cuándo los necesitás?
GraphQL tiene un resolver por defecto que simplemente devuelve parent[fieldName]. Los resolvers de campo (field resolvers) los escribís solo cuando la lógica es más compleja:
const resolvers = {
User: {
// Campo derivado — no existe en DB, lo calculamos
fullName: (parent: User) => `${parent.firstName} ${parent.lastName}`,
// Campo que requiere una query separada
posts: async (parent: User, _: unknown, ctx: Context) => {
return ctx.loaders.postsByAuthor.load(parent.id);
},
// Transformación de formato
createdAt: (parent: User) => new Date(parent.createdAt).toISOString(),
},
};El problema N+1 y DataLoader
16. Explicá el problema N+1 en GraphQL
El N+1 es el bug de rendimiento más típico en GraphQL. Ocurre cuando resolvés una lista y cada elemento dispara una query adicional a la base de datos.
// Schema:
// posts: [Post!]!
// Post.author: User!
// Query del cliente:
// { posts { title author { name } } }
const resolvers = {
Query: {
posts: () => db.posts.findAll(), // 1 query → devuelve 100 posts
},
Post: {
// ❌ Este resolver se ejecuta 100 veces, uno por post
author: (parent: Post) => db.users.findById(parent.authorId), // 100 queries
},
};
// Total: 1 + 100 = 101 queries para mostrar 100 postsCon 100 posts tenés 101 queries. Con 1000, tenés 1001. La base de datos explota.
17. ¿Cómo resolvés el N+1 con DataLoader?
DataLoader agrupa (batch) múltiples llamadas que ocurren en el mismo tick del event loop y las ejecuta en una sola query.
import DataLoader from "dataloader";
// Función batch: recibe un array de IDs, devuelve un array de resultados en el mismo orden
async function batchUsers(ids: readonly string[]): Promise<User[]> {
const users = await db.users.findByIds([...ids]);
// DataLoader exige que el resultado esté en el mismo orden que los IDs
const userMap = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => userMap.get(id) ?? new Error(`User ${id} not found`));
}
// Creás el loader por request (en el context factory)
function createLoaders(db: Database) {
return {
users: new DataLoader<string, User>(batchUsers),
};
}
// En el resolver:
const resolvers = {
Post: {
// ✅ DataLoader agrupa los 100 `load(authorId)` en una sola query
author: (parent: Post, _: unknown, ctx: Context) => {
return ctx.loaders.users.load(parent.authorId);
},
},
};
// Total: 2 queries (1 para posts, 1 para todos los autores)DataLoader también tiene caché por request: si el mismo ID se pide dos veces, se devuelve el resultado cacheado sin ir a la DB.
18. ¿Cuándo DataLoader no alcanza y qué más podés hacer?
DataLoader resuelve el N+1 para relaciones uno-a-uno o muchos-a-uno. Para relaciones uno-a-muchos o muchos-a-muchos el batch function puede volverse complejo. Otras estrategias:
- Join Monster o Prisma: generan JOINs SQL inteligentes basados en el campo
infodel resolver. - Persisted Queries: evitás queries ad-hoc en producción; ejecutás solo queries pre-autorizadas.
- Query complexity limits: limitás el depth o la complejidad del grafo para prevenir queries abusivas.
import { createComplexityRule, fieldExtensionsEstimator, simpleEstimator } from "graphql-query-complexity";
import { validate } from "graphql";
const complexityRule = createComplexityRule({
maximumComplexity: 1000,
variables: {},
onComplete: (complexity: number) => console.log("Query complexity:", complexity),
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
});Paginación
19. ¿Cuál es la diferencia entre paginación offset y cursor-based?
Offset-based (LIMIT/OFFSET en SQL):
query {
posts(offset: 20, limit: 10) {
id
title
}
}Simple de implementar pero tiene problemas:
- Si se inserta un item entre la página 2 y la 3, el usuario ve duplicados o se saltea items.
- Escala mal en tablas grandes (la DB tiene que contar todos los registros previos).
Cursor-based (Relay spec):
query {
posts(first: 10, after: "cursor_opaco_base64") {
edges {
cursor
node {
id
title
}
}
pageInfo {
hasNextPage
endCursor
}
}
}El cursor apunta a un registro específico (generalmente el ID o un timestamp). No hay problema con inserciones concurrentes y escala bien.
20. Implementá cursor-based pagination en TypeScript
function encodeCursor(id: string): string {
return Buffer.from(`cursor:${id}`).toString("base64");
}
function decodeCursor(cursor: string): string {
return Buffer.from(cursor, "base64").toString("utf8").replace("cursor:", "");
}
const resolvers = {
Query: {
posts: async (
_: unknown,
{ first = 10, after }: { first?: number; after?: string },
ctx: Context
) => {
const afterId = after ? decodeCursor(after) : null;
const posts = await ctx.db.posts.findMany({
take: first + 1, // pedimos uno extra para saber si hay siguiente página
cursor: afterId ? { id: afterId } : undefined,
skip: afterId ? 1 : 0,
orderBy: { createdAt: "desc" },
});
const hasNextPage = posts.length > first;
const edges = posts.slice(0, first).map((post) => ({
cursor: encodeCursor(post.id),
node: post,
}));
return {
edges,
pageInfo: {
hasNextPage,
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null,
},
};
},
},
};Autenticación y Autorización
21. ¿Cuál es la diferencia entre autenticación y autorización en GraphQL?
- Autenticación: ¿quién sos? Se valida en el
contextfactory, antes de ejecutar ningún resolver. - Autorización: ¿podés hacer esto? Se implementa dentro de los resolvers o en una capa de directivas.
// Autenticación — en el context factory (ya lo vimos arriba)
// Si el token es inválido, currentUser es null
// Autorización — en el resolver
const resolvers = {
Mutation: {
deletePost: async (_: unknown, { id }: { id: string }, ctx: Context) => {
if (!ctx.currentUser) {
throw new GraphQLError("No autenticado", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const post = await ctx.db.posts.findById(id);
if (!post) throw new GraphQLError("Post no encontrado", { extensions: { code: "NOT_FOUND" } });
if (post.authorId !== ctx.currentUser.id && ctx.currentUser.role !== "ADMIN") {
throw new GraphQLError("Sin permisos para eliminar este post", {
extensions: { code: "FORBIDDEN" },
});
}
return ctx.db.posts.delete(id);
},
},
};22. ¿Qué son las Schema Directives y cómo las usás para autorización?
Las directivas son anotaciones en el schema que modifican el comportamiento de la ejecución. Una directiva @auth es el patrón más común para centralizar la autorización:
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role {
USER
ADMIN
}
type Mutation {
deletePost(id: ID!): Boolean! @auth(requires: ADMIN)
createPost(input: CreatePostInput!): Post! @auth
}import { mapSchema, getDirective, MapperKind } from "@graphql-tools/utils";
import { defaultFieldResolver } from "graphql";
function authDirectiveTransformer(schema: GraphQLSchema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, "auth")?.[0];
if (!authDirective) return fieldConfig;
const { requires = "USER" } = authDirective;
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
resolve: async (source, args, context: Context, info) => {
if (!context.currentUser) {
throw new GraphQLError("No autenticado", {
extensions: { code: "UNAUTHENTICATED" },
});
}
if (requires === "ADMIN" && context.currentUser.role !== "ADMIN") {
throw new GraphQLError("Acceso denegado", {
extensions: { code: "FORBIDDEN" },
});
}
return resolve(source, args, context, info);
},
};
},
});
}23. ¿Cómo protegés una API GraphQL de ataques de complejidad o profundidad excesiva?
Un cliente malicioso podría enviar una query con profundidad infinita:
# Query de "death star" — amigos de amigos infinitos
{
user(id: "1") {
friends {
friends {
friends {
friends { ... }
}
}
}
}
}Para protegerte:
import depthLimit from "graphql-depth-limit";
import { createComplexityRule } from "graphql-query-complexity";
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(7), // máximo 7 niveles de profundidad
createComplexityRule({
maximumComplexity: 500,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
}),
],
});Error Handling
24. ¿Cómo maneja GraphQL los errores y cómo los exponés al cliente?
GraphQL tiene una diferencia clave con REST: siempre devuelve HTTP 200. Los errores van en el campo errors de la respuesta:
{
"data": { "user": null },
"errors": [
{
"message": "Usuario no encontrado",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"],
"extensions": { "code": "NOT_FOUND" }
}
]
}En Apollo Server v4:
import { GraphQLError } from "graphql";
const resolvers = {
Query: {
user: async (_: unknown, { id }: { id: string }, ctx: Context) => {
const user = await ctx.db.users.findById(id);
if (!user) {
throw new GraphQLError("Usuario no encontrado", {
extensions: {
code: "NOT_FOUND",
http: { status: 404 }, // Apollo v4 respeta esto para el status HTTP
},
});
}
return user;
},
},
};Formateo global de errores (importante para no exponer stacktraces en producción):
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// En producción: no expongas el stack
if (process.env.NODE_ENV === "production") {
return {
message: formattedError.message,
extensions: { code: formattedError.extensions?.code ?? "INTERNAL_ERROR" },
};
}
return formattedError;
},
});25. ¿Qué es el patrón de "errores como valores" en el schema?
En lugar de lanzar errores, algunos equipos modelan las respuestas de mutations como unions que incluyen el tipo de error:
type CreatePostSuccess {
post: Post!
}
type ValidationError {
field: String!
message: String!
}
union CreatePostResult = CreatePostSuccess | ValidationError
type Mutation {
createPost(input: CreatePostInput!): CreatePostResult!
}const resolvers = {
Mutation: {
createPost: async (_: unknown, { input }: any, ctx: Context) => {
if (!input.title || input.title.length < 3) {
return {
__typename: "ValidationError",
field: "title",
message: "El título necesita al menos 3 caracteres",
};
}
const post = await ctx.db.posts.create(input);
return { __typename: "CreatePostSuccess", post };
},
},
};Ventaja: el cliente sabe en el tipo si la operación tuvo éxito o no, sin parsear el campo errors. Desventaja: complejiza el schema. Usalo para errores de negocio esperados, no para errores de infraestructura.
Testing de Resolvers
26. ¿Cómo testeás resolvers unitariamente?
Los resolvers son funciones puras que reciben argumentos y devuelven datos. Se testean directamente sin levantar el servidor:
import { describe, it, expect, vi } from "vitest";
import { resolvers } from "./resolvers";
describe("Query.user", () => {
it("devuelve el usuario cuando existe", async () => {
const mockUser = { id: "1", name: "Ana García", email: "ana@test.com" };
const mockDb = {
users: { findById: vi.fn().mockResolvedValue(mockUser) },
};
const context = { db: mockDb, currentUser: null, loaders: {} };
const result = await resolvers.Query.user(undefined, { id: "1" }, context as any, {} as any);
expect(result).toEqual(mockUser);
expect(mockDb.users.findById).toHaveBeenCalledWith("1");
});
it("lanza error cuando no existe el usuario", async () => {
const mockDb = { users: { findById: vi.fn().mockResolvedValue(null) } };
const context = { db: mockDb, currentUser: null, loaders: {} };
await expect(
resolvers.Query.user(undefined, { id: "999" }, context as any, {} as any)
).rejects.toThrow("Usuario no encontrado");
});
});27. ¿Cómo testeás la API completa de extremo a extremo?
Para tests de integración, @apollo/server tiene soporte para ejecutar operaciones sin HTTP:
import { ApolloServer } from "@apollo/server";
import { typeDefs } from "./schema";
import { resolvers } from "./resolvers";
describe("Integration: createPost mutation", () => {
let server: ApolloServer;
beforeAll(async () => {
server = new ApolloServer({ typeDefs, resolvers });
await server.start();
});
afterAll(async () => {
await server.stop();
});
it("crea un post cuando el usuario está autenticado", async () => {
const CREATE_POST = `
mutation CreatePost($title: String!, $body: String!) {
createPost(title: $title, body: $body) {
id
title
}
}
`;
const response = await server.executeOperation(
{
query: CREATE_POST,
variables: { title: "Test post", body: "Contenido de prueba" },
},
{
contextValue: {
currentUser: { id: "user-1", role: "USER" },
db: mockDb,
loaders: createLoaders(mockDb),
},
}
);
expect(response.body.kind).toBe("single");
if (response.body.kind === "single") {
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.createPost).toHaveProperty("title", "Test post");
}
});
});28. ¿Cómo testeás que los resolvers requieren autenticación?
it("rechaza la mutation sin autenticación", async () => {
const response = await server.executeOperation(
{ query: CREATE_POST, variables: { title: "Test", body: "Body" } },
{ contextValue: { currentUser: null, db: mockDb, loaders: createLoaders(mockDb) } }
);
if (response.body.kind === "single") {
const errors = response.body.singleResult.errors;
expect(errors).toBeDefined();
expect(errors?.[0].extensions?.code).toBe("UNAUTHENTICATED");
}
});Federation
29. ¿Qué es Apollo Federation y para qué sirve?
Apollo Federation permite dividir un schema GraphQL grande en múltiples subgraphs, cada uno mantenido por un equipo distinto. Un Router (antes Gateway) compone todos los subgraphs en una API unificada que el cliente ve como un solo schema.
Cliente → Apollo Router → Subgraph Users (Node.js)
→ Subgraph Products (Go)
→ Subgraph Orders (Python)Es la solución de GraphQL para arquitecturas de microservicios.
30. ¿Cómo se define una entidad en Federation?
Las entities son tipos que varios subgraphs pueden extender. Se definen con @key:
// Subgraph: users-service
import { buildSubgraphSchema } from "@apollo/subgraph";
import gql from "graphql-tag";
const typeDefs = gql`
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
`;
const resolvers = {
User: {
__resolveReference: async (reference: { id: string }, ctx: Context) => {
return ctx.db.users.findById(reference.id);
},
},
};
export const schema = buildSubgraphSchema({ typeDefs, resolvers });// Subgraph: orders-service
const typeDefs = gql`
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@external"])
type User @key(fields: "id") {
id: ID! @external
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
status: String!
}
`;El Router une ambos subgraphs. Cuando el cliente pide user { name orders { total } }, el Router hace un plan: primero llama a users-service para el nombre, luego a orders-service con el id del usuario para las órdenes.
31. ¿Qué son las directivas `@requires` y `@provides` en Federation?
@requires: le dice al Router que un campo necesita campos del subgraph base antes de resolverse.@provides: le dice al Router que este subgraph puede devolver campos de otra entidad, evitando un fetch adicional.
# En el subgraph de productos
type Product @key(fields: "id") {
id: ID!
name: String!
price: Float!
}
# En el subgraph de reseñas
type Review @key(fields: "id") {
id: ID!
product: Product @provides(fields: "name")
rating: Int!
}
# product.name ya viene con las reseñas — no hace falta llamar al subgraph de productosPreguntas avanzadas y de situación real
32. ¿Cómo implementarías rate limiting en GraphQL?
A diferencia de REST, no podés limitar por endpoint. Limitás por operación o por complejidad:
import { RateLimiterMemory } from "rate-limiter-flexible";
const rateLimiter = new RateLimiterMemory({
points: 100, // 100 operaciones
duration: 60, // por minuto
});
// En el context factory:
context: async ({ req }) => {
const ip = req.ip ?? "unknown";
try {
await rateLimiter.consume(ip);
} catch {
throw new GraphQLError("Demasiadas requests, intentá en un momento", {
extensions: { code: "RATE_LIMITED", http: { status: 429 } },
});
}
// ...resto del context
}Para límites más granulares por operación, podés combinar con graphql-query-complexity.
33. ¿Qué es el caching en GraphQL y por qué es más complejo que en REST?
En REST, el caché HTTP funciona con URLs + métodos GET. En GraphQL todas las queries van al mismo endpoint (POST /graphql), así que el caché HTTP nativo no aplica.
Estrategias:
- 1Persisted Queries / APQ (Automatic Persisted Queries): el cliente envía un hash de la query; si el servidor la reconoce, ejecuta sin recibir el documento completo. Reduce el payload y permite caché en CDN por hash.
- 2Response caching (plugin de Apollo): caché a nivel de resultado de operación:
import responseCachePlugin from "@apollo/server-plugin-response-cache";
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [responseCachePlugin()],
});
// En el schema, marcás qué campos son cacheables:
// type Post @cacheControl(maxAge: 300) { ... }- 3DataLoader cache: ya cubierto — evita queries duplicadas dentro del mismo request.
34. ¿Cuándo usarías `@defer` y `@stream`?
@defer permite que el servidor envíe los datos no críticos en una segunda respuesta incremental, sin bloquear la UI esperando los datos lentos:
query GetPost($id: ID!) {
post(id: $id) {
title
body
# estos comentarios tardan — los deferimos
... on Post @defer {
comments {
body
author { name }
}
}
}
}El cliente recibe title y body inmediatamente, y comments cuando el servidor los tiene listos. Requiere Apollo Server 4+ y soporte en el cliente. @stream hace lo mismo para arrays, enviando cada item a medida que se resuelve.
35. ¿Cómo debuggeás una query lenta en producción?
Un proceso estructurado:
- 1Apollo Studio / Rover: revisá los traces por operación. Apollo Studio muestra el tiempo de cada resolver individualmente.
- 2Logging de resolvers con un plugin:
const resolverTimingPlugin = (): ApolloServerPlugin => ({
requestDidStart: async () => ({
executionDidStart: async () => ({
willResolveField: ({ info }) => {
const start = Date.now();
return () => {
const duration = Date.now() - start;
if (duration > 100) {
console.warn(`Resolver lento: ${info.parentType.name}.${info.fieldName} — ${duration}ms`);
}
};
},
}),
}),
});- 3Revisá si hay N+1: si ves muchas queries repetidas en los logs de tu ORM, un DataLoader lo resuelve.
- 4Analizá el query plan en Federation: el Router de Apollo puede exportar el execution plan; si hay fetches seriales innecesarios,
@provideso reestructurar el schema puede ayudar.
Errores comunes que los entrevistadores buscan
No limpiar el caché del DataLoader entre requests: creás el DataLoader fuera del context factory y todos los requests comparten el caché → datos stale para todos los usuarios.
Exponer stacktraces en producción: sin formatError, Apollo devuelve el stack completo al cliente en errores inesperados.
Schemas circulares sin lazy resolvers: User.posts → Post.author → User.posts puede crear loops. Con lazy resolvers (funciones que devuelven funciones) en makeExecutableSchema de algunos toolkits, o simplemente con resolvers separados, se evita.
Usar type en lugar de input para argumentos de mutations: GraphQL no lo permite y el error de runtime no siempre es claro.
Subscriptions con PubSub in-memory en múltiples instancias: ya mencionado, pero es tan frecuente que vale repetirlo.
Consejos finales para la entrevista
- Mencioná el N+1 antes de que te lo pregunten. Si hablás de relaciones entre tipos, decí: "y acá usaría DataLoader para evitar el N+1". Demuestra que ya pensaste en producción.
- Sabé cuándo NO usar GraphQL. Los mejores candidatos no defienden ciegamente una tecnología.
- Diferenciá errores de negocio de errores de infraestructura. Los primeros pueden ir en el schema como unions; los segundos van en
errors. - Mencioná APQ y caché en CDN si preguntás por performance. Muchos candidatos olvidan que GraphQL tiene workarounds elegantes para el caché HTTP.
- Federation es un diferenciador: pocos candidatos la conocen a fondo. Si la nombrás con
@key,__resolveReferencey@requires, destacás.
Si querés practicar estas respuestas en voz alta antes de la entrevista, [InterviewHack.ai](https://interviewhack.ai) tiene simulaciones de entrevistas técnicas de backend donde podés ensayar exactamente este tipo de preguntas con feedback inmediato.