GraphQL - Pytania Rekrutacyjne i Kompletny Przewodnik 2026

Sławomir Plamowski 25 min czytania
api apollo backend graphql nodejs pytania-rekrutacyjne

"Wyjaśnij różnicę między GraphQL a REST" - to pytanie otwiera większość rozmów o nowoczesnych API. GraphQL stał się standardem w wielu firmach, od startupów po gigantów jak Facebook, GitHub, Shopify. Rekruterzy oczekują nie tylko znajomości składni, ale zrozumienia kiedy GraphQL ma sens, jak rozwiązać N+1 problem, i jak zaimplementować autentykację.

W tym przewodniku znajdziesz 50+ pytań rekrutacyjnych z odpowiedziami, od podstaw GraphQL po zaawansowane tematy jak Federation, caching i optymalizacja wydajności.

GraphQL vs REST - Podstawy

Odpowiedź w 30 sekund

"GraphQL to język zapytań do API gdzie klient określa dokładnie jakie dane chce otrzymać. W przeciwieństwie do REST z wieloma endpointami, GraphQL używa jednego endpointu i pozwala pobierać powiązane dane w jednym zapytaniu. Ma silne typowanie przez schema, co eliminuje over-fetching i under-fetching."

Odpowiedź w 2 minuty

REST i GraphQL to dwa różne podejścia do projektowania API. REST opiera się na zasobach i metodach HTTP, GraphQL na typach i zapytaniach. Kluczowa różnica jest w tym, kto kontroluje kształt odpowiedzi.

┌─────────────────────────────────────────────────────────────────┐
│                    REST vs GraphQL                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  REST: Serwer decyduje co zwrócić                              │
│  ────────────────────────────────                              │
│  GET /users/1                                                   │
│  → { id, name, email, avatar, createdAt, settings, ... }       │
│                                                                 │
│  GET /users/1/posts                                            │
│  → [{ id, title, content, ... }, ...]                          │
│                                                                 │
│  GET /users/1/posts/5/comments                                 │
│  → [{ id, text, author, ... }, ...]                            │
│                                                                 │
│  = 3 requesty, dużo niepotrzebnych danych (over-fetching)      │
│                                                                 │
│  ─────────────────────────────────────────────────────────────  │
│                                                                 │
│  GraphQL: Klient decyduje co chce                              │
│  ────────────────────────────────                              │
│  query {                                                        │
│    user(id: 1) {                                               │
│      name                                                       │
│      posts(first: 5) {                                         │
│        title                                                    │
│        comments(first: 3) {                                    │
│          text                                                   │
│          author { name }                                        │
│        }                                                        │
│      }                                                          │
│    }                                                            │
│  }                                                              │
│                                                                 │
│  = 1 request, dokładnie te dane które potrzebujemy             │
└─────────────────────────────────────────────────────────────────┘

Porównanie szczegółowe:

Aspekt REST GraphQL
Endpointy Wiele (/users, /posts) Jeden (/graphql)
Data fetching Fixed response Client-specified
Typowanie Opcjonalne (OpenAPI) Wbudowane (Schema)
Wersjonowanie URL (/v1/, /v2/) Schema evolution
Caching HTTP cache (łatwe) Wymaga strategii
Real-time Polling/WebHooks Subscriptions
Learning curve Niższa Wyższa

Schema i Typy - Pytania Rekrutacyjne

1. Jak zdefiniować schema w GraphQL?

Odpowiedź:

Schema to kontrakt między klientem a serwerem. Definiuje typy danych, queries, mutations i subscriptions.

# Podstawowe typy skalarne
# String, Int, Float, Boolean, ID

# Custom type
type User {
  id: ID!                    # ! = non-nullable
  name: String!
  email: String!
  age: Int
  posts: [Post!]!            # Lista non-null postów
  role: Role!
  createdAt: DateTime!       # Custom scalar
}

type Post {
  id: ID!
  title: String!
  content: String
  author: User!
  comments: [Comment!]!
  tags: [String!]
  status: PostStatus!
}

type Comment {
  id: ID!
  text: String!
  author: User!
  post: Post!
}

# Enum
enum Role {
  USER
  ADMIN
  MODERATOR
}

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

# Input type (dla mutations)
input CreatePostInput {
  title: String!
  content: String
  tags: [String!]
}

input UpdatePostInput {
  title: String
  content: String
  tags: [String!]
}

# Query - read operations
type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  post(id: ID!): Post
  posts(status: PostStatus, first: Int, after: String): PostConnection!
  me: User                   # Current authenticated user
}

# Mutation - write operations
type Mutation {
  createUser(name: String!, email: String!): User!
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post
  deletePost(id: ID!): Boolean!
  login(email: String!, password: String!): AuthPayload!
}

# Subscription - real-time
type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}

# Custom types for auth
type AuthPayload {
  token: String!
  user: User!
}

# Relay-style pagination
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

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

Nullable vs Non-nullable:

field: String    # Może być null
field: String!   # Nigdy null
field: [String]  # Lista lub null, elementy mogą być null
field: [String!] # Lista lub null, elementy non-null
field: [String!]!# Lista non-null, elementy non-null

2. Co to są resolvers i jak działają?

Słaba odpowiedź: "Resolvers zwracają dane dla pól."

Mocna odpowiedź:

Resolver to funkcja odpowiedzialna za zwrócenie danych dla konkretnego pola w schema. GraphQL wykonuje resolvers rekurencyjnie, budując odpowiedź.

// Resolver signature
// (parent, args, context, info) => result

const resolvers = {
  Query: {
    // Root resolver - parent jest undefined
    user: async (_, { id }, context) => {
      return context.dataSources.users.findById(id);
    },

    users: async (_, { limit = 10, offset = 0 }, context) => {
      return context.dataSources.users.findAll({ limit, offset });
    },

    me: async (_, __, context) => {
      // Authenticated user from context
      if (!context.user) {
        throw new AuthenticationError('Not authenticated');
      }
      return context.user;
    },
  },

  Mutation: {
    createPost: async (_, { input }, context) => {
      if (!context.user) {
        throw new AuthenticationError('Not authenticated');
      }

      return context.dataSources.posts.create({
        ...input,
        authorId: context.user.id,
      });
    },

    deletePost: async (_, { id }, context) => {
      const post = await context.dataSources.posts.findById(id);

      // Authorization check
      if (post.authorId !== context.user.id) {
        throw new ForbiddenError('Not authorized');
      }

      await context.dataSources.posts.delete(id);
      return true;
    },
  },

  // Field resolvers - parent zawiera dane z parent type
  User: {
    // Resolver dla pola posts na typie User
    posts: async (user, { first = 10 }, context) => {
      // user = parent object (User)
      return context.dataSources.posts.findByAuthorId(user.id, { first });
    },

    // Computed field
    fullName: (user) => `${user.firstName} ${user.lastName}`,
  },

  Post: {
    author: async (post, _, context) => {
      // N+1 problem! Każdy post = osobne query
      return context.dataSources.users.findById(post.authorId);
    },

    comments: async (post, _, context) => {
      return context.dataSources.comments.findByPostId(post.id);
    },
  },

  // Custom scalar
  DateTime: new GraphQLScalarType({
    name: 'DateTime',
    description: 'ISO-8601 DateTime',
    serialize: (value) => value.toISOString(),
    parseValue: (value) => new Date(value),
    parseLiteral: (ast) => new Date(ast.value),
  }),
};

Resolver chain visualization:

query {
  user(id: "1") {      ← Query.user resolver
    name               ← Default resolver (user.name)
    posts {            ← User.posts resolver
      title            ← Default resolver (post.title)
      author {         ← Post.author resolver
        name           ← Default resolver (author.name)
      }
    }
  }
}

Execution order:
1. Query.user(id: "1") → { id: "1", name: "John", ... }
2. User.posts(parent: user) → [{ id: "10", title: "...", authorId: "1" }]
3. Post.author(parent: post) → { id: "1", name: "John" }  ← N+1!

3. Co to jest N+1 problem i jak go rozwiązać z DataLoader?

Odpowiedź:

N+1 problem to sytuacja gdy dla listy N elementów wykonujemy N+1 zapytań do bazy - 1 dla listy i N dla powiązanych danych każdego elementu.

┌─────────────────────────────────────────────────────────────────┐
│                    N+1 PROBLEM                                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Query:                                                        │
│  query {                                                        │
│    posts {           # 1 query: SELECT * FROM posts            │
│      title                                                      │
│      author {        # N queries: SELECT * FROM users          │
│        name          #            WHERE id = ?                 │
│      }               #            (dla każdego posta!)         │
│    }                                                            │
│  }                                                              │
│                                                                 │
│  Bez DataLoader (10 postów = 11 queries):                      │
│  ─────────────────────────────────────────                     │
│  SELECT * FROM posts                                           │
│  SELECT * FROM users WHERE id = 1                              │
│  SELECT * FROM users WHERE id = 2                              │
│  SELECT * FROM users WHERE id = 1  ← duplikat!                 │
│  SELECT * FROM users WHERE id = 3                              │
│  ... (N razy)                                                  │
│                                                                 │
│  Z DataLoader (10 postów = 2 queries):                         │
│  ─────────────────────────────────────                         │
│  SELECT * FROM posts                                           │
│  SELECT * FROM users WHERE id IN (1, 2, 3)  ← batched!        │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

DataLoader implementation:

const DataLoader = require('dataloader');

// Batch function - otrzymuje array keys, zwraca array results
// WAŻNE: Kolejność wyników musi odpowiadać kolejności keys!
const batchUsers = async (userIds) => {
  console.log('Batch loading users:', userIds);
  // userIds = [1, 2, 3, 1, 2] → unique = [1, 2, 3]

  const users = await db.query(
    'SELECT * FROM users WHERE id IN (?)',
    [userIds]
  );

  // Map by id for O(1) lookup
  const userMap = new Map(users.map(user => [user.id, user]));

  // Return in same order as input keys
  return userIds.map(id => userMap.get(id) || null);
};

// Create loader (per-request!)
const createLoaders = () => ({
  userLoader: new DataLoader(batchUsers),
  postLoader: new DataLoader(batchPosts),
  commentLoader: new DataLoader(batchComments),
});

// Apollo Server setup
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => ({
    user: getUserFromToken(req.headers.authorization),
    loaders: createLoaders(), // Fresh loaders per request
  }),
});

// Resolver using DataLoader
const resolvers = {
  Post: {
    author: (post, _, { loaders }) => {
      // DataLoader batches & caches
      return loaders.userLoader.load(post.authorId);
    },
  },

  Comment: {
    author: (comment, _, { loaders }) => {
      // Same loader - will batch with Post.author
      return loaders.userLoader.load(comment.authorId);
    },
  },
};

Jak DataLoader działa:

Event Loop Tick 1:
├─ Post.author resolver → userLoader.load(1)
├─ Post.author resolver → userLoader.load(2)
├─ Post.author resolver → userLoader.load(1)  ← cached from above
└─ Post.author resolver → userLoader.load(3)

End of tick → DataLoader executes batch:
batchUsers([1, 2, 3])  ← deduplicated & batched

Event Loop Tick 2:
└─ Results returned to all waiting resolvers

DataLoader options:

new DataLoader(batchFn, {
  // Cache within request (default: true)
  cache: true,

  // Max batch size
  maxBatchSize: 100,

  // Custom cache key function
  cacheKeyFn: key => key.toString(),

  // Batch scheduling (default: process.nextTick)
  batchScheduleFn: callback => setTimeout(callback, 10),
});

4. Jak zaimplementować autentykację i autoryzację w GraphQL?

Odpowiedź:

Autentykacja i autoryzacja w GraphQL można zaimplementować na kilku poziomach:

// 1. CONTEXT - autentykacja przy każdym request
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ req }) => {
    // Extract token from header
    const token = req.headers.authorization?.replace('Bearer ', '');

    let user = null;
    if (token) {
      try {
        // Verify JWT
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        user = await User.findById(decoded.userId);
      } catch (err) {
        // Invalid token - user remains null
      }
    }

    return {
      user,
      loaders: createLoaders(),
    };
  },
});

// 2. RESOLVER-LEVEL authorization
const resolvers = {
  Query: {
    me: (_, __, { user }) => {
      if (!user) {
        throw new AuthenticationError('Must be logged in');
      }
      return user;
    },

    adminDashboard: (_, __, { user }) => {
      if (!user) {
        throw new AuthenticationError('Must be logged in');
      }
      if (user.role !== 'ADMIN') {
        throw new ForbiddenError('Admin access required');
      }
      return getDashboardData();
    },
  },

  Mutation: {
    deleteUser: async (_, { id }, { user }) => {
      if (!user) throw new AuthenticationError('Must be logged in');
      if (user.role !== 'ADMIN' && user.id !== id) {
        throw new ForbiddenError('Cannot delete other users');
      }
      return User.delete(id);
    },
  },
};

// 3. DIRECTIVE-BASED authorization (cleaner)
// Schema:
// directive @auth(requires: Role = USER) on FIELD_DEFINITION
//
// type Query {
//   me: User @auth
//   adminDashboard: Dashboard @auth(requires: ADMIN)
//   publicPosts: [Post!]!  # No directive = public
// }

const authDirective = (schema, directiveName) => {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const directive = getDirective(schema, fieldConfig, directiveName)?.[0];

      if (directive) {
        const { requires = 'USER' } = directive;
        const originalResolve = fieldConfig.resolve ?? defaultFieldResolver;

        fieldConfig.resolve = async (source, args, context, info) => {
          if (!context.user) {
            throw new AuthenticationError('Must be logged in');
          }

          const roleHierarchy = ['USER', 'MODERATOR', 'ADMIN'];
          const userRoleIndex = roleHierarchy.indexOf(context.user.role);
          const requiredRoleIndex = roleHierarchy.indexOf(requires);

          if (userRoleIndex < requiredRoleIndex) {
            throw new ForbiddenError(`${requires} role required`);
          }

          return originalResolve(source, args, context, info);
        };
      }

      return fieldConfig;
    },
  });
};

// 4. FIELD-LEVEL authorization (fine-grained)
const resolvers = {
  User: {
    email: (user, _, { user: currentUser }) => {
      // Only show email to self or admin
      if (currentUser?.id === user.id || currentUser?.role === 'ADMIN') {
        return user.email;
      }
      return null; // or throw error
    },

    privateData: (user, _, { user: currentUser }) => {
      if (currentUser?.id !== user.id) {
        throw new ForbiddenError('Cannot access other user\'s private data');
      }
      return user.privateData;
    },
  },
};

Login mutation:

const resolvers = {
  Mutation: {
    login: async (_, { email, password }) => {
      const user = await User.findByEmail(email);

      if (!user || !await bcrypt.compare(password, user.passwordHash)) {
        throw new AuthenticationError('Invalid credentials');
      }

      const token = jwt.sign(
        { userId: user.id, role: user.role },
        process.env.JWT_SECRET,
        { expiresIn: '7d' }
      );

      return { token, user };
    },

    register: async (_, { input }) => {
      const passwordHash = await bcrypt.hash(input.password, 10);

      const user = await User.create({
        ...input,
        passwordHash,
        role: 'USER',
      });

      const token = jwt.sign(
        { userId: user.id, role: user.role },
        process.env.JWT_SECRET,
        { expiresIn: '7d' }
      );

      return { token, user };
    },
  },
};

Queries i Mutations - Zaawansowane

5. Jak zaimplementować paginację w GraphQL?

Odpowiedź:

Dwa główne podejścia: offset-based i cursor-based (Relay specification).

# Offset-based (proste, ale problemy ze zmianami danych)
type Query {
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
}

# Cursor-based (Relay spec - stabilne, zalecane)
type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

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

Implementacja cursor-based pagination:

const resolvers = {
  Query: {
    posts: async (_, { first = 10, after, last, before }, { db }) => {
      // Decode cursor (base64 encoded id or timestamp)
      const decodeCursor = (cursor) => {
        if (!cursor) return null;
        return Buffer.from(cursor, 'base64').toString('utf8');
      };

      const encodeCursor = (id) => {
        return Buffer.from(id.toString()).toString('base64');
      };

      let query = db('posts').orderBy('created_at', 'desc');

      // Forward pagination (first + after)
      if (after) {
        const afterId = decodeCursor(after);
        const afterPost = await db('posts').where('id', afterId).first();
        query = query.where('created_at', '<', afterPost.created_at);
      }

      // Backward pagination (last + before)
      if (before) {
        const beforeId = decodeCursor(before);
        const beforePost = await db('posts').where('id', beforeId).first();
        query = query.where('created_at', '>', beforePost.created_at);
      }

      // Fetch one extra to determine hasNextPage
      const limit = first || last || 10;
      const posts = await query.limit(limit + 1);

      const hasMore = posts.length > limit;
      const nodes = hasMore ? posts.slice(0, -1) : posts;

      // Reverse for backward pagination
      if (last) {
        nodes.reverse();
      }

      const edges = nodes.map(post => ({
        node: post,
        cursor: encodeCursor(post.id),
      }));

      const totalCount = await db('posts').count('* as count').first();

      return {
        edges,
        pageInfo: {
          hasNextPage: first ? hasMore : false,
          hasPreviousPage: last ? hasMore : (after ? true : false),
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor,
        },
        totalCount: totalCount.count,
      };
    },
  },
};

Client usage:

# First page
query {
  posts(first: 10) {
    edges {
      node { id, title }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

# Next page
query {
  posts(first: 10, after: "YWJjMTIz") {
    edges {
      node { id, title }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Offset vs Cursor comparison:

Aspekt Offset Cursor
Implementacja Prosta Złożona
Performance O(n) skip O(1) seek
Stabilność Problemy przy insert/delete Stabilna
Random access Tak (page 50) Nie (sekwencyjnie)
Use case Małe datasety Duże datasety, infinite scroll

6. Jak obsłużyć błędy w GraphQL?

Odpowiedź:

GraphQL ma specyficzny sposób obsługi błędów - partial responses i errors array:

// Response format
{
  "data": {
    "user": {
      "name": "John",
      "posts": null  // Partial failure
    }
  },
  "errors": [
    {
      "message": "Failed to fetch posts",
      "path": ["user", "posts"],
      "extensions": {
        "code": "INTERNAL_SERVER_ERROR"
      }
    }
  ]
}

Custom error classes:

const { ApolloError, UserInputError, AuthenticationError, ForbiddenError } = require('apollo-server');

// Custom error
class NotFoundError extends ApolloError {
  constructor(resource, id) {
    super(`${resource} with id ${id} not found`, 'NOT_FOUND', {
      resource,
      id,
    });
  }
}

class ValidationError extends ApolloError {
  constructor(errors) {
    super('Validation failed', 'VALIDATION_ERROR', {
      validationErrors: errors,
    });
  }
}

// Usage in resolvers
const resolvers = {
  Query: {
    user: async (_, { id }, { db }) => {
      const user = await db.users.findById(id);
      if (!user) {
        throw new NotFoundError('User', id);
      }
      return user;
    },
  },

  Mutation: {
    createPost: async (_, { input }, { user }) => {
      // Validation
      const errors = [];
      if (!input.title || input.title.length < 3) {
        errors.push({ field: 'title', message: 'Title must be at least 3 characters' });
      }
      if (input.content && input.content.length > 10000) {
        errors.push({ field: 'content', message: 'Content too long' });
      }
      if (errors.length > 0) {
        throw new ValidationError(errors);
      }

      // Business logic error
      if (await hasReachedPostLimit(user.id)) {
        throw new ApolloError(
          'Post limit reached',
          'POST_LIMIT_EXCEEDED',
          { limit: 100, current: 100 }
        );
      }

      return createPost(input);
    },
  },
};

Error formatting:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (error) => {
    // Log error for monitoring
    console.error('GraphQL Error:', error);

    // Don't expose internal errors in production
    if (process.env.NODE_ENV === 'production') {
      if (error.extensions?.code === 'INTERNAL_SERVER_ERROR') {
        return {
          message: 'An unexpected error occurred',
          extensions: { code: 'INTERNAL_SERVER_ERROR' },
        };
      }
    }

    // Return full error in development
    return error;
  },
});

Union type for errors (alternative pattern):

type Query {
  user(id: ID!): UserResult!
}

union UserResult = User | NotFoundError | PermissionError

type NotFoundError {
  message: String!
  id: ID!
}

type PermissionError {
  message: String!
  requiredRole: Role!
}
const resolvers = {
  Query: {
    user: async (_, { id }, { user: currentUser, db }) => {
      const user = await db.users.findById(id);

      if (!user) {
        return {
          __typename: 'NotFoundError',
          message: `User ${id} not found`,
          id,
        };
      }

      if (user.isPrivate && currentUser?.id !== id) {
        return {
          __typename: 'PermissionError',
          message: 'Cannot view private profile',
          requiredRole: 'ADMIN',
        };
      }

      return { __typename: 'User', ...user };
    },
  },

  UserResult: {
    __resolveType: (obj) => obj.__typename,
  },
};

7. Jak działa Subscriptions w GraphQL?

Odpowiedź:

Subscriptions to operacje GraphQL dla real-time danych, implementowane przez WebSocket.

type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
  userStatusChanged(userId: ID!): UserStatus!
}

type UserStatus {
  userId: ID!
  isOnline: Boolean!
  lastSeen: DateTime
}

Server implementation (Apollo Server + graphql-ws):

const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const { createServer } = require('http');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { PubSub } = require('graphql-subscriptions');

// PubSub for local development
// Use Redis PubSub for production (multiple servers)
const pubsub = new PubSub();

// Event names
const EVENTS = {
  POST_CREATED: 'POST_CREATED',
  COMMENT_ADDED: 'COMMENT_ADDED',
  USER_STATUS_CHANGED: 'USER_STATUS_CHANGED',
};

const resolvers = {
  Mutation: {
    createPost: async (_, { input }, { user, db }) => {
      const post = await db.posts.create({ ...input, authorId: user.id });

      // Publish event
      pubsub.publish(EVENTS.POST_CREATED, { postCreated: post });

      return post;
    },

    addComment: async (_, { postId, text }, { user, db }) => {
      const comment = await db.comments.create({
        postId,
        text,
        authorId: user.id,
      });

      // Publish to specific channel
      pubsub.publish(`${EVENTS.COMMENT_ADDED}.${postId}`, {
        commentAdded: comment,
      });

      return comment;
    },
  },

  Subscription: {
    postCreated: {
      // Subscribe returns AsyncIterator
      subscribe: () => pubsub.asyncIterator([EVENTS.POST_CREATED]),
    },

    commentAdded: {
      subscribe: (_, { postId }) => {
        // Filter by postId
        return pubsub.asyncIterator([`${EVENTS.COMMENT_ADDED}.${postId}`]);
      },
    },

    userStatusChanged: {
      subscribe: withFilter(
        () => pubsub.asyncIterator([EVENTS.USER_STATUS_CHANGED]),
        (payload, variables) => {
          // Only send to subscribers watching this user
          return payload.userStatusChanged.userId === variables.userId;
        }
      ),
    },
  },
};

// Server setup with WebSocket
const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
const httpServer = createServer(app);

// WebSocket server
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});

// Setup graphql-ws
const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => {
      // Auth for WebSocket
      const token = ctx.connectionParams?.authToken;
      const user = token ? await verifyToken(token) : null;
      return { user, pubsub };
    },
    onConnect: async (ctx) => {
      console.log('Client connected');
      // Validate connection
      if (!ctx.connectionParams?.authToken) {
        return false; // Reject connection
      }
      return true;
    },
    onDisconnect: (ctx) => {
      console.log('Client disconnected');
    },
  },
  wsServer
);

// Apollo Server
const server = new ApolloServer({
  schema,
  plugins: [
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          },
        };
      },
    },
  ],
});

await server.start();
app.use('/graphql', expressMiddleware(server));

httpServer.listen(4000);

Client usage (Apollo Client):

import { useSubscription, gql } from '@apollo/client';

const COMMENT_SUBSCRIPTION = gql`
  subscription OnCommentAdded($postId: ID!) {
    commentAdded(postId: $postId) {
      id
      text
      author {
        name
      }
    }
  }
`;

function Comments({ postId }) {
  const { data, loading } = useSubscription(COMMENT_SUBSCRIPTION, {
    variables: { postId },
  });

  if (data?.commentAdded) {
    // New comment received!
    console.log('New comment:', data.commentAdded);
  }

  return <div>...</div>;
}

Production considerations:

// Use Redis PubSub for multi-server setup
const { RedisPubSub } = require('graphql-redis-subscriptions');
const Redis = require('ioredis');

const pubsub = new RedisPubSub({
  publisher: new Redis(process.env.REDIS_URL),
  subscriber: new Redis(process.env.REDIS_URL),
});

Wydajność i Caching

8. Jak zaimplementować caching w GraphQL?

Odpowiedź:

Caching w GraphQL jest trudniejsze niż w REST (brak HTTP cache). Strategie:

// 1. DataLoader caching (per-request, automatic)
const userLoader = new DataLoader(batchUsers);
// Subsequent calls to userLoader.load(1) return cached value

// 2. Application-level cache (Redis)
const Redis = require('ioredis');
const redis = new Redis();

const resolvers = {
  Query: {
    post: async (_, { id }, { redis }) => {
      // Check cache first
      const cacheKey = `post:${id}`;
      const cached = await redis.get(cacheKey);

      if (cached) {
        return JSON.parse(cached);
      }

      // Fetch from DB
      const post = await db.posts.findById(id);

      // Cache for 5 minutes
      await redis.setex(cacheKey, 300, JSON.stringify(post));

      return post;
    },
  },

  Mutation: {
    updatePost: async (_, { id, input }, { redis }) => {
      const post = await db.posts.update(id, input);

      // Invalidate cache
      await redis.del(`post:${id}`);

      return post;
    },
  },
};

// 3. Apollo Server cache hints (CDN caching)
const resolvers = {
  Query: {
    posts: (_, __, ___, info) => {
      // Set cache hint
      info.cacheControl.setCacheHint({ maxAge: 60, scope: 'PUBLIC' });
      return db.posts.findAll();
    },
  },

  Post: {
    __resolveReference: (post, _, info) => {
      info.cacheControl.setCacheHint({ maxAge: 300, scope: 'PUBLIC' });
      return db.posts.findById(post.id);
    },
  },
};

// Schema directives for cache
// type Post @cacheControl(maxAge: 300) {
//   id: ID!
//   title: String!
//   viewCount: Int! @cacheControl(maxAge: 0)  # No cache for dynamic fields
// }

// 4. Persisted Queries (reduce bandwidth, enable CDN)
const { ApolloServerPluginCacheControl } = require('@apollo/server/plugin/cacheControl');
const responseCachePlugin = require('@apollo/server-plugin-response-cache').default;

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    ApolloServerPluginCacheControl({ defaultMaxAge: 60 }),
    responseCachePlugin(),
  ],
});

Apollo Client caching:

import { ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  uri: '/graphql',
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          posts: {
            // Merge paginated results
            keyArgs: ['filter'],
            merge(existing = [], incoming) {
              return [...existing, ...incoming];
            },
          },
        },
      },
      Post: {
        // How to identify Post objects
        keyFields: ['id'],
      },
    },
  }),
});

// Fetch policy options
const { data } = useQuery(GET_POSTS, {
  fetchPolicy: 'cache-first',     // Default: cache then network
  // fetchPolicy: 'network-only', // Always fetch
  // fetchPolicy: 'cache-only',   // Only cache
  // fetchPolicy: 'no-cache',     // Fetch, don't cache
  // fetchPolicy: 'cache-and-network', // Cache immediately, then update
});

9. Jak zabezpieczyć GraphQL przed nadużyciami?

Odpowiedź:

GraphQL jest podatny na kilka rodzajów ataków. Kluczowe zabezpieczenia:

// 1. Query Complexity Analysis
const { createComplexityLimitRule } = require('graphql-validation-complexity');

const complexityLimit = createComplexityLimitRule(1000, {
  scalarCost: 1,
  objectCost: 2,
  listFactor: 10,
});

// Or use graphql-query-complexity
const { getComplexity, simpleEstimator } = require('graphql-query-complexity');

const server = new ApolloServer({
  validationRules: [complexityLimit],
  plugins: [{
    requestDidStart: () => ({
      didResolveOperation({ request, document }) {
        const complexity = getComplexity({
          schema,
          query: document,
          variables: request.variables,
          estimators: [simpleEstimator({ defaultComplexity: 1 })],
        });

        if (complexity > 1000) {
          throw new Error(`Query too complex: ${complexity} > 1000`);
        }
      },
    }),
  }],
});

// 2. Query Depth Limiting
const depthLimit = require('graphql-depth-limit');

const server = new ApolloServer({
  validationRules: [depthLimit(10)],  // Max 10 levels deep
});

// 3. Rate Limiting
const rateLimit = require('express-rate-limit');
const graphqlRateLimit = require('graphql-rate-limit');

// Express-level rate limit
app.use('/graphql', rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
}));

// Field-level rate limit
const rateLimitDirective = graphqlRateLimit.createRateLimitDirective({
  identifyContext: (ctx) => ctx.user?.id || ctx.ip,
});

// type Query {
//   expensiveQuery: Data @rateLimit(window: "1m", max: 10)
// }

// 4. Persisted Queries (whitelist)
const server = new ApolloServer({
  persistedQueries: {
    cache: new RedisCache(),
  },
});

// In production, disable ad-hoc queries
if (process.env.NODE_ENV === 'production') {
  server.plugins.push({
    requestDidStart() {
      return {
        didResolveOperation({ request }) {
          if (!request.extensions?.persistedQuery) {
            throw new Error('Only persisted queries allowed');
          }
        },
      };
    },
  });
}

// 5. Input Validation
const resolvers = {
  Mutation: {
    createPost: async (_, { input }) => {
      // Size limits
      if (input.title.length > 200) {
        throw new UserInputError('Title too long');
      }
      if (input.content.length > 50000) {
        throw new UserInputError('Content too long');
      }

      // Sanitize HTML
      const sanitizedContent = sanitizeHtml(input.content);

      return createPost({ ...input, content: sanitizedContent });
    },
  },
};

// 6. Disable Introspection in Production
const { ApolloServerPluginLandingPageDisabled } = require('@apollo/server/plugin/disabled');

const server = new ApolloServer({
  introspection: process.env.NODE_ENV !== 'production',
  plugins: process.env.NODE_ENV === 'production'
    ? [ApolloServerPluginLandingPageDisabled()]
    : [],
});

Query complexity example:

# High complexity query (attack vector)
query EvilQuery {
  users(first: 100) {          # 100 users
    posts(first: 100) {         # × 100 posts each
      comments(first: 100) {    # × 100 comments each
        author {                # × author for each
          posts(first: 100) {   # × 100 posts... infinite!
            title
          }
        }
      }
    }
  }
}
# Complexity: 100 × 100 × 100 × 100 = 100,000,000!

# Blocked by depth limit (depth > 10) and complexity limit

Federation i Microservices

10. Co to jest Apollo Federation i kiedy go używać?

Odpowiedź:

Apollo Federation pozwala budować distributed GraphQL z wielu microservices:

┌─────────────────────────────────────────────────────────────────┐
│                    APOLLO FEDERATION                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│                    ┌──────────────────┐                        │
│                    │   Apollo Gateway  │                        │
│                    │   (Supergraph)    │                        │
│                    └────────┬─────────┘                        │
│                             │                                   │
│         ┌───────────────────┼───────────────────┐              │
│         │                   │                   │              │
│         ▼                   ▼                   ▼              │
│  ┌────────────┐     ┌────────────┐     ┌────────────┐         │
│  │   Users    │     │   Posts    │     │  Comments  │         │
│  │  Subgraph  │     │  Subgraph  │     │  Subgraph  │         │
│  └────────────┘     └────────────┘     └────────────┘         │
│                                                                 │
│  Każdy subgraph to osobny serwis z własnym schema              │
│  Gateway łączy je w jeden unified API                          │
└─────────────────────────────────────────────────────────────────┘

Users Subgraph:

# users-subgraph/schema.graphql
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0",
        import: ["@key", "@shareable"])

type User @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
}

type Query {
  user(id: ID!): User
  me: User
}

Posts Subgraph (extends User):

# posts-subgraph/schema.graphql
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0",
        import: ["@key", "@external"])

type Post @key(fields: "id") {
  id: ID!
  title: String!
  content: String
  author: User!
}

# Extend User type from users-subgraph
type User @key(fields: "id") {
  id: ID! @external
  posts: [Post!]!  # Add posts field to User
}

type Query {
  post(id: ID!): Post
  posts: [Post!]!
}
// posts-subgraph/resolvers.js
const resolvers = {
  User: {
    // Resolve posts for User entity from users-subgraph
    posts: (user) => {
      return db.posts.findByAuthorId(user.id);
    },
  },

  Post: {
    // Reference resolver - resolve User from just { id }
    author: (post) => {
      return { __typename: 'User', id: post.authorId };
    },
  },
};

Gateway setup:

const { ApolloServer } = require('@apollo/server');
const { ApolloGateway } = require('@apollo/gateway');

const gateway = new ApolloGateway({
  supergraphSdl: new IntrospectAndCompose({
    subgraphs: [
      { name: 'users', url: 'http://users-service:4001/graphql' },
      { name: 'posts', url: 'http://posts-service:4002/graphql' },
      { name: 'comments', url: 'http://comments-service:4003/graphql' },
    ],
  }),
});

const server = new ApolloServer({ gateway });

Federation directives:

Directive Purpose
@key Define entity's primary key
@external Reference field from another subgraph
@provides Specify fields resolved by this subgraph
@requires Fields needed to resolve this field
@shareable Multiple subgraphs can resolve this field
@override Take ownership of field from another subgraph

When to use Federation:

  • Multiple teams own different domains
  • Microservices architecture
  • Need independent deployment
  • Large schema that should be split

When NOT to use:

  • Small team / monolith
  • Simple CRUD application
  • All data in one database
  • Starting new project (add complexity later)

Praktyczne Zadania

Zadanie 1: Debug slow query

Query posts { author { posts { comments }}} jest wolne. Jak to zbadasz?

Podejście:

// 1. Check for N+1
// Add query logging
const db = knex({
  client: 'pg',
  debug: true,  // Log all queries
});

// 2. Add DataLoader if missing
const authorLoader = new DataLoader(async (authorIds) => {
  console.log('Batch loading authors:', authorIds);
  const authors = await db('users').whereIn('id', authorIds);
  return authorIds.map(id => authors.find(a => a.id === id));
});

// 3. Add query complexity limit
// posts(100) → author(100) → posts(100×100) = too much

// 4. Consider denormalization
// Cache author data in post record

Zadanie 2: Implement optimistic locking

Zaimplementuj mutation z optimistic locking dla concurrent updates.

Rozwiązanie:

type Post {
  id: ID!
  title: String!
  version: Int!  # For optimistic locking
}

input UpdatePostInput {
  title: String
  expectedVersion: Int!  # Client sends current version
}
const resolvers = {
  Mutation: {
    updatePost: async (_, { id, input }, { db }) => {
      const { expectedVersion, ...updates } = input;

      const result = await db('posts')
        .where({ id, version: expectedVersion })
        .update({
          ...updates,
          version: db.raw('version + 1'),
        })
        .returning('*');

      if (result.length === 0) {
        throw new ApolloError(
          'Post was modified by another user',
          'CONCURRENT_MODIFICATION',
          { expectedVersion }
        );
      }

      return result[0];
    },
  },
};

Zadanie 3: Design a GraphQL API for e-commerce

Rozwiązanie:

type Query {
  products(filter: ProductFilter, first: Int, after: String): ProductConnection!
  product(id: ID!): Product
  cart: Cart
  orders(first: Int, after: String): OrderConnection!
}

type Mutation {
  addToCart(productId: ID!, quantity: Int!): Cart!
  removeFromCart(itemId: ID!): Cart!
  checkout(input: CheckoutInput!): Order!
  cancelOrder(id: ID!): Order!
}

type Subscription {
  orderStatusChanged(orderId: ID!): OrderStatus!
}

type Product @key(fields: "id") {
  id: ID!
  name: String!
  price: Money!
  inventory: Int!
  category: Category!
  reviews(first: Int): ReviewConnection!
}

type Cart {
  id: ID!
  items: [CartItem!]!
  total: Money!
}

type Order {
  id: ID!
  items: [OrderItem!]!
  status: OrderStatus!
  total: Money!
  createdAt: DateTime!
}

Podsumowanie

GraphQL to potężne narzędzie dla nowoczesnych API. Na rozmowie rekrutacyjnej oczekuj pytań o:

  1. Podstawy - różnica vs REST, schema, typy, resolvers
  2. N+1 problem - DataLoader, batching, caching
  3. Autentykacja - JWT, context, directives
  4. Subscriptions - WebSocket, PubSub, real-time
  5. Performance - caching, complexity limits, security
  6. Federation - microservices, distributed schema

Klucz do sukcesu: zrozumienie trade-offs między GraphQL a REST, i znajomość rozwiązań typowych problemów (N+1, security, caching).


Zobacz też


Artykuł przygotowany przez zespół Flipcards - tworzymy materiały do nauki programowania i przygotowania do rozmów rekrutacyjnych.

Chcesz więcej pytań rekrutacyjnych?

To tylko jeden temat z naszego kompletnego przewodnika po rozmowach rekrutacyjnych. Uzyskaj dostęp do 800+ pytań z 13 technologii.

Kup pełny dostęp Zobacz bezpłatny podgląd
Powrót do blogu

Zostaw komentarz

Pamiętaj, że komentarze muszą zostać zatwierdzone przed ich opublikowaniem.