Cours
GraphQL est une manière puissante et efficace de concevoir des API. Le client interroge l’API comme il le ferait pour une base de données, et l’API ne renvoie que les données demandées, ce qui réduit souvent la taille des réponses et améliore les temps de réponse. Des temps de réponse faibles sont essentiels aujourd’hui.
Associé à MongoDB, un API GraphQL offre non seulement des réponses rapides, mais aussi un format de données cohérent de bout en bout.
Imaginez ceci : votre client exécute une requête GraphQL dont la structure rappelle le JSON. Lorsque les données arrivent dans votre application — disons en TypeScript dans cet exemple — vous manipulez un format proche de JSON dans l’application. Plus encore, avec MongoDB, les données échangées avec la base sont elles aussi proches de JSON. Résultat : une expérience de données cohérente, sans compromettre la performance. Moins de temps passé à transformer et formater les données, plus de temps pour soigner l’expérience utilisateur de votre application, plutôt que de vous soucier de la base et des outils.
Dans ce tutoriel, nous allons voir à quel point il est simple d’utiliser MongoDB dans une API GraphQL, cette fois avec TypeScript.
Les prérequis
Pour ce tutoriel, assurez-vous d’avoir les éléments suivants avant de commencer :
- Un cluster MongoDB Atlas
- Node.js 22+
- Le pilote MongoDB Node.js 6.x
Nous ne ferons rien de particulièrement complexe côté base dans ce tutoriel ; n’importe quel niveau de cluster MongoDB conviendra, y compris l’offre gratuite. En revanche, nous n’aborderons pas le provisionnement d’un cluster MongoDB Atlas ici. L’hypothèse est que vous avez déjà configuré les règles utilisateurs et réseau. Besoin d’un coup de pouce ? Consultez la documentation Get Started With Atlas sur le sujet.
Vous aurez besoin d’une application Node.js. Si vous le souhaitez, exécutez les commandes suivantes :
mkdir graphql_example
cd graphql_example
npm init -y
Ces commandes créent un nouveau répertoire de projet et un fichier package.json minimal. Nous utiliserons TypeScript, Express Framework, MongoDB et Apollo dans ce projet. Installez-les avec les commandes suivantes :
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
Ces commandes installent les dépendances ainsi que les définitions de types nécessaires à TypeScript.
Dernier prérequis : un fichier de configuration TypeScript. À la racine du projet, créez un fichier tsconfig.json avec le JSON suivant :
{
"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"]
}
À tout moment, si vous souhaitez voir le projet complet ou l’essayer, vous pouvez le récupérer sur GitHub.
Poser les bases avec Apollo, Express et les dépendances MongoDB
Côté structure de projet, nous allons utiliser les éléments suivants :
- src/database/connection.ts
- src/database/userService.ts
- src/resolvers/index.ts
- src/schema/typeDefs.ts
- src/types/index.ts
- src/index.ts
- .env
Le fichier src/index.ts contiendra tout l’amorçage d’Express Framework et d’Apollo : démarrage du serveur, définition des points de terminaison GraphQL, etc. Le fichier src/types/index.ts regroupera nos définitions TypeScript pour manipuler le modèle de données de la base dans l’application. À ne pas confondre avec src/schema/typeDefs.ts qui, bien que similaire, porte sur les définitions de types GraphQL.
Il nous reste donc les répertoires database et resolvers. L’essentiel de la logique applicative et d’accès aux données s’y trouvera, et nous allons y venir rapidement.
Concentrons-nous d’abord sur la mise en place du serveur dans 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();
Nous enrichirons ce fichier au fil de l’eau.
Pour commencer, nous importons toutes les dépendances du projet. Ensuite, nous configurons le serveur Express et le middleware Apollo.
const server = new ApolloServer({
typeDefs,
resolvers,
});
Nous n’avons pas encore renseigné src/schema/typeDefs.ts ni src/resolvers/index.ts, mais cette étape prépare le terrain. Le serveur ne démarrera pas sans erreurs tant que nous n’aurons pas comblé les manques dans ces fichiers.
À noter : cet exemple autorise les requêtes depuis toutes les origines, à cause de :
cors<cors.CorsRequest>(),
C’est pratique en développement, mais en production, limitez-vous aux origines nécessaires.
Interagir avec MongoDB depuis l’application TypeScript/Node.js
Nous avons la base du serveur Express et Apollo, mais la connexion MongoDB n’est pas encore configurée dans l’application. Avant de passer au code, ajoutons les informations de connexion dans le fichier .env :
MONGODB_URI="mongodb+srv://<USERNAME>:<PASSWORD>@<HOST>/?appName=devrel-graphql"
DB_NAME="graphql_example"
Remplacez <USERNAME>, <PASSWORD> et <HOST> par les informations de votre cluster MongoDB Atlas, disponibles dans votre tableau de bord MongoDB Atlas.
Ensuite, mettons en place la logique de connexion à la base pour l’application. Dans le fichier **src/database/connection.ts**, ajoutez le code suivant :
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');
}
}
L’idée ici est de créer une connexion singleton. Il existe bien d’autres approches pour intégrer MongoDB, mais c’est celle que nous retenons pour cette application.
La fonction connectToDatabase lit les informations de connexion depuis le fichier **.env**, se connecte et renvoie la base définie. En cas d’échec, l’erreur est capturée et relancée. Vous ne devriez l’appeler qu’au démarrage du serveur, comme montré précédemment.
La fonction getDatabase sera utilisée à chaque requête vers l’application. Elle récupère l’instance courante de la connexion.
Enfin, pour rester propre, la fonction closeDatabase ferme la connexion lorsque le serveur s’arrête. À vous de voir si vous souhaitez l’inclure, mais c’est une bonne pratique.
Avant de passer à la logique base de données, définissons nos types TypeScript pour nos Data Transfer Objects (DTO) :
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;
}
Ces interfaces, placées dans **src/types/index.ts**, sont gérées par notre API, mais la logique base doit aussi en tenir compte. Vous allez le voir tout de suite.
Passons aux interactions réelles avec la base pour créer, lire, mettre à jour et supprimer (CRUD). Tout se passe dans src/database/userService.ts. L’idée : les resolvers GraphQL gèrent la logique applicative et appellent les fonctions de ce service, qui lui s’occupe de la base.
Dans le fichier src/database/userService.ts, nous avons le code suivant :
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');
}
}
Première chose à noter : l’interface suivante :
interface UserDocument {
_id: ObjectId;
name: string;
email: string;
age?: number;
createdAt: Date;
}
Elle ressemble à notre interface User, sans être identique. UserDocument reflète plus fidèlement le modèle de la base. Dans la base, nous utilisons ObjectId, tandis que l’application manipule des chaînes. Dans MongoDB, l’identifiant est _id et non id, comme dans User.
Entrons dans la classe UserService.
Après avoir obtenu un handle sur la collection via le constructor, nous avons la série de fonctions suivante :
- toUser
- getAllUsers
- getUserById
- createUser
- updateUser
- deleteUser
- createIndexes
La fonction toUser sert d’aide à la conversion entre User et UserDocument. La base échange des UserDocument ; l’application envoie et reçoit des User.
async createIndexes(): Promise<void> {
await this.collection.createIndex({ email: 1 }, { unique: true });
await this.collection.createIndex({ createdAt: -1 });
console.log('Database indexes created');
}
La fonction createIndexes crée quelques index d’exemple potentiellement utiles. Par défaut, seul le champ _id est indexé, ce qui suffit pour une démo avec peu de documents. Mais à mesure que l’application grandit, ne négligez pas la création d’index adaptés à vos données et à vos requêtes. Les deux ci-dessus ne sont que des exemples.
Voyons chaque fonction de service, en commençant par getAllUsers :
async getAllUsers(): Promise<User[]> {
const users = await this.collection.find().toArray();
return users.map(doc => this.toUser(doc));
}
Cette fonction récupère tous les documents de la collection, car aucun filtre n’est passé à find. Les résultats sont convertis via toUser puis renvoyés à l’application, c’est-à-dire à nos resolvers Apollo.
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;
}
}
La fonction getByUserId est similaire, mais utilise findOne avec un critère de filtre : nous voulons un utilisateur unique par _id. Comme le champ id de l’interface User est une chaîne, nous l’engloberons dans ObjectId pour l’interroger dans MongoDB.
Passons à la création de données dans MongoDB avec 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);
}
Après l’appel à insertOne, nous utilisons l’insertedId renvoyé pour relire le document. En GraphQL, lorsqu’une création a lieu, il est courant de pouvoir récupérer l’objet créé dans la même mutation.
La fonction updateUser diffère un peu :
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;
}
}
Ici, nous autorisons uniquement la mise à jour des champs name, email et age. Tous sont optionnels ; nous ne les ajoutons au $set que s’ils sont présents.
Cela nous mène à findOneAndUpdate.
Le premier paramètre est le filtre : nous mettons à jour uniquement si l’_id correspond. En cas de correspondance, l’opérateur $set remplace ou crée les champs présents dans updateFields. Les champs absents ne sont pas modifiés. Enfin, comme nous « trouvons et mettons à jour », nous fixons returnDocument à after pour obtenir le document modifié en réponse. Avec updateOne, nous n’aurions reçu que des infos d’opération, pas les données.
La dernière opération CRUD consiste à supprimer des données 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;
}
}
Cette fonction deleteOne rappelle findOne : on filtre par _id. La différence : si correspondance il y a — au maximum un document — il est supprimé.
À ce stade, la logique base est prête. Pour finaliser, connectons-la depuis **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...
}
Dans l’extrait ci-dessus, l’essentiel de src/index.ts est omis. L’objectif est de montrer l’appel à connectToDatabase et la création des index. Concrètement, placez ces trois lignes en haut de votre fonction startServer.
Nous pouvons maintenant nous concentrer sur la logique de l’API.
Définir les types GraphQL, les resolvers et la logique métier
Nous avons défini les types TypeScript pour la base et pour la circulation des données dans l’application. En revanche, nous n’avons pas encore créé les définitions de types GraphQL, qui décrivent ce que l’utilisateur peut manipuler via notre API.
Dans le fichier src/schema/typeDefs.ts, ajoutez :
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!
}
;
Le type User définit ce que le client est autorisé à demander dans une requête. Ici, il reflète l’interface User, mais ce n’est pas obligatoire. Par exemple, votre interface pourrait contenir un mot de passe que vous ne renverriez jamais au client.
Le type Query déclare les requêtes possibles dans notre API. Chaque requête renvoie un User ou un tableau de User, avec la logique que vous souhaitez intercaler.
Le type Mutation est similaire, mais dédié aux opérations qui modifient les données.
Avec les types GraphQL en place, passons à la logique des resolvers. Dans src/resolvers/index.ts, ajoutez :
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);
},
},
};
Ces resolvers prolongent simplement ce que nous avons défini dans les types pour les requêtes et mutations.
Regardez la requête users :
users: async (): Promise<User[]> => {
const userService = new UserService();
return await userService.getAllUsers();
},
Cette requête GraphQL s’appuie sur UserService pour récupérer tous les utilisateurs. Le User[] est renvoyé au client, mais uniquement les champs demandés et autorisés par les définitions de types GraphQL.
Par exemple, si le client envoie cette requête :
query {
users {
name
email
}
}
Dans cet exemple, d’autres champs existent, mais le client ne souhaite que name et email. Cela fonctionne grâce aux définitions de types GraphQL côté client. Apollo se charge du reste.
Les mêmes principes s’appliquent à chaque requête et mutation dans **src/resolvers/index.ts**.
createUser: async (
_: unknown,
{ name, email, age }: CreateUserInput
): Promise<User> => {
const userService = new UserService();
return await userService.createUser({ name, email, age });
},
Pour la mutation createUser, le client peut transmettre name, email et age. Le resolver fait de même et appelle la fonction createUser du service base. La réponse renvoyée dépend des champs demandés par le client.
Construire et exécuter l’API GraphQL
À ce stade, votre API GraphQL est en bonne voie. Plusieurs solutions existent pour construire et exécuter l’application.
Par exemple, ajoutez ceci à votre **package.json** :
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"dev:watch": "tsx watch src/index.ts",
"watch": "tsc -w"
},
Avec cette approche, exécutez npm run build pour construire le projet et npm run start pour le lancer. Le port par défaut est 3000 ; vous pouvez le tester via cURL ou l’outil de votre choix.
Conclusion
Vous venez de voir comment créer une API GraphQL avec TypeScript et MongoDB comme base de données. Même si l’exemple est volontairement simple, nous avons mis en place des requêtes et mutations pour réaliser des opérations CRUD. MongoDB s’associe parfaitement avec une API GraphQL, car le format de données reste semblable de bout en bout. Par exemple, le client envoie une requête au format proche de JSON. Les données véhiculées nécessitent peu de transformations, et il en va de même lors des échanges avec la base. L’expérience est fluide : vous pouvez vous concentrer sur l’UX plutôt que sur la manipulation des données pour satisfaire la base.
Si vous avez rencontré des difficultés, consultez le projet finalisé sur GitHub.
FAQs
Pourquoi utiliser GraphQL ?
GraphQL est intéressant car vous interrogez une API comme une base de données, en ne demandant que ce dont vous avez besoin. C’est plus léger et efficace côté client.
Apollo est-il la seule option pour GraphQL ?
Apollo facilite grandement la création d’API GraphQL, mais ce n’est pas la seule option. Les autres solutions sortent toutefois du cadre de ce tutoriel.
Pourquoi associer MongoDB et GraphQL ?
Les deux reposent sur des formats proches de JSON, ce qui évite de convertir sans cesse entre des structures différentes lorsque l’information circule de la base à l’API puis au client.
À quoi sert un resolver ?
Un resolver est une fonction qui indique à GraphQL comment récupérer ou modifier les données pour un champ donné de votre schéma.
Quelle est la différence entre une query et une mutation ?
Les queries servent à lire/récupérer des données (comme des requêtes GET), tandis que les mutations servent à créer, mettre à jour ou supprimer des données (comme POST, PUT, DELETE).

Nic Raboy est responsable des relations avec les développeurs chez MongoDB, où il dirige une équipe de développeurs Python, Java, C# et PHP qui créent un contenu génial pour aider les développeurs à réussir à inclure MongoDB dans leurs projets. Il a de l'expérience avec Golang et JavaScript et écrit souvent sur ses aventures de développement.