Zum Inhalt springen

Einen GraphQL-Server von Grund auf bauen

Schritt-für-Schritt zum GraphQL-Server mit TypeScript: Schema-Design, Resolver, Dataloader gegen N+1, Authentifizierung, Fehler und Deployment.

4 Min. Lesezeit
GraphQL-Playground-Oberfläche mit einer Abfrage, einer Antwort und einem Schema-Explorer-Panel nebeneinander

GraphQL löst die Over-Fetching- und Under-Fetching-Probleme von REST, indem Clients genau die Daten anfordern können, die sie brauchen. Statt 3 Endpunkte aufzurufen, um eine Benutzerprofilseite zu rendern (User, Posts, Follower), liefert eine einzige GraphQL-Abfrage alle drei in der Form, die der Client vorgibt.

Dieses Tutorial baut einen produktionsreifen GraphQL-Server von Grund auf mit TypeScript und Apollo Server. Am Ende hast du eine funktionierende API mit Schema-Design, Resolvers, N+1-Abfrageverhinderung, Authentifizierung und Fehlerbehandlung.

Schema-Design

Das Schema definiert den Vertrag deiner API – die Typen, Queries und Mutations, die den Clients zur Verfügung stehen. Betrachte es als typisierte Schnittstelle für die gesamte API-Oberfläche.

graphqlgraphql
# schema.graphql
 
type User {
  id: ID!
  email: String!
  name: String!
  role: Role!
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
  createdAt: DateTime!
}
 
type Post {
  id: ID!
  title: String!
  content: String!
  published: Boolean!
  author: User!
  tags: [Tag!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}
 
type Tag {
  id: ID!
  name: String!
  posts: [Post!]!
}
 
enum Role {
  USER
  ADMIN
  EDITOR
}
 
scalar DateTime
 
type Query {
  user(id: ID!): User
  me: User
  posts(
    limit: Int = 20
    offset: Int = 0
    published: Boolean
  ): [Post!]!
  post(id: ID!): Post
}
 
type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
}
 
input CreatePostInput {
  title: String!
  content: String!
  published: Boolean = false
  tagIds: [ID!] = []
}
 
input UpdatePostInput {
  title: String
  content: String
  published: Boolean
}

Resolvers implementieren

Resolvers sind Funktionen, die die Daten für jedes Feld deines Schemas abrufen. Die Resolver-Kette beginnt an der Query-Wurzel und durchläuft den Typgraphen, wobei für jedes angeforderte Feld der entsprechende Resolver aufgerufen wird.

tstypescript
import { Resolvers } from "./generated/types";
 
const resolvers: Resolvers = {
  Query: {
    user: async (_parent, { id }, context) => {
      return context.db.user.findUnique({ where: { id } });
    },
 
    me: async (_parent, _args, context) => {
      if (!context.currentUser) return null;
      return context.db.user.findUnique({
        where: { id: context.currentUser.id },
      });
    },
 
    posts: async (_parent, { limit, offset, published }, context) => {
      return context.db.post.findMany({
        where: published !== undefined ? { published } : {},
        take: Math.min(limit ?? 20, 100),
        skip: offset ?? 0,
        orderBy: { createdAt: "desc" },
      });
    },
 
    post: async (_parent, { id }, context) => {
      return context.db.post.findUnique({ where: { id } });
    },
  },
 
  Mutation: {
    createPost: async (_parent, { input }, context) => {
      if (!context.currentUser) {
        throw new AuthenticationError("Must be logged in");
      }
 
      return context.db.post.create({
        data: {
          ...input,
          authorId: context.currentUser.id,
          tags: input.tagIds?.length
            ? { connect: input.tagIds.map((id) => ({ id })) }
            : undefined,
        },
      });
    },
 
    updatePost: async (_parent, { id, input }, context) => {
      const post = await context.db.post.findUnique({ where: { id } });
 
      if (!post) throw new NotFoundError("Post not found");
      if (post.authorId !== context.currentUser?.id) {
        throw new ForbiddenError("Not authorized to edit this post");
      }
 
      return context.db.post.update({ where: { id }, data: input });
    },
 
    deletePost: async (_parent, { id }, context) => {
      await context.db.post.delete({ where: { id } });
      return true;
    },
  },
 
  // Field resolvers for nested types
  User: {
    posts: async (user, { limit, offset }, context) => {
      return context.db.post.findMany({
        where: { authorId: user.id },
        take: limit ?? 10,
        skip: offset ?? 0,
      });
    },
  },
 
  Post: {
    author: async (post, _args, context) => {
      return context.db.user.findUnique({
        where: { id: post.authorId },
      });
    },
    tags: async (post, _args, context) => {
      return context.db.tag.findMany({
        where: { posts: { some: { id: post.id } } },
      });
    },
  },
};

Das N+1-Problem mit DataLoader lösen

Ohne DataLoader löst die Abfrage von 20 Posts 20 separate Abfragen für deren Autoren aus – das klassische N+1-Problem. DataLoader bündelt diese zu einer einzigen Abfrage.

tstypescript
import DataLoader from "dataloader";
 
// ❌ Without DataLoader: N+1 queries
// Query: posts(limit: 20) { author { name } }
// SQL: SELECT * FROM posts LIMIT 20
// SQL: SELECT * FROM users WHERE id = 1  (for post 1)
// SQL: SELECT * FROM users WHERE id = 2  (for post 2)
// ... 20 individual queries for authors
 
// ✅ With DataLoader: 2 queries total
// SQL: SELECT * FROM posts LIMIT 20
// SQL: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
 
interface DataLoaders {
  userLoader: DataLoader<string, User>;
  postTagsLoader: DataLoader<string, Tag[]>;
}
 
function createLoaders(db: PrismaClient): DataLoaders {
  return {
    userLoader: new DataLoader(async (userIds) => {
      const users = await db.user.findMany({
        where: { id: { in: [...userIds] } },
      });
 
      // DataLoader requires results in the same order as keys
      const userMap = new Map(users.map((u) => [u.id, u]));
      return userIds.map((id) => userMap.get(id) ?? null);
    }),
 
    postTagsLoader: new DataLoader(async (postIds) => {
      const posts = await db.post.findMany({
        where: { id: { in: [...postIds] } },
        include: { tags: true },
      });
 
      const tagMap = new Map(posts.map((p) => [p.id, p.tags]));
      return postIds.map((id) => tagMap.get(id) ?? []);
    }),
  };
}
 
// Updated resolver using DataLoader
const resolversWithLoader: Resolvers = {
  Post: {
    author: (post, _args, context) => {
      return context.loaders.userLoader.load(post.authorId);
    },
    tags: (post, _args, context) => {
      return context.loaders.postTagsLoader.load(post.id);
    },
  },
};

Authentifizierung und Kontext

Das Kontextobjekt wird pro Request erstellt und enthält den Authentifizierungszustand, Datenbankverbindungen und DataLoaders.

tstypescript
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import jwt from "jsonwebtoken";
 
interface Context {
  db: PrismaClient;
  loaders: DataLoaders;
  currentUser: User | null;
}
 
async function createContext(
  req: express.Request
): Promise<Context> {
  const db = prisma;
  const loaders = createLoaders(db);
 
  // Extract user from JWT token
  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.user.findUnique({
        where: { id: payload.userId },
      });
    } catch {
      // Invalid token — proceed as unauthenticated
    }
  }
 
  return { db, loaders, currentUser };
}
 
const server = new ApolloServer<Context>({
  typeDefs,
  resolvers,
});
 
await server.start();
 
app.use(
  "/graphql",
  expressMiddleware(server, {
    context: async ({ req }) => createContext(req),
  })
);

Fehlerbehandlung

GraphQL gibt Fehler zusammen mit Teildaten zurück. Strukturiere deine Fehler so, dass Clients sie programmatisch verarbeiten können.

tstypescript
import { GraphQLError } from "graphql";
 
class AuthenticationError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: "UNAUTHENTICATED", http: { status: 401 } },
    });
  }
}
 
class ForbiddenError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: "FORBIDDEN", http: { status: 403 } },
    });
  }
}
 
class NotFoundError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: "NOT_FOUND", http: { status: 404 } },
    });
  }
}
 
// ❌ Generic error — client can't distinguish error types
// throw new Error("Something went wrong");
 
// ✅ Typed error — client checks extensions.code
// throw new AuthenticationError("Must be logged in to create posts");
// Response: { errors: [{ message: "...", extensions: { code: "UNAUTHENTICATED" } }] }

Die wichtigsten Erkenntnisse

  1. Schema-First-Design definiert den Vertrag deiner API — schreibe das Schema vor den Resolvers; das Schema ist Dokumentation, Validierung und Typgenerierung in einem einzigen Artefakt
  2. DataLoader ist Pflicht bei verschachtelten Abfragen — ohne ihn verursacht die Abfrage einer Liste von Posts mit ihren Autoren N+1 Datenbankabfragen; DataLoader bündelt sie zu einer einzigen
  3. Erstelle neue DataLoaders pro Request — DataLoader cached Ergebnisse innerhalb eines Requests; eine Wiederverwendung über Requests hinweg liefert veraltete Daten
  4. Verwende typisierte Fehlerklassen mit Extension-Codes — Clients müssen Authentifizierungsfehler von Not-Found-Fehlern unterscheiden können; extensions.code liefert ein stabiles, maschinenlesbares Feld
  5. Begrenze Abfragetiefe und -komplexität — ohne Limits kann ein Client tief verschachtelte Abfragen anfordern, die deine Datenbank überlasten; füge Depth-Limiting und Query-Cost-Analyse hinzu
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX