Cursus
GraphQL is een krachtige en efficiënte manier om API’s te bouwen. De client bevraagt de API, vergelijkbaar met hoe je een database zou bevragen, en die API retourneert alleen de data waar specifiek om is gevraagd. Dat verkleint vaak de response payload en verbetert de reactietijd. Lage reactietijden zijn cruciaal in de moderne wereld.
Koppel je de GraphQL-API aan MongoDB, dan krijg je niet alleen snelle reacties, maar ook een data-indeling die van begin tot eind consistent is.
Stel je dit voor: je client voert een GraphQL-query uit en die query lijkt op JSON. Wanneer de data je applicatie bereikt — laten we zeggen dat dit TypeScript is in dit voorbeeld — werk je in je applicatie met een data-indeling die vergelijkbaar is met JSON. Nog een stap verder: met MongoDB is de data die je naar MongoDB stuurt en terugkrijgt óók vergelijkbaar met JSON. Je krijgt dus die consistente data-ervaring bovenop performance. Je hoeft je minder zorgen te maken over manipulatie en formatting van je data, zodat je je kunt focussen op de gebruikerservaring van je applicatie — niet op de database en tooling.
In deze tutorial zie je hoe eenvoudig het is om MongoDB te gebruiken in je GraphQL-API, dit keer gebouwd met TypeScript.
De vereisten
Voor deze tutorial moeten we een paar zaken klaar hebben staan voordat we beginnen:
- Een MongoDB Atlas-cluster
- Node.js 22+
- De MongoDB Node.js-driver 6.x
We doen in deze tutorial niets bijzonders aan de databasekant, dus elk cluster-niveau van MongoDB werkt, zelfs de gratis tier. We gaan echter niet in op het inrichten van een MongoDB Atlas-cluster in deze tutorial. We gaan ervan uit dat je gebruikers- en netwerkregels al klaar staan. Heb je hulp nodig om te starten, bekijk dan de documentatie Get Started With Atlas over dit onderwerp.
Je moet een Node.js-applicatie hebben aangemaakt. Als je wilt, kun je de volgende commando’s uitvoeren:
mkdir graphql_example
cd graphql_example
npm init -y
De bovenstaande commando’s maken een nieuwe projectmap en een basisbestand package.json aan. We gebruiken TypeScript, Express Framework, MongoDB en Apollo in dit project. Je installeert ze met de volgende commando’s:
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
Deze commando’s installeren de dependencies en de type-definities die in TypeScript worden gebruikt.
Als laatste onderdeel van de vereisten heb je een configuratiebestand voor TypeScript nodig. Maak in de root van je project een bestand tsconfig.json met de volgende JSON:
{
"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"]
}
Als je op enig moment het complete project wilt bekijken of zelf wilt uitproberen, kun je het op GitHub bekijken.
Leg de basis met Apollo, Express en MongoDB-dependencies
Qua projectstructuur maken we gebruik van het volgende:
- src/database/connection.ts
- src/database/userService.ts
- src/resolvers/index.ts
- src/schema/typeDefs.ts
- src/types/index.ts
- src/index.ts
- .env
Het bestand src/index.ts bevat al onze Express Framework- en Apollo-bootstrapping. Denk aan het starten van de server, het definiëren van de GraphQL-endpoints, enzovoort. Het bestand src/types/index.ts bevat onze TypeScript-definities voor het werken met ons databasemodel binnen de applicatie. Dit moet je niet verwarren met het bestand src/schema/typeDefs.ts dat, hoewel vergelijkbaar, focust op de GraphQL-typedefinities.
Dan blijven de mappen database en resolvers over. Het grootste deel van onze applicatie- en databaselogica staat hierin — daar komen we zo op terug.
Laten we ons nu richten op het opzetten van onze server in het bestand 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();
We breiden het bovenstaande bestand uit naarmate we verder gaan.
We beginnen met het importeren van alle projectdependencies. Vervolgens configureren we onze Express-server en onze Apollo-middleware.
const server = new ApolloServer({
typeDefs,
resolvers,
});
We hebben nog niets toegevoegd aan onze bestanden src/schema/typeDefs.ts en src/resolvers/index.ts, maar in deze fase bereiden we alles voor. We kunnen de server nog niet zonder fouten draaien totdat we de gaten in de andere bestanden opvullen.
Het is het vermelden waard dat dit voorbeeld verzoeken van alle origins toestaat, vanwege het volgende:
cors<cors.CorsRequest>(),
Dit is prima voor een ontwikkelomgeving, maar voeg in productie alleen de origins toe die je nodig hebt.
Interacteren met MongoDB vanuit de TypeScript- met Node.js-applicatie
We hebben de basis met Express en Apollo staan, maar we hebben MongoDB nog niet binnen de applicatie geconfigureerd. Voordat we in de code duiken, moeten we de verbindingsinformatie toevoegen aan ons bestand .env:
MONGODB_URI="mongodb+srv://<USERNAME>:<PASSWORD>@<HOST>/?appName=devrel-graphql"
DB_NAME="graphql_example"
Vervang <USERNAME>, <PASSWORD> en <HOST> door de gegevens van je MongoDB Atlas-cluster zoals te vinden op je MongoDB Atlas-dashboard.
Vervolgens stellen we de databaselogica voor onze applicatie vast. Voeg in het bestand **src/database/connection.ts** de volgende code toe:
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');
}
}
Het idee hier is dat we een singleton-verbinding maken. Er zijn genoeg andere manieren om MongoDB in je applicatie te gebruiken, maar voor deze applicatie kiezen we voor deze aanpak.
De functie connectToDatabase haalt de verbindingsinformatie uit het bestand **.env**, maakt verbinding, en retourneert de database die je hebt gedefinieerd. Als het mislukt, wordt de fout opgevangen en doorgegeven. Je hoeft connectToDatabase alleen te gebruiken bij het opzetten van je server, zoals in het vorige bestand te zien is.
De functie getDatabase gebruiken we telkens wanneer er een request naar onze applicatie komt. Zo krijgen we de huidige instantie van onze verbinding.
Tot slot hebben we, om het netjes te houden, een functie closeDatabase om de verbinding met onze database te sluiten wanneer onze server stopt. Of je zo’n functie opneemt is aan jou, maar het is goede praktijk.
Voordat we toekomen aan onze databaselogica, is het waarschijnlijk een goed idee om onze TypeScript-types voor onze data transfer objects (DTO’s) klaar te zetten:
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;
}
Bovenstaande interfaces, in het bestand **src/types/index.ts**, worden beheerd door onze API, maar onze databaselogica moet ze wel kennen. Dat zie je zo meteen.
Dit brengt ons bij de daadwerkelijke database-interacties voor create, retrieve, update, delete (CRUD). Dat gebeurt in het bestand src/database/userService.ts. Het idee is dat onze GraphQL-resolvers de applicatielogica afhandelen en de functies in dit servicebestand aanroepen, en de service handelt de databaselogica af.
In het bestand src/database/userService.ts hebben we de volgende code:
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');
}
}
Het eerste dat opvalt is de volgende interface:
interface UserDocument {
_id: ObjectId;
name: string;
email: string;
age?: number;
createdAt: Date;
}
Die lijkt op onze interface User, maar is niet helemaal hetzelfde. UserDocument is strikter gemodelleerd naar wat onze database gebruikt. We werken in de database met ObjectId, maar in onze applicatie met string-hashes. In MongoDB is de id het veld _id in plaats van id, zoals in de interface User.
Dit brengt ons bij de klasse UserService.
Nadat we via de constructor-methode een verwijzing naar onze collectie uit de database-instantie hebben gehaald, hebben we de volgende reeks functies:
- toUser
- getAllUsers
- getUserById
- createUser
- updateUser
- deleteUser
- createIndexes
De functie toUser is eigenlijk een helper die converteert tussen User en UserDocument. We sturen en ontvangen UserDocument naar en van de database en moeten converteren zodat we in de applicatie User kunnen versturen en ontvangen.
async createIndexes(): Promise<void> {
await this.collection.createIndex({ email: 1 }, { unique: true });
await this.collection.createIndex({ createdAt: -1 });
console.log('Database indexes created');
}
De functie createIndexes maakt enkele voorbeeld-indexen die nuttig kunnen zijn. Standaard bestaat er alleen een index op het veld _id. Dat is prima voor een demo met een paar documenten, maar naarmate je applicatie groeit, moet je zeker de juiste indexen aanmaken op basis van je data en querybehoeften. De twee indexen hierboven zijn slechts voorbeelden.
Nu kunnen we naar elke servicefunctie kijken, te beginnen met getAllUsers:
async getAllUsers(): Promise<User[]> {
const users = await this.collection.find().toArray();
return users.map(doc => this.toUser(doc));
}
Bovenstaande functie zoekt alle documenten in de collectie, omdat er geen filtercriteria zijn meegegeven aan find. De resultaten worden met onze functie toUser geconverteerd en teruggestuurd naar de applicatie — straks onze Apollo-resolvers.
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;
}
}
De functie getByUserId is vergelijkbaar, maar nu gebruiken we findOne met filtercriteria — hier willen we één gebruiker op basis van het unieke veld _id. Omdat het veld id in de interface User een string is, moeten we het in een ObjectId wikkelen om het geschikt te maken voor onze MongoDB-queries.
Dan komen we uit bij het creëren van data in MongoDB via de functie 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);
}
Na het invoegen met insertOne gebruiken we de teruggegeven insertedId om die data vervolgens op te vragen. Dat doen we omdat je in een GraphQL-API na het creëren van data doorgaans verwacht die data in dezelfde mutatie te kunnen opvragen.
De functie updateUser is een beetje anders:
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 deze applicatie staan we alleen updates toe voor de velden name, email en age. Al deze velden zijn optioneel, dus we zetten ze in onze updatecriteria als ze aanwezig zijn.
Daarmee komen we bij de functie findOneAndUpdate.
De eerste parameter van deze functie is het filter. We willen alleen updaten waar _id overeenkomt. Voor elke match gebruiken we de operator $set om velden in ons object updateFields te vervangen of aan te maken. Velden die niet in het object staan, worden niet gewijzigd. Omdat we vinden én updaten in één keer, zetten we returnDocument op after — we willen de aangepaste versie terug, niet het origineel. Als we updateOne hadden gebruikt in plaats van findOneAndUpdate, hadden we alleen informatie over de update-operatie teruggekregen, niet de data.
De laatste CRUD-operatie voor deze API is het verwijderen van data uit MongoDB via de functie 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;
}
}
De functie deleteOne lijkt op findOne omdat we een filter op _id gebruiken. Het verschil is dat elke match — in dit geval maximaal één document — wordt verwijderd.
Op dit punt is de databaselogica klaar. Om het te finaliseren, moeten we vanuit het bestand **src/index.ts** verbinding maken:
// 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...
}
In de bovenstaande snippet is het grootste deel van src/index.ts weggelaten. Het doel is alleen om het gebruik van connectToDatabase en het aanmaken van indexen te laten zien. Plaats die drie regels duidelijk gezien bovenaan je functie startServer.
Nu kunnen we ons focussen op de API-logica.
Definieer GraphQL-types, resolvers en aangepaste applicatielogica
We hebben de TypeScript-types al gedefinieerd om met onze database te werken en data door onze applicatie door te geven. Maar we hebben de GraphQL-typedefinities nog niet gemaakt — die bepalen waar de gebruiker mee kan interacteren als die onze API gebruikt.
Voeg in het bestand src/schema/typeDefs.ts het volgende toe:
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!
}
;
Het type User is wat de client mag opvragen in een query. In dit voorbeeld komt het overeen met de interface User, maar dat hoeft niet. Misschien bevat je interface User bijvoorbeeld data die je nooit naar de client wilt terugsturen, zoals een wachtwoord.
Het type Query definieert de soorten queries die binnen onze API kunnen worden uitgevoerd. Elke query retourneert een User of een array van User, maar we kunnen specifieke logica in deze queries ertussen plaatsen.
Het type Mutation lijkt op Query, maar deze zijn bedoeld om data te manipuleren.
Met de GraphQL-typedefinities op hun plek kunnen we ons richten op de resolverlogica van de applicatie. Voeg in src/resolvers/index.ts de volgende code toe:
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);
},
},
};
De resolvers hierboven zijn een uitwerking van wat we in het typedefinitiebestand zagen voor queries en mutaties.
Kijk eens naar de query users:
users: async (): Promise<User[]> => {
const userService = new UserService();
return await userService.getAllUsers();
},
Deze GraphQL-query gebruikt onze UserService — onderdeel van de databaselogica — om alle gebruikers op te halen. De respons User[] gaat terug naar de gebruiker, maar alleen de onderdelen waar de gebruiker om vroeg en die overeenkomen met de GraphQL-typedefinities.
Stel dat de client deze query stuurt:
query {
users {
name
email
}
}
In het bovenstaande voorbeeld hebben we meer velden beschikbaar, maar de client wil alleen de velden name en email. Dat is geregeld op basis van onze GraphQL-typedefinities waar de client mee werkt. Apollo regelt de magie om dat mogelijk te maken.
Dezelfde regels gelden voor elke query en mutatie in ons bestand **src/resolvers/index.ts**.
createUser: async (
_: unknown,
{ name, email, age }: CreateUserInput
): Promise<User> => {
const userService = new UserService();
return await userService.createUser({ name, email, age });
},
Voor de typedefinitie createUser zagen we dat de client de velden name, email en age kan doorgeven. In de resolverfunctie createUser doen we hetzelfde en roepen we de functie createUser uit onze databaseservice aan. De respons wordt naar de client teruggestuurd op basis van de velden waar die om heeft gevraagd.
Bouw en draai de GraphQL-API
Op dit punt zou de GraphQL-API in goede staat moeten zijn. Er zijn meerdere manieren om deze applicatie te bouwen en te draaien.
Je kunt bijvoorbeeld het volgende toevoegen aan je **package.json**-bestand:
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"dev:watch": "tsx watch src/index.ts",
"watch": "tsc -w"
},
Kies je voor deze route, dan kun je npm run build draaien om het project te bouwen en npm run start om het project te serveren. De standaardpoort is 3000 en je kunt testen met cURL of elke tool die je prettig vindt.
Conclusie
Je hebt gezien hoe je een GraphQL-API maakt met TypeScript en MongoDB als database. Hoewel dit voorbeeld bewust eenvoudig was, hebben we gezien hoe je queries en mutaties bouwt voor CRUD-operaties tegen onze database. MongoDB past perfect bij een GraphQL-API omdat de data waar je mee werkt van begin tot eind vergelijkbaar is qua formaat. De client draait bijvoorbeeld een query die op JSON lijkt. De data die door de query gaat, kun je met minimale formatting en aanpassingen gebruiken — en hetzelfde geldt bij het sturen naar en ophalen uit de database. De ervaring is soepel, zodat je je kunt richten op de user experience in plaats van op het manipuleren van je data om de database tevreden te stellen.
Als je ergens vastliep tijdens deze tutorial, bekijk dan zeker het voltooide project op GitHub.
FAQs
Waarom zou ik GraphQL gebruiken?
GraphQL is fijn omdat je een API kunt bevragen zoals je een database zou doen en alleen terugkrijgt wat je nodig hebt. Dat maakt het lichtgewicht en efficiënt voor de client.
Is Apollo de enige optie voor GraphQL?
Apollo maakt het heel eenvoudig om GraphQL-API’s te bouwen, maar het is niet de enige optie. De andere opties vallen echter buiten de scope van deze tutorial.
Waarom MongoDB gebruiken met GraphQL?
Beide werken met JSON-achtige dataformaten, waardoor je niet voortdurend hoeft te converteren tussen verschillende datastructuren terwijl informatie van database naar API naar client stroomt.
Wat doet een resolver?
Een resolver is een functie die GraphQL vertelt hoe data voor een specifiek veld in je schema moet worden opgehaald of aangepast.
Wat is het verschil tussen een query en een mutatie?
Queries zijn bedoeld om data te lezen/op te halen (zoals GET-requests), terwijl mutaties bedoeld zijn om data te creëren, bij te werken of te verwijderen (zoals POST, PUT, DELETE).

Nic Raboy is een Developer Relations Lead bij MongoDB, waar hij een team van Python-, Java-, C#- en PHP-ontwikkelaars aanstuurt dat geweldige content maakt om developers te helpen MongoDB succesvol in hun projecten op te nemen. Hij heeft ervaring met Golang en JavaScript en schrijft vaak over zijn ontwikkelavonturen.
