Kurs
GraphQL ist eine leistungsstarke und effiziente Art, APIs zu entwickeln. Der Client stellt der API gezielte Anfragen – ähnlich wie an eine Datenbank – und die API liefert nur die Daten zurück, die angefordert wurden. Das reduziert die Payload und verbessert oft die Antwortzeiten. Kurze Latenzen sind heute entscheidend.
In Kombination mit MongoDB bekommst du nicht nur schnelle Antworten, sondern auch ein durchgängig einheitliches Datenformat – von Anfang bis Ende.
Stell dir vor: Dein Client führt eine GraphQL-Query aus, die wie JSON aussieht. In deiner Anwendung – sagen wir TypeScript in diesem Beispiel – arbeitest du ebenfalls mit einem JSON-ähnlichen Format. Gehst du noch einen Schritt weiter und nutzt MongoDB, werden die Daten, die nach MongoDB hinein- und wieder herausgehen, ebenfalls JSON-ähnlich sein. Unterm Strich erhältst du also Konsistenz im Datenformat und starke Performance. Du musst dich weniger um Transformationen und Formatierungen kümmern und kannst dich stattdessen auf die User Experience deiner Anwendung konzentrieren – nicht auf Datenbank und Tooling.
In diesem Tutorial siehst du, wie einfach sich MongoDB in deiner GraphQL-API einsetzen lässt – in diesem Fall mit TypeScript.
Voraussetzungen
Für dieses Tutorial solltest du vor dem Start Folgendes bereit haben:
- Ein MongoDB Atlas-Cluster
- Node.js 22+
- Den MongoDB Node.js-Treiber 6.x
Wir machen in diesem Tutorial nichts Ausgefallenes auf Datenbankseite, daher reicht jeder Cluster-Tarif von MongoDB – selbst der Free-Tier. Die Bereitstellung eines Atlas-Clusters behandeln wir hier nicht. Erwartet wird, dass Benutzer- und Netzwerkrichtlinien stehen. Wenn du Unterstützung beim Einstieg brauchst, wirf einen Blick in die Dokumentation „Get Started With Atlas“.
Du brauchst eine angelegte Node.js-Anwendung. Falls du möchtest, führe folgende Befehle aus:
mkdir graphql_example
cd graphql_example
npm init -y
Die Befehle oben erstellen ein neues Projektverzeichnis und eine einfache package.json. In diesem Projekt nutzen wir TypeScript, Express Framework, MongoDB und Apollo. Installiere alles mit diesen Befehlen:
npm install @apollo/server@4 body-parser cors dotenv express@4 graphql mongodb
npm install @types/cors @types/express@4 @types/node tsx typescript --save-dev
Damit installierst du die Laufzeitabhängigkeiten sowie die Typdefinitionen für TypeScript.
Zum Schluss brauchst du eine TypeScript-Konfigurationsdatei. Lege im Projektroot eine tsconfig.json mit folgendem JSON an:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Wenn du das komplette Projekt ansehen oder selbst ausprobieren möchtest, kannst du es auf GitHub ansehen.
Basis mit Apollo, Express und MongoDB-Abhängigkeiten schaffen
Wir verwenden folgende Projektstruktur:
- src/database/connection.ts
- src/database/userService.ts
- src/resolvers/index.ts
- src/schema/typeDefs.ts
- src/types/index.ts
- src/index.ts
- .env
Die Datei src/index.ts enthält das Bootstrapping für Express Framework und Apollo: Serverstart, Definition der GraphQL-Endpunkte usw. In src/types/index.ts liegen unsere TypeScript-Definitionen, um in der Anwendung mit dem Datenbankmodell zu arbeiten. Verwechsele das nicht mit src/schema/typeDefs.ts, das sich – ähnlich, aber getrennt – auf die GraphQL-Typdefinitionen konzentriert.
Bleiben noch die Verzeichnisse database und resolvers. Dort liegt der Großteil der App- und Datenbanklogik – gleich schauen wir rein.
Fokussieren wir uns zunächst auf das Server-Setup in src/index.ts:
import 'dotenv/config';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import express from 'express';
import http from 'http';
import cors from 'cors';
import bodyParser from 'body-parser';
import { typeDefs } from './schema/typeDefs';
import { resolvers } from './resolvers';
async function startServer() {
try {
const app = express();
const httpServer = http.createServer(app);
const server = new ApolloServer({
typeDefs,
resolvers,
});
await server.start();
app.use(
'/graphql',
cors<cors.CorsRequest>(),
bodyParser.json(),
expressMiddleware(server)
);
const PORT = process.env.PORT || 3000;
await new Promise<void>((resolve) => httpServer.listen({ port: PORT }, resolve));
console.log(Server ready at http://localhost:${PORT}/graphql);
} catch (error) {
console.error('Error starting server:', error);
process.exit(1);
}
}
startServer();
Diese Datei erweitern wir im Verlauf.
Zunächst importieren wir alle Projektabhängigkeiten. Danach konfigurieren wir den Express-Server und das Apollo-Middleware-Setup.
const server = new ApolloServer({
typeDefs,
resolvers,
});
Die Dateien src/schema/typeDefs.ts und src/resolvers/index.ts sind noch leer. In diesem Schritt bereiten wir nur alles vor. Erfolgreich starten lässt sich der Server erst, wenn wir die übrigen Dateien befüllen.
Wichtig: Im Beispiel erlauben wir Anfragen von allen Origins – erkennbar an:
cors<cors.CorsRequest>(),
Für die Entwicklung ist das okay. In Produktion solltest du die Origins auf die wirklich benötigten beschränken.
Mit MongoDB aus der TypeScript-Node.js-Anwendung interagieren
Wir haben die Basis mit Express und Apollo, aber MongoDB ist in der Anwendung noch nicht konfiguriert. Bevor wir ins Coding einsteigen, ergänze die Verbindungsdaten in der .env:
MONGODB_URI="mongodb+srv://<USERNAME>:<PASSWORD>@<HOST>/?appName=devrel-graphql"
DB_NAME="graphql_example"
Ersetze <USERNAME>, <PASSWORD> und <HOST> mit den Infos aus deinem MongoDB Atlas-Dashboard.
Als Nächstes implementieren wir die Verbindungslogik. Lege in src/database/connection.ts Folgendes an:
import { MongoClient, Db } from 'mongodb';
let db: Db | null = null;
let client: MongoClient | null = null;
export async function connectToDatabase(): Promise<Db> {
if (db) {
return db;
}
try {
const MONGODB_URI = process.env.MONGODB_URI;
if (!MONGODB_URI) {
throw new Error('MONGODB_URI is not set');
}
const DB_NAME = process.env.DB_NAME || 'graphql_example';
const APP_NAME = process.env.APP_NAME || 'devrel-graphql';
console.log('Connecting to MongoDB...');
client = new MongoClient(MONGODB_URI, { appName: APP_NAME });
await client.connect();
db = client.db(DB_NAME);
console.log(Successfully connected to MongoDB database: ${DB_NAME});
return db;
} catch (error) {
console.error('MongoDB connection error:', error);
throw error;
}
}
export function getDatabase(): Db {
if (!db) {
throw new Error('Database not initialized. Call connectToDatabase() first.');
}
return db;
}
export async function closeDatabase(): Promise<void> {
if (client) {
await client.close();
db = null;
client = null;
console.log('MongoDB connection closed');
}
}
Die Idee ist eine Singleton-Verbindung. Es gibt viele Wege, MongoDB in deiner App einzubinden, aber für dieses Beispiel wählen wir diesen Ansatz.
Die Funktion connectToDatabase liest die Verbindung aus der .env, verbindet sich und gibt die konfigurierte Datenbank zurück. Scheitert etwas, wird der Fehler geloggt und erneut geworfen. Diese Funktion brauchst du im Grunde nur beim Server-Setup – wie zuvor gezeigt.
Die Funktion getDatabase nutzt du bei jeder Anfrage in der Anwendung, um die bestehende Instanz zu erhalten.
Optional hält closeDatabase die Dinge sauber, indem es beim Beenden des Servers die Verbindung schließt. Gute Praxis – entscheide selbst, ob du es nutzt.
Bevor wir zur Datenbanklogik gehen, definieren wir unsere TypeScript-Typen für die Data Transfer Objects (DTOs):
export interface User {
id: string;
name: string;
email: string;
age?: number;
createdAt: string;
}
export interface CreateUserInput {
name: string;
email: string;
age?: number;
}
export interface UpdateUserInput {
id: string;
name?: string;
email?: string;
age?: number;
}
Diese Interfaces liegen in src/types/index.ts und werden von unserer API genutzt – und die Datenbanklogik muss sie ebenfalls kennen. Gleich siehst du warum.
Damit kommen wir zu den eigentlichen Datenbankoperationen für Create, Read, Update, Delete (CRUD) – in src/database/userService.ts. Die Idee: Unsere GraphQL-Resolver übernehmen die Anwendungslogik und rufen die Funktionen aus diesem Service auf; der Service kümmert sich um die Datenbank.
In src/database/userService.ts steht Folgendes:
import { Collection, ObjectId } from 'mongodb';
import { getDatabase } from './connection';
import { User, CreateUserInput, UpdateUserInput } from '../types';
interface UserDocument {
_id: ObjectId;
name: string;
email: string;
age?: number;
createdAt: Date;
}
export class UserService {
private collection: Collection<UserDocument>;
constructor() {
const db = getDatabase();
this.collection = db.collection<UserDocument>('users');
}
private toUser(doc: UserDocument): User {
return {
id: doc._id.toString(),
name: doc.name,
email: doc.email,
age: doc.age,
createdAt: doc.createdAt.toISOString(),
};
}
async getAllUsers(): Promise<User[]> {
const users = await this.collection.find().toArray();
return users.map(doc => this.toUser(doc));
}
async getUserById(id: string): Promise<User | null> {
try {
const doc = await this.collection.findOne({ _id: new ObjectId(id) });
return doc ? this.toUser(doc) : null;
} catch (error) {
return null;
}
}
async createUser(input: CreateUserInput): Promise<User> {
const newUser: Omit<UserDocument, '_id'> = {
name: input.name,
email: input.email,
age: input.age,
createdAt: new Date(),
};
const result = await this.collection.insertOne(newUser as UserDocument);
const createdUser = await this.collection.findOne({ _id: result.insertedId });
if (!createdUser) {
throw new Error('Failed to create user');
}
return this.toUser(createdUser);
}
async updateUser(input: UpdateUserInput): Promise<User | null> {
try {
const updateFields: Partial<Omit<UserDocument, '_id'>> = {};
if (input.name !== undefined) updateFields.name = input.name;
if (input.email !== undefined) updateFields.email = input.email;
if (input.age !== undefined) updateFields.age = input.age;
const result = await this.collection.findOneAndUpdate(
{ _id: new ObjectId(input.id) },
{ $set: updateFields },
{ returnDocument: 'after' }
);
return result ? this.toUser(result) : null;
} catch (error) {
return null;
}
}
async deleteUser(id: string): Promise<boolean> {
try {
const result = await this.collection.deleteOne({ _id: new ObjectId(id) });
return result.deletedCount > 0;
} catch (error) {
return false;
}
}
async createIndexes(): Promise<void> {
await this.collection.createIndex({ email: 1 }, { unique: true });
await this.collection.createIndex({ createdAt: -1 });
console.log('Database indexes created');
}
}
Als Erstes fällt oben dieses Interface auf:
interface UserDocument {
_id: ObjectId;
name: string;
email: string;
age?: number;
createdAt: Date;
}
Es ähnelt unserem User-Interface, ist aber nicht identisch. UserDocument bildet strenger ab, wie unsere Datenbank arbeitet. In der Datenbank nutzen wir ObjectId, in der Anwendung String-IDs. Außerdem heißt die ID in MongoDB _id und nicht id, wie im User-Interface.
Damit sind wir bei der Klasse UserService.
Nachdem wir uns im constructor ein Handle auf die Collection aus der Datenbankinstanz geholt haben, folgen diese Funktionen:
- toUser
- getAllUsers
- getUserById
- createUser
- updateUser
- deleteUser
- createIndexes
toUser ist ein Helper, der zwischen User und UserDocument konvertiert. Zur Datenbank senden/empfangen wir UserDocument, in der App arbeiten wir mit User.
async createIndexes(): Promise<void> {
await this.collection.createIndex({ email: 1 }, { unique: true });
await this.collection.createIndex({ createdAt: -1 });
console.log('Database indexes created');
}
createIndexes legt ein paar sinnvolle Beispielindizes an. Standardmäßig existiert nur ein Index auf _id – für ein kleines Demo okay, aber je größer die Anwendung wird, desto wichtiger ist durchdachtes Indexing basierend auf Daten und Abfragen. Die beiden oben sind nur Beispiele.
Schauen wir uns die Servicefunktionen an, beginnend mit getAllUsers:
async getAllUsers(): Promise<User[]> {
const users = await this.collection.find().toArray();
return users.map(doc => this.toUser(doc));
}
Diese Funktion findet alle Dokumente in der Collection, da kein Filter im find gesetzt ist. Die Ergebnisse werden per toUser konvertiert und – später über unsere Apollo-Resolver – an die Anwendung zurückgegeben.
async getUserById(id: string): Promise<User | null> {
try {
const doc = await this.collection.findOne({ _id: new ObjectId(id) });
return doc ? this.toUser(doc) : null;
} catch (error) {
return null;
}
}
getUserById ist ähnlich, nutzt aber findOne mit einem Filter – hier wollen wir genau einen Benutzer anhand des eindeutigen _id-Felds. Da id im User-Interface ein String ist, umhüllen wir ihn für MongoDB-Abfragen mit ObjectId.
Kommen wir zum Anlegen von Daten in MongoDB über createUser:
async createUser(input: CreateUserInput): Promise<User> {
const newUser: Omit<UserDocument, '_id'> = {
name: input.name,
email: input.email,
age: input.age,
createdAt: new Date(),
};
const result = await this.collection.insertOne(newUser as UserDocument);
const createdUser = await this.collection.findOne({ _id: result.insertedId });
if (!createdUser) {
throw new Error('Failed to create user');
}
return this.toUser(createdUser);
}
Nach insertOne nutzen wir die zurückgegebene insertedId, um das neu angelegte Dokument zu laden. In einer GraphQL-API ist es üblich, dass eine Mutation die erstellten Daten direkt zurückliefern kann.
updateUser sieht etwas anders aus:
async updateUser(input: UpdateUserInput): Promise<User | null> {
try {
const updateFields: Partial<Omit<UserDocument, '_id'>> = {};
if (input.name !== undefined) updateFields.name = input.name;
if (input.email !== undefined) updateFields.email = input.email;
if (input.age !== undefined) updateFields.age = input.age;
const result = await this.collection.findOneAndUpdate(
{ _id: new ObjectId(input.id) },
{ $set: updateFields },
{ returnDocument: 'after' }
);
return result ? this.toUser(result) : null;
} catch (error) {
return null;
}
}
In dieser Anwendung erlauben wir Updates nur für name, email und age. Alle sind optional – wir setzen sie also nur, wenn sie vorhanden sind.
Das führt uns zu findOneAndUpdate.
Das erste Objekt ist der Filter: Wir updaten nur, wenn _id passt. Für Treffer setzen wir mit dem Operator $set die Felder aus updateFields – fehlende Felder bleiben unverändert. Da wir „finden und updaten“ statt nur „updaten“, setzen wir returnDocument auf after, um das geänderte Dokument zurückzubekommen. Ein updateOne würde nur Metadaten zur Operation liefern, nicht die Daten selbst.
Die letzte CRUD-Operation entfernt Daten aus MongoDB – via deleteUser:
async deleteUser(id: string): Promise<boolean> {
try {
const result = await this.collection.deleteOne({ _id: new ObjectId(id) });
return result.deletedCount > 0;
} catch (error) {
return false;
}
}
deleteOne ähnelt findOne, da wir ebenfalls über _id filtern. Der Unterschied: Ein Treffer – maximal ein Dokument – wird gelöscht.
Die Datenbanklogik steht. Zum Finalisieren verbinden wir sie in src/index.ts:
// Previous imports here...
import { connectToDatabase, closeDatabase } from './database/connection';
import { UserService } from './database/userService';
async function startServer() {
try {
await connectToDatabase();
const userService = new UserService();
await userService.createIndexes();
// The rest of the file here...
}
Im Snippet oben haben wir den Großteil von src/index.ts weggelassen. Wichtig ist nur, wie connectToDatabase genutzt und die Indizes erstellt werden. Platziere diese drei Zeilen einfach oben in deiner startServer-Funktion.
Jetzt können wir uns auf die API-Logik konzentrieren.
GraphQL-Typen, Resolver und eigene Logik definieren
Die TypeScript-Typen für die Arbeit mit der Datenbank und den Datenfluss in der App sind definiert. Was noch fehlt, sind die GraphQL-Typdefinitionen – also das, womit Nutzer deiner API interagieren dürfen.
Füge in src/schema/typeDefs.ts Folgendes ein:
export const typeDefs = #graphql
type User {
id: ID!
name: String!
email: String!
age: Int
createdAt: String!
}
type Query {
users: [User!]!
user(id: ID!): User
}
type Mutation {
createUser(name: String!, email: String!, age: Int): User!
updateUser(id: ID!, name: String, email: String, age: Int): User
deleteUser(id: ID!): Boolean!
}
;
Der Typ User legt fest, was ein Client in einer Query anfordern darf. Im Beispiel entspricht er unserem User-Interface, muss das aber nicht. Manche Felder – etwa Passwörter – willst du vielleicht nie an den Client zurückgeben.
Der Typ Query definiert, welche Leseabfragen unsere API erlaubt. Jede Query liefert einen User oder eine Liste von User – mit eigener Logik dazwischen.
Mutation ist analog für schreibende Vorgänge gedacht.
Mit den GraphQL-Typen können wir die Resolver-Logik implementieren. Lege in src/resolvers/index.ts Folgendes an:
import { UserService } from '../database/userService';
import { User, CreateUserInput, UpdateUserInput } from '../types';
export const resolvers = {
Query: {
users: async (): Promise<User[]> => {
const userService = new UserService();
return await userService.getAllUsers();
},
user: async (_: unknown, { id }: { id: string }): Promise<User | null> => {
const userService = new UserService();
return await userService.getUserById(id);
},
},
Mutation: {
createUser: async (
_: unknown,
{ name, email, age }: CreateUserInput
): Promise<User> => {
const userService = new UserService();
return await userService.createUser({ name, email, age });
},
updateUser: async (
_: unknown,
{ id, name, email, age }: UpdateUserInput
): Promise<User | null> => {
const userService = new UserService();
return await userService.updateUser({ id, name, email, age });
},
deleteUser: async (_: unknown, { id }: { id: string }): Promise<boolean> => {
const userService = new UserService();
return await userService.deleteUser(id);
},
},
};
Die Resolver sind die praktische Entsprechung zu den Typdefinitionen für Queries und Mutationen.
Sieh dir die Query users an:
users: async (): Promise<User[]> => {
const userService = new UserService();
return await userService.getAllUsers();
},
Diese GraphQL-Query nutzt unseren UserService, um alle Nutzer zu laden. Zurück geht ein User[], aber nur die Felder, die der Client angefragt hat und die in den Typdefinitionen erlaubt sind.
Beispiel: Der Client sendet diese Query:
query {
users {
name
email
}
}
Obwohl es mehr Felder gibt, möchte der Client nur name und email. Genau das liefert GraphQL – basierend auf unseren Typdefinitionen. Apollo übernimmt die Magie dahinter.
Dasselbe Prinzip gilt für alle Queries und Mutationen in src/resolvers/index.ts.
createUser: async (
_: unknown,
{ name, email, age }: CreateUserInput
): Promise<User> => {
const userService = new UserService();
return await userService.createUser({ name, email, age });
},
In der Typdefinition zu createUser kann der Client name, email und age übergeben. Im gleichnamigen Resolver nehmen wir dieselben Felder entgegen und rufen den Service auf. Zurück gehen die angeforderten Felder.
Die GraphQL-API bauen und starten
An diesem Punkt sollte die GraphQL-API gut dastehen. Es gibt mehrere Wege, die Anwendung zu bauen und zu starten.
Zum Beispiel kannst du in deiner package.json Folgendes ergänzen:
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"dev:watch": "tsx watch src/index.ts",
"watch": "tsc -w"
},
Mit diesem Setup baust du mit npm run build und startest mit npm run start. Standardport ist 3000; testen kannst du mit cURL oder jedem Tool deiner Wahl.
Fazit
Du hast gesehen, wie du mit TypeScript und MongoDB als Datenbank eine GraphQL-API baust. Das Beispiel ist bewusst einfach gehalten, zeigt aber, wie du Queries und Mutationen für CRUD-Operationen umsetzt. MongoDB passt hervorragend zu einer GraphQL-API, weil das Datenformat entlang des gesamten Flusses ähnlich bleibt. Der Client formuliert eine Query, die wie JSON aussieht; die Daten lassen sich mit minimaler Formatierung in der Anwendung weiterverwenden und genauso an die Datenbank senden bzw. von dort empfangen. Das macht den Flow reibungslos – du kannst dich stärker auf die User Experience konzentrieren und weniger darauf, Daten für die Datenbank passend zu biegen.
Wenn du an irgendeiner Stelle festhängst, schau dir das fertige Projekt auf GitHub an.
FAQs
Warum sollte ich GraphQL verwenden?
GraphQL ist großartig, weil du eine API wie eine Datenbank abfragen kannst und genau das zurückbekommst, was du brauchst. Das macht den Client leichtgewichtig und effizient.
Ist Apollo die einzige Option für GraphQL?
Apollo macht das Erstellen von GraphQL-APIs sehr einfach, ist aber nicht die einzige Option. Andere Möglichkeiten liegen jedoch außerhalb des Rahmens dieses Tutorials.
Warum MongoDB mit GraphQL verwenden?
Beide arbeiten mit JSON-ähnlichen Formaten. Du musst also nicht ständig zwischen unterschiedlichen Datenstrukturen konvertieren, wenn Informationen von der Datenbank über die API zum Client fließen.
Was macht ein Resolver?
Ein Resolver ist eine Funktion, die GraphQL erklärt, wie für ein bestimmtes Feld im Schema Daten gelesen oder verändert werden.
Was ist der Unterschied zwischen einer Query und einer Mutation?
Queries sind zum Lesen/Abrufen von Daten (ähnlich GET), Mutationen zum Erstellen, Aktualisieren oder Löschen von Daten (ähnlich POST, PUT, DELETE).

Nic Raboy ist Developer Relations Lead bei MongoDB. Er leitet ein Team von Python-, Java-, C#- und PHP-Entwicklern, die großartige Inhalte erstellen, um Entwicklern zu helfen, MongoDB erfolgreich in ihre Projekte einzubinden. Er hat Erfahrung mit Golang und JavaScript und schreibt oft über seine Entwicklungsabenteuer.
