MFormations
Modern Backend Engineering

Chapitre 19

19 - Corrections des Exercices

19 - Corrections des Exercices

Chapitre 19 : Corrections Détaillées des Exercices

Introduction

Ce chapitre fournit les corrigés complets des 40 exercices. Chaque correction est structurée pour expliquer non seulement le code mais aussi les décisions architecturales et les compromis.


Exercice 01 : Hello World API

Solution

import express, { Request, Response, NextFunction } from "express";

const app = express();
const PORT = process.env.PORT ?? 3000;

// Middleware de logging
function requestLogger(req: Request, _res: Response, next: NextFunction): void {
  const start = Date.now();
  console.info(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
  next();
  const duration = Date.now() - start;
  console.info(`[${new Date().toISOString()}] ${req.method} ${req.url} - ${duration}ms`);
}

app.use(requestLogger);

app.get("/hello", (_req: Request, res: Response) => {
  res.json({
    message: "Hello World",
    timestamp: new Date().toISOString(),
  });
});

app.get("/health", (_req: Request, res: Response) => {
  res.json({ status: "ok", uptime: process.uptime() });
});

app.listen(PORT, () => {
  console.info(`Server running on http://localhost:${PORT}`);
});

export { app };

Tests

import { describe, it, expect } from "vitest";
import supertest from "supertest";
import { app } from "./01-hello-world";

describe("Hello World API", () => {
  it("should return hello message with timestamp", async () => {
    const response = await supertest(app).get("/hello");
    expect(response.status).toBe(200);
    expect(response.body).toHaveProperty("message", "Hello World");
    expect(response.body).toHaveProperty("timestamp");
    expect(new Date(response.body.timestamp).toISOString()).toBe(response.body.timestamp);
  });

  it("should return health status", async () => {
    const response = await supertest(app).get("/health");
    expect(response.status).toBe(200);
    expect(response.body).toHaveProperty("status", "ok");
  });
});

Discussion

L'approche utilise un middleware de logging pour séparer les préoccupations. Le port est configurable via variable d'environnement avec valeur par défaut. L'export de app permet les tests sans lancer le serveur.


Exercice 02 : CRUD Utilisateur basique

Solution

import { Router, Request, Response } from "express";
import { z } from "zod";

interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
}

const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
});

const updateUserSchema = createUserSchema.partial();

const router = Router();
const users = new Map<string, User>();

// POST /api/users
router.post("/", (req: Request, res: Response) => {
  const result = createUserSchema.safeParse(req.body);
  if (!result.success) {
    res.status(400).json({ error: result.error.flatten() });
    return;
  }

  const id = crypto.randomUUID();
  const user: User = {
    id,
    ...result.data,
    createdAt: new Date(),
    updatedAt: new Date(),
  };
  users.set(id, user);
  res.status(201).json(user);
});

// GET /api/users
router.get("/", (_req: Request, res: Response) => {
  res.json(Array.from(users.values()));
});

// GET /api/users/:id
router.get("/:id", (req: Request, res: Response) => {
  const user = users.get(req.params.id);
  if (!user) {
    res.status(404).json({ error: "User not found" });
    return;
  }
  res.json(user);
});

// PUT /api/users/:id
router.put("/:id", (req: Request, res: Response) => {
  const existing = users.get(req.params.id);
  if (!existing) {
    res.status(404).json({ error: "User not found" });
    return;
  }

  const result = updateUserSchema.safeParse(req.body);
  if (!result.success) {
    res.status(400).json({ error: result.error.flatten() });
    return;
  }

  const updated: User = {
    ...existing,
    ...result.data,
    id: existing.id,
    createdAt: existing.createdAt,
    updatedAt: new Date(),
  };
  users.set(req.params.id, updated);
  res.json(updated);
});

// DELETE /api/users/:id
router.delete("/:id", (req: Request, res: Response) => {
  if (!users.has(req.params.id)) {
    res.status(404).json({ error: "User not found" });
    return;
  }
  users.delete(req.params.id);
  res.status(204).send();
});

export { router as userRouter, users };

Discussion

L'utilisation de Map plutôt qu'un objet JavaScript offre de meilleures performances pour les suppressions fréquentes. UUID v4 pour les identifiants. Zod assure la validation avec des messages d'erreur explicites. Le statut 204 pour DELETE est plus correct que 200.


Exercice 03 : Validation avec Joi/Zod

Solution

import { z } from "zod";

// Schémas
const userSchema = z.object({
  name: z.string().min(3).max(50),
  email: z.string().email(),
  age: z.number().int().min(18).max(120),
});

const productSchema = z.object({
  name: z.string().min(1).max(200),
  price: z.number().positive(),
  category: z.enum(["electronics", "clothing", "food", "books"]),
});

const orderSchema = z.object({
  userId: z.string().uuid(),
  products: z
    .array(
      z.object({
        productId: z.string().uuid(),
        quantity: z.number().int().positive(),
      }),
    )
    .min(1),
  total: z.number().positive(),
});

// Middleware de validation générique
function validate(schema: z.ZodSchema, source: "body" | "query" | "params" = "body") {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req[source]);
    if (!result.success) {
      res.status(400).json({
        error: "Validation failed",
        details: result.error.errors.map((e) => ({
          path: e.path.join("."),
          message: e.message,
        })),
      });
      return;
    }
    req[source] = result.data;
    next();
  };
}

// Utilisation
router.post("/users", validate(userSchema), createUserHandler);
router.post("/products", validate(productSchema), createProductHandler);
router.post("/orders", validate(orderSchema), createOrderHandler);

Discussion

Le middleware de validation générique réutilise un pattern commun. Les messages d'erreur détaillés aident au débogage. L'assignation req[source] = result.data nettoie les données transformées (trim, cast).


Exercice 04 : Middleware personnalisé

Solution

import { Request, Response, NextFunction } from "express";

// Logger
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
  const start = process.hrtime.bigint();
  res.on("finish", () => {
    const duration = Number(process.hrtime.bigint() - start) / 1e6;
    console.info({
      method: req.method,
      url: req.originalUrl,
      status: res.statusCode,
      duration: `${duration.toFixed(2)}ms`,
      ip: req.ip,
      userAgent: req.get("user-agent"),
    });
  });
  next();
}

// Error Handler
export class AppError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public code?: string,
    public details?: unknown,
  ) {
    super(message);
    this.name = "AppError";
  }
}

export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      error: {
        code: err.code ?? "APP_ERROR",
        message: err.message,
        details: err.details,
      },
    });
    return;
  }

  console.error("Unhandled error:", err);
  res.status(500).json({
    error: {
      code: "INTERNAL_ERROR",
      message: "An unexpected error occurred",
    },
  });
}

// Rate Limiter (in-memory, basique)
interface RateLimitEntry {
  count: number;
  resetAt: number;
}

const rateLimitStore = new Map<string, RateLimitEntry>();

export function rateLimiter(maxRequests: number = 100, windowMs: number = 60_000) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const key = req.ip ?? "unknown";
    const now = Date.now();
    let entry = rateLimitStore.get(key);

    if (!entry || now > entry.resetAt) {
      entry = { count: 0, resetAt: now + windowMs };
      rateLimitStore.set(key, entry);
    }

    entry.count++;
    res.setHeader("X-RateLimit-Limit", maxRequests);
    res.setHeader("X-RateLimit-Remaining", Math.max(0, maxRequests - entry.count));
    res.setHeader("X-RateLimit-Reset", Math.ceil(entry.resetAt / 1000));

    if (entry.count > maxRequests) {
      res.status(429).json({
        error: {
          code: "RATE_LIMIT_EXCEEDED",
          message: "Too many requests, please try again later",
        },
      });
      return;
    }

    next();
  };
}

// Request Validator (réutilisable)
export function requestValidator(schema: z.ZodSchema) {
  return (req: Request, _res: Response, next: NextFunction): void => {
    const result = schema.safeParse({ ...req.body, ...req.query, ...req.params });
    if (!result.success) {
      next(new AppError(400, "Validation failed", "VALIDATION_ERROR", result.error.errors));
      return;
    }
    next();
  };
}

Discussion

Chaque middleware a une responsabilité unique. Le rate limiter utilise une Map en mémoire (à remplacer par Redis en production). Le logger utilise les timestamps haute résolution (process.hrtime.bigint()) pour des métriques précises.


Exercice 05 : Routes paramétrées

Solution

import { Router, Request, Response } from "express";

interface ProductQuery {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  page: number;
  limit: number;
  sort?: "price" | "name" | "createdAt";
  order?: "asc" | "desc";
}

const router = Router();

router.get("/products", (req: Request, res: Response) => {
  const {
    category,
    minPrice,
    maxPrice,
    page = "1",
    limit = "10",
    sort = "createdAt",
    order = "desc",
  } = req.query as Record<string, string>;

  const query: ProductQuery = {
    category,
    minPrice: minPrice ? Number(minPrice) : undefined,
    maxPrice: maxPrice ? Number(maxPrice) : undefined,
    page: Math.max(1, Number(page)),
    limit: Math.min(100, Math.max(1, Number(limit))),
    sort: sort as ProductQuery["sort"],
    order: order as ProductQuery["order"],
  };

  // Implémentation simulée
  const filteredProducts = mockProducts
    .filter((p) => !query.category || p.category === query.category)
    .filter((p) => !query.minPrice || p.price >= query.minPrice!)
    .filter((p) => !query.maxPrice || p.price <= query.maxPrice!);

  const total = filteredProducts.length;
  const totalPages = Math.ceil(total / query.limit);
  const start = (query.page - 1) * query.limit;
  const paginatedProducts = filteredProducts.slice(start, start + query.limit);

  res.json({
    data: paginatedProducts,
    pagination: {
      page: query.page,
      limit: query.limit,
      total,
      totalPages,
      hasNext: query.page < totalPages,
      hasPrev: query.page > 1,
    },
  });
});

router.get("/products/:id/reviews", (req: Request, res: Response) => {
  const { id } = req.params;
  const { page = "1", limit = "10" } = req.query as Record<string, string>;

  const productReviews = mockReviews.filter((r) => r.productId === id);
  const pageNum = Number(page);
  const limitNum = Number(limit);
  const start = (pageNum - 1) * limitNum;

  res.json({
    data: productReviews.slice(start, start + limitNum),
    pagination: {
      page: pageNum,
      limit: limitNum,
      total: productReviews.length,
    },
  });
});

Discussion

Les valeurs sont nettoyées et validées avec des bornes (page >= 1, limit entre 1 et 100). La pagination retourne des métadonnées complètes pour que le client sache s'il y a plus de pages.


Exercice 06 : Gestion d'erreurs HTTP

Solution

export class AppError extends Error {
  public readonly statusCode: number;
  public readonly code: string;
  public readonly details?: unknown;
  public readonly isOperational: boolean;

  constructor(statusCode: number, message: string, code?: string, details?: unknown) {
    super(message);
    this.statusCode = statusCode;
    this.code = code ?? "APP_ERROR";
    this.details = details;
    this.isOperational = true;
    this.name = "AppError";
    Error.captureStackTrace(this, this.constructor);
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string, id?: string) {
    const message = id ? `${resource} with id '${id}' not found` : `${resource} not found`;
    super(404, message, "NOT_FOUND");
  }
}

export class ValidationError extends AppError {
  constructor(details: unknown) {
    super(400, "Validation failed", "VALIDATION_ERROR", details);
  }
}

export class UnauthorizedError extends AppError {
  constructor(message = "Authentication required") {
    super(401, message, "UNAUTHORIZED");
  }
}

export class ForbiddenError extends AppError {
  constructor(message = "Access denied") {
    super(403, message, "FORBIDDEN");
  }
}

export class ConflictError extends AppError {
  constructor(message = "Resource already exists") {
    super(409, message, "CONFLICT");
  }
}

// Middleware de traitement centralisé
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
      },
    });
    return;
  }

  // Erreur inattendue
  console.error("UNHANDLED ERROR:", err);
  res.status(500).json({
    error: {
      code: "INTERNAL_SERVER_ERROR",
      message: "An unexpected error occurred",
    },
  });
}

Discussion

Les classes d'erreur spécialisées améliorent la lisibilité et la maintenabilité. Toutes les erreurs opérationnelles sont standardisées. Le flag isOperational permet de distinguer les erreurs prévisibles des bugs inattendus.


Exercices 07-40

Exercice 07 : Environnements et configuration

Pattern : Configuration validation avec Zod, typage strict.

import { z } from "zod";
import dotenv from "dotenv";

dotenv.config();

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "staging", "production", "test"]).default("development"),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url().optional(),
  JWT_SECRET: z.string().min(32),
  JWT_EXPIRES_IN: z.string().default("15m"),
  REFRESH_TOKEN_EXPIRES_IN: z.string().default("7d"),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  CORS_ORIGIN: z.string().default("*"),
});

const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
  console.error("Invalid environment variables:", parsed.error.flatten());
  process.exit(1);
}

export const config = parsed.data;
export type Config = z.infer<typeof envSchema>;

Exercice 08 : Logging structuré

Pattern : Pino avec correlation ID.

import pino from "pino";
import { randomUUID } from "crypto";
import { Request, Response, NextFunction } from "express";

const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  transport:
    process.env.NODE_ENV === "development"
      ? { target: "pino-pretty", options: { colorize: true } }
      : undefined,
  serializers: {
    req: (req) => ({ method: req.method, url: req.url, correlationId: req.correlationId }),
    res: (res) => ({ statusCode: res.statusCode }),
    err: pino.stdSerializers.err,
  },
});

export function correlationMiddleware(req: Request, _res: Response, next: NextFunction): void {
  req.correlationId = (req.get("X-Correlation-ID") ?? randomUUID()) as string;
  next();
}

export { logger };

Exercice 09 : Upload de fichiers

Pattern : Multer + Sharp + S3 presigned URL.

import multer from "multer";
import sharp from "sharp";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const upload = multer({
  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
  fileFilter: (_req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    cb(null, allowed.includes(file.mimetype));
  },
});

async function processImage(buffer: Buffer): Promise<Buffer> {
  return sharp(buffer).resize(1200, 1200, { fit: "inside", withoutEnlargement: true }).webp({ quality: 80 }).toBuffer();
}

async function uploadToS3(buffer: Buffer, key: string): Promise<string> {
  const s3 = new S3Client({ region: process.env.AWS_REGION });
  await s3.send(new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key, Body: buffer }));
  return getSignedUrl(s3, new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key }), { expiresIn: 3600 });
}

Exercice 10 : Sérialisation et DTO

Pattern : DTOs explicites avec class-transformer ou mapping manuel.

interface UserEntity {
  id: string;
  name: string;
  email: string;
  passwordHash: string;
  role: string;
  createdAt: Date;
  updatedAt: Date;
}

interface UserResponse {
  id: string;
  name: string;
  email: string;
  role: string;
  createdAt: string;
}

function toUserResponse(user: UserEntity): UserResponse {
  return {
    id: user.id,
    name: user.name,
    email: user.email,
    role: user.role,
    createdAt: user.createdAt.toISOString(),
  };
}

interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
    hasNext: boolean;
    hasPrev: boolean;
  };
}

function toPaginatedResponse<T>(data: T[], total: number, page: number, limit: number): PaginatedResponse<T> {
  return {
    data,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
      hasNext: page * limit < total,
      hasPrev: page > 1,
    },
  };
}

Exercice 11 : PostgreSQL avec TypeORM

import "reflect-metadata";
import { DataSource, Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany, ManyToOne, JoinColumn } from "typeorm";

@Entity("users")
class User {
  @PrimaryGeneratedColumn("uuid")
  id!: string;

  @Column({ length: 100 })
  name!: string;

  @Column({ unique: true })
  email!: string;

  @Column({ select: false })
  passwordHash!: string;

  @CreateDateColumn()
  createdAt!: Date;

  @OneToMany(() => Post, (post) => post.author)
  posts!: Post[];
}

const AppDataSource = new DataSource({
  type: "postgres",
  url: process.env.DATABASE_URL,
  entities: [User],
  synchronize: process.env.NODE_ENV !== "production",
  logging: process.env.NODE_ENV === "development",
  poolSize: 20,
});

export { AppDataSource, User };

Exercice 12 : Authentification JWT

import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import { z } from "zod";

const SALT_ROUNDS = 12;

const registerSchema = z.object({
  name: z.string().min(3).max(100),
  email: z.string().email(),
  password: z.string().min(8).max(128),
});

interface TokenPair {
  accessToken: string;
  refreshToken: string;
}

async function generateTokens(userId: string, role: string): Promise<TokenPair> {
  const accessToken = jwt.sign({ sub: userId, role }, process.env.JWT_SECRET!, {
    expiresIn: "15m",
  });
  const refreshToken = jwt.sign({ sub: userId, type: "refresh" }, process.env.JWT_REFRESH_SECRET!, {
    expiresIn: "7d",
  });
  return { accessToken, refreshToken };
}

function verifyAccessToken(token: string): jwt.JwtPayload {
  return jwt.verify(token, process.env.JWT_SECRET!) as jwt.JwtPayload;
}

async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

// Middleware d'authentification
import { Request, Response, NextFunction } from "express";

interface AuthRequest extends Request {
  userId?: string;
  userRole?: string;
}

function authenticate(req: AuthRequest, res: Response, next: NextFunction): void {
  const header = req.headers.authorization;
  if (!header?.startsWith("Bearer ")) {
    res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Missing token" } });
    return;
  }

  try {
    const payload = verifyAccessToken(header.slice(7));
    req.userId = payload.sub;
    req.userRole = payload.role as string;
    next();
  } catch {
    res.status(401).json({ error: { code: "TOKEN_EXPIRED", message: "Token invalid or expired" } });
  }
}

Exercice 13 : Cache Redis

import { createClient } from "redis";
import { Request, Response, NextFunction } from "express";

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

interface CacheOptions {
  ttl?: number;
  key?: (req: Request) => string;
}

function cacheMiddleware(options: CacheOptions = {}) {
  return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    if (req.method !== "GET") {
      next();
      return;
    }

    const key = options.key?.(req) ?? `cache:${req.originalUrl}`;
    const ttl = options.ttl ?? 300;

    try {
      const cached = await redisClient.get(key);
      if (cached) {
        res.json(JSON.parse(cached));
        return;
      }

      const originalJson = res.json.bind(res);
      res.json = function (body: unknown): Response {
        redisClient.setEx(key, ttl, JSON.stringify(body)).catch(console.error);
        return originalJson(body);
      };
      next();
    } catch (error) {
      console.error("Cache error:", error);
      next();
    }
  };
}

// Invalidation pattern
async function invalidateCache(pattern: string): Promise<void> {
  const keys = await redisClient.keys(pattern);
  if (keys.length > 0) {
    await redisClient.del(keys);
  }
}

Exercice 14 : Pagination avancée

interface OffsetPaginationParams {
  page: number;
  limit: number;
}

interface CursorPaginationParams {
  cursor?: string;
  limit: number;
}

interface OffsetPaginationResult<T> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

interface CursorPaginationResult<T> {
  data: T[];
  pagination: {
    nextCursor?: string;
    hasMore: boolean;
  };
}

class PaginationService {
  static offset(page: number, limit: number): { skip: number; take: number } {
    return {
      skip: (page - 1) * limit,
      take: limit,
    };
  }

  static cursor(cursor?: string): { cursor?: Date; take: number } {
    if (!cursor) return { take: 21 }; // 20 + 1 for hasMore check
    const decoded = Buffer.from(cursor, "base64").toString("utf-8");
    return { cursor: new Date(decoded), take: 21 };
  }

  static encodeCursor(date: Date): string {
    return Buffer.from(date.toISOString()).toString("base64");
  }
}

Exercice 15 : Recherche full-text

import { Router, Request, Response } from "express";

const searchRouter = Router();

searchRouter.get("/search", async (req: Request, res: Response) => {
  const { q, category, minPrice, maxPrice, page = "1", limit = "20" } = req.query as Record<string, string>;

  const query = `
    SELECT 
      p.*,
      ts_rank(p.search_vector, plainto_tsquery('english', $1)) AS rank,
      ts_headline('english', p.description, plainto_tsquery('english', $1),
        'StartSel=<mark>, StopSel=</mark>, MaxWords=50, MinWords=20') AS headline
    FROM products p
    WHERE p.search_vector @@ plainto_tsquery('english', $1)
      AND ($2::text IS NULL OR p.category = $2)
      AND ($3::numeric IS NULL OR p.price >= $3)
      AND ($4::numeric IS NULL OR p.price <= $4)
    ORDER BY rank DESC
    LIMIT $5 OFFSET $6
  `;

  const offset = (Number(page) - 1) * Number(limit);
  const result = await pool.query(query, [q, category ?? null, minPrice ?? null, maxPrice ?? null, Number(limit), offset]);

  res.json({
    data: result.rows,
    search: { query: q, total: result.rowCount },
  });
});

Exercice 16 : Rate limiting distribué

import { createClient } from "redis";

const redis = createClient({ url: process.env.REDIS_URL });

async function slidingWindowRateLimit(
  key: string,
  limit: number,
  windowMs: number,
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
  const now = Date.now();
  const windowStart = now - windowMs;
  const multi = redis.multi();

  multi.zRemRangeByScore(key, 0, windowStart);
  multi.zAdd(key, { score: now, value: `${now}` });
  multi.zCard(key);
  multi.expire(key, Math.ceil(windowMs / 1000));
  multi.zRangeWithScores(key, 0, -1);

  const [, , count] = await multi.exec();
  const requestCount = count as number;

  return {
    allowed: requestCount <= limit,
    remaining: Math.max(0, limit - requestCount),
    resetAt: now + windowMs,
  };
}

Exercice 17 : Import/Export CSV

import { parse } from "csv-parse";
import { stringify } from "csv-stringify";
import { Transform } from "stream";
import { pipeline } from "stream/promises";

async function importCSV<T>(filePath: string, batchSize: number, processBatch: (records: T[]) => Promise<void>): Promise<void> {
  let batch: T[] = [];
  const parser = parse({ columns: true, skipEmptyLines: true, trim: true });

  const transformer = new Transform({
    objectMode: true,
    transform(record: T, _encoding, callback) {
      batch.push(record);
      if (batch.length >= batchSize) {
        const currentBatch = batch;
        batch = [];
        processBatch(currentBatch).then(() => callback(null)).catch(callback);
      } else {
        callback(null);
      }
    },
    flush(callback) {
      if (batch.length > 0) {
        processBatch(batch).then(() => callback(null)).catch(callback);
      } else {
        callback(null);
      }
    },
  });

  const source = await fs.open(filePath, "r");
  const readStream = source.createReadStream();
  await pipeline(readStream, parser, transformer);
}

async function exportCSV<T>(data: T[], columns: string[], filePath: string): Promise<void> {
  const stringifier = stringify({ header: true, columns });
  const writeStream = fs.createWriteStream(filePath);
  for (const record of data) {
    stringifier.write(record);
  }
  stringifier.end();
  await pipeline(stringifier, writeStream);
}

Exercice 18 : WebSockets

import { Server as SocketIOServer } from "socket.io";
import { Server as HTTPServer } from "http";
import jwt from "jsonwebtoken";

function setupWebSocket(httpServer: HTTPServer): SocketIOServer {
  const io = new SocketIOServer(httpServer, {
    cors: { origin: process.env.CORS_ORIGIN },
    pingInterval: 25000,
    pingTimeout: 20000,
  });

  io.use((socket, next) => {
    const token = socket.handshake.auth.token;
    if (!token) {
      next(new Error("Authentication required"));
      return;
    }
    try {
      const payload = jwt.verify(token, process.env.JWT_SECRET!) as jwt.JwtPayload;
      (socket as any).userId = payload.sub;
      next();
    } catch {
      next(new Error("Invalid token"));
    }
  });

  io.on("connection", (socket) => {
    const userId = (socket as any).userId;
    console.info(`User ${userId} connected`);

    socket.join(`user:${userId}`);

    socket.on("join:room", (room: string) => {
      socket.join(room);
    });

    socket.on("leave:room", (room: string) => {
      socket.leave(room);
    });

    socket.on("disconnect", () => {
      console.info(`User ${userId} disconnected`);
    });
  });

  return io;
}

Exercice 19 : Tests d'intégration

import { describe, beforeAll, afterAll, it, expect } from "vitest";
import supertest from "supertest";
import { AppDataSource } from "../src/database";
import { app } from "../src/app";

const request = supertest(app);

beforeAll(async () => {
  await AppDataSource.initialize();
  await AppDataSource.runMigrations();
});

afterAll(async () => {
  await AppDataSource.dropDatabase();
  await AppDataSource.destroy();
});

describe("User API Integration", () => {
  it("should create a user", async () => {
    const res = await request.post("/api/users").send({ name: "Test", email: "test@example.com" });
    expect(res.status).toBe(201);
    expect(res.body.data).toHaveProperty("id");
  });

  it("should list users", async () => {
    const res = await request.get("/api/users");
    expect(res.status).toBe(200);
    expect(res.body.data).toBeInstanceOf(Array);
  });
});

Exercice 20 : Documentation Swagger

import swaggerJsdoc from "swagger-jsdoc";
import swaggerUi from "swagger-ui-express";
import { Express } from "express";

const options: swaggerJsdoc.Options = {
  definition: {
    openapi: "3.0.0",
    info: {
      title: "Modern Backend API",
      version: "1.0.0",
      description: "RESTful API documentation",
    },
    servers: [{ url: "/api/v1" }],
    components: {
      securitySchemes: {
        bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
      },
    },
  },
  apis: ["./src/routes/*.ts", "./src/controllers/*.ts"],
};

export function setupSwagger(app: Express): void {
  const specs = swaggerJsdoc(options);
  app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(specs, { explorer: true }));
}

Exercices Niveau Avancé (21-30)

Exercice 21 : Queue BullMQ

import { Queue, Worker, QueueEvents } from "bullmq";
import IORedis from "ioredis";

const connection = new IORedis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });

const emailQueue = new Queue("email", { connection });

const worker = new Worker(
  "email",
  async (job) => {
    const { to, subject, body } = job.data;
    await sendEmail(to, subject, body);
    return { sent: true };
  },
  {
    connection,
    concurrency: 5,
    limiter: { max: 10, duration: 1000 },
  },
);

worker.on("completed", (job) => console.info(`Job ${job.id} completed`));
worker.on("failed", (job, err) => console.error(`Job ${job?.id} failed:`, err));

Exercice 22 : Kafka Producer/Consumer

import { Kafka, Producer, Consumer, EachMessagePayload } from "kafkajs";

const kafka = new Kafka({
  clientId: "backend-app",
  brokers: (process.env.KAFKA_BROKERS ?? "localhost:9093").split(","),
});

const producer: Producer = kafka.producer({ allowAutoTopicCreation: true, idempotent: true });

const consumer: Consumer = kafka.consumer({ groupId: "backend-group" });

async function produce(topic: string, messages: Array<{ key?: string; value: string }>): Promise<void> {
  await producer.connect();
  await producer.send({
    topic,
    messages: messages.map((m) => ({
      key: m.key,
      value: m.value,
      timestamp: Date.now().toString(),
    })),
    acks: -1, // all replicas
  });
}

async function consume(topic: string, handler: (payload: EachMessagePayload) => Promise<void>): Promise<void> {
  await consumer.connect();
  await consumer.subscribe({ topic, fromBeginning: false });
  await consumer.run({
    eachMessage: handler,
    autoCommit: true,
    partitionsConsumedConcurrently: 3,
  });
}

Exercice 23 : API Gateway

import httpProxy from "http-proxy-middleware";
import { Router, Request, Response, NextFunction } from "express";

const proxy = httpProxy.createProxyMiddleware({
  changeOrigin: true,
  proxyTimeout: 30000,
  timeout: 30000,
});

const services = {
  users: "http://user-service:3001",
  orders: "http://order-service:3002",
  payments: "http://payment-service:3003",
};

const gatewayRouter = Router();

gatewayRouter.use("/api/v1/users", authenticate, rateLimit("users"), (req, res, next) => {
  proxy.web(req, res, { target: services.users }, next);
});

gatewayRouter.use("/api/v1/orders", authenticate, rateLimit("orders"), (req, res, next) => {
  proxy.web(req, res, { target: services.orders }, next);
});

Exercice 24 : Cache invalidation pattern

// Pub/Sub pour l'invalidation distribuée
const publisher = createClient({ url: process.env.REDIS_URL! });
const subscriber = createClient({ url: process.env.REDIS_URL! });

await publisher.connect();
await subscriber.connect();

async function publishInvalidation(pattern: string): Promise<void> {
  await publisher.publish("cache:invalidation", JSON.stringify({ pattern, timestamp: Date.now() }));
}

async function subscribeInvalidation(): Promise<void> {
  await subscriber.subscribe("cache:invalidation", async (message) => {
    const { pattern } = JSON.parse(message);
    const keys = await publisher.keys(pattern);
    if (keys.length > 0) {
      await publisher.del(keys);
    }
  });
}

Exercice 25 : Transactions distribuées (Saga)

interface SagaStep<T = unknown> {
  name: string;
  execute: (context: T) => Promise<void>;
  compensate: (context: T) => Promise<void>;
}

class SagaOrchestrator<T = unknown> {
  private steps: SagaStep<T>[] = [];
  private executedSteps: SagaStep<T>[] = [];

  addStep(step: SagaStep<T>): this {
    this.steps.push(step);
    return this;
  }

  async execute(context: T): Promise<void> {
    for (const step of this.steps) {
      try {
        await step.execute(context);
        this.executedSteps.push(step);
      } catch (error) {
        console.error(`Saga failed at step '${step.name}', compensating...`);
        await this.compensate();
        throw error;
      }
    }
  }

  private async compensate(): Promise<void> {
    for (const step of this.executedSteps.reverse()) {
      try {
        await step.compensate(step as unknown as T);
      } catch (error) {
        console.error(`Compensation failed for step '${step.name}':`, error);
      }
    }
  }
}

Exercice 26 : Docker multi-stage

Voir le Dockerfile à la racine du projet pour l'implémentation complète multi-stage avec optimisation layer caching, alpine, healthcheck, et tini.

Exercice 27 : CI/CD Pipeline

Voir .github/workflows/ci.yml et .github/workflows/deploy.yml à la racine du projet.

Exercice 28 : Monitoring et métriques

import prometheus from "prom-client";

const httpRequestsTotal = new prometheus.Counter({
  name: "http_requests_total",
  help: "Total HTTP requests",
  labelNames: ["method", "route", "status"],
});

const httpRequestDuration = new prometheus.Histogram({
  name: "http_request_duration_seconds",
  help: "HTTP request duration in seconds",
  labelNames: ["method", "route"],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
});

const activeConnections = new prometheus.Gauge({
  name: "http_active_connections",
  help: "Active HTTP connections",
});

Exercice 29 : Graceful Shutdown

async function gracefulShutdown(server: http.Server, ...clients: Array<{ destroy: () => Promise<void> }>): Promise<void> {
  console.info("SIGTERM received. Shutting down gracefully...");

  server.close(async () => {
    console.info("HTTP server closed");
    for (const client of clients) {
      await client.destroy();
    }
    process.exit(0);
  });

  // Force shutdown after timeout
  setTimeout(() => {
    console.error("Forced shutdown after 30s timeout");
    process.exit(1);
  }, 30000).unref();
}

process.on("SIGTERM", () => gracefulShutdown(server, redisClient, dbConnection));
process.on("SIGINT", () => gracefulShutdown(server, redisClient, dbConnection));

Exercice 30 : Feature Flags

interface FeatureFlag {
  name: string;
  enabled: boolean;
  percentage?: number;
  rules?: Array<{ field: string; operator: string; value: unknown }>;
}

class FeatureFlagService {
  private cache = new Map<string, boolean>();

  async isEnabled(flag: string, context?: Record<string, unknown>): Promise<boolean> {
    const cacheKey = `${flag}:${JSON.stringify(context)}`;
    if (this.cache.has(cacheKey)) return this.cache.get(cacheKey)!;

    const feature = await this.getFeatureFlag(flag);
    if (!feature) return false;
    if (!feature.enabled) return false;

    if (feature.percentage && context?.userId) {
      const hash = this.hashCode(String(context.userId)) % 100;
      if (hash >= feature.percentage) return false;
    }

    this.cache.set(cacheKey, feature.enabled);
    return feature.enabled;
  }
}

Exercices Niveau Expert (31-40)

Exercice 31 : Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend-app
  labels:
    app: backend
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 1
  selector:
    matchLabels:
      app: backend
  template:
    metadata:
      labels:
        app: backend
    spec:
      containers:
        - name: app
          image: ghcr.io/org/backend:latest
          ports:
            - containerPort: 3000
          envFrom:
            - configMapRef:
                name: app-config
            - secretRef:
                name: app-secrets
          livenessProbe:
            httpGet: { path: /health, port: 3000 }
            initialDelaySeconds: 10
            periodSeconds: 30
          readinessProbe:
            httpGet: { path: /ready, port: 3000 }
            initialDelaySeconds: 5
            periodSeconds: 10
          resources:
            requests: { cpu: "250m", memory: "256Mi" }
            limits: { cpu: "500m", memory: "512Mi" }

Exercice 32 : Helm Chart

# Chart.yaml
apiVersion: v2
name: backend-app
description: Modern Backend Application
type: application
version: 0.1.0
appVersion: "1.0.0"
dependencies:
  - name: postgresql
    version: "12.x"
    repository: "https://charts.bitnami.com/bitnami"
  - name: redis
    version: "18.x"
    repository: "https://charts.bitnami.com/bitnami"

Exercice 33 : Terraform Infrastructure

# main.tf
terraform {
  backend "s3" {
    bucket         = "backend-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"
  name   = "backend-${var.environment}"
  cidr   = "10.0.0.0/16"
  azs    = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
  enable_nat_gateway = true
}

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.0"
  cluster_name    = "backend-${var.environment}"
  cluster_version = "1.30"
  vpc_id          = module.vpc.vpc_id
  subnet_ids      = module.vpc.private_subnets
  node_groups = {
    main = {
      desired_capacity = 3
      max_capacity     = 10
      min_capacity     = 1
      instance_types   = ["t3.medium"]
    }
  }
}

Exercice 34 : Service Mesh (Istio)

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: backend
spec:
  hosts:
    - backend
  http:
    - match:
        - headers:
            x-canary:
              exact: "true"
      route:
        - destination:
            host: backend
            subset: v2
          weight: 100
    - route:
        - destination:
            host: backend
            subset: v1
          weight: 90
        - destination:
            host: backend
            subset: v2
          weight: 10
  retries:
    attempts: 3
    perTryTimeout: 2s

Exercice 35 : Database Sharding

class ShardManager {
  private shards: string[];
  private readonly SHARD_COUNT: number;

  constructor(shards: string[]) {
    this.shards = shards;
    this.SHARD_COUNT = shards.length;
  }

  getShard(key: string): string {
    const hash = this.hash(key);
    const shardIndex = hash % this.SHARD_COUNT;
    return this.shards[shardIndex];
  }

  private hash(key: string): number {
    let hash = 0;
    for (let i = 0; i < key.length; i++) {
      const char = key.charCodeAt(i);
      hash = (hash << 5) - hash + char;
      hash = hash & hash; // Convert to 32bit integer
    }
    return Math.abs(hash);
  }
}

Exercice 36 : Event Sourcing

interface DomainEvent {
  id: string;
  aggregateId: string;
  aggregateType: string;
  type: string;
  data: Record<string, unknown>;
  version: number;
  timestamp: Date;
}

class EventStore {
  async append(event: DomainEvent): Promise<void> {
    await pool.query(
      `INSERT INTO events (id, aggregate_id, aggregate_type, type, data, version, timestamp)
       VALUES ($1, $2, $3, $4, $5, $6, $7)`,
      [event.id, event.aggregateId, event.aggregateType, event.type, JSON.stringify(event.data), event.version, event.timestamp],
    );
  }

  async getEvents(aggregateId: string): Promise<DomainEvent[]> {
    const result = await pool.query(
      "SELECT * FROM events WHERE aggregate_id = $1 ORDER BY version ASC",
      [aggregateId],
    );
    return result.rows;
  }

  async getProjection<T>(projectionName: string): Promise<T | null> {
    const result = await pool.query(
      "SELECT data FROM projections WHERE name = $1",
      [projectionName],
    );
    return result.rows[0]?.data ?? null;
  }
}

Exercice 37 : GraphQL

import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import DataLoader from "dataloader";

const typeDefs = `#graphql
  type User { id: ID!, name: String!, email: String!, posts: [Post!]! }
  type Post { id: ID!, title: String!, author: User!, comments: [Comment!]! }
  type Comment { id: ID!, text: String!, author: User! }
  type Query { users: [User!]! user(id: ID!): User posts: [Post!]! }
  type Mutation { createUser(name: String!, email: String!): User! }
`;

const resolvers = {
  Query: {
    users: () => userService.findAll(),
    user: (_: unknown, { id }: { id: string }) => userService.findById(id),
  },
  User: {
    posts: (parent: { id: string }) => postLoaders.load(parent.id),
  },
};

const postLoaders = new DataLoader(async (userIds: readonly string[]) => {
  const posts = await postService.findByUserIds(userIds as string[]);
  const grouped = new Map<string, typeof posts>();
  for (const post of posts) {
    if (!grouped.has(post.authorId)) grouped.set(post.authorId, []);
    grouped.get(post.authorId)!.push(post);
  }
  return userIds.map((id) => grouped.get(id) ?? []);
});

Exercice 38 : gRPC

syntax = "proto3";

package user;

service UserService {
  rpc GetUser (GetUserRequest) returns (User);
  rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
  rpc CreateUser (CreateUserRequest) returns (User);
  rpc StreamUsers (Empty) returns (stream User);
}

message User {
  string id = 1;
  string name = 2;
  string email = 3;
}

message GetUserRequest { string id = 1; }
message ListUsersRequest { int32 page = 1; int32 limit = 2; }
message ListUsersResponse { repeated User users = 1; int32 total = 2; }
message CreateUserRequest { string name = 1; string email = 2; }
message Empty {}

Exercice 39 : Chaos Engineering

class ChaosMonkey {
  private enabled = false;
  private failureRate = 0.1;

  async injectLatency(durationMs: number): Promise<void> {
    if (!this.enabled || Math.random() > this.failureRate) return;
    await new Promise((resolve) => setTimeout(resolve, durationMs));
  }

  async injectError<T>(fallback: T): Promise<T> {
    if (!this.enabled || Math.random() > this.failureRate) return fallback;
    throw new Error("Chaos monkey injected failure");
  }
}

Exercice 40 : Plateforme complète

L'exercice 40 est un projet de synthèse qui combine l'ensemble des concepts. L'architecture proposée :

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│   Client     │────▶│  API Gateway │────▶│ Auth Service│
└─────────────┘     └──────┬───────┘     └─────────────┘
                           │
              ┌────────────┼────────────────┐
              ▼            ▼                ▼
       ┌──────────┐ ┌──────────┐ ┌──────────────┐
       │  User    │ │  Order   │ │  Payment     │
       │ Service  │ │  Service │ │  Service     │
       └────┬─────┘ └────┬─────┘ └──────┬───────┘
            │            │              │
       ┌────▼────┐ ┌────▼────┐    ┌─────▼──────┐
       │Postgres │ │  Redis  │    │   Kafka    │
       └─────────┘ └─────────┘    └────────────┘

Le déploiement complet inclut :

  • Kubernetes avec Helm
  • Terraform pour l'infrastructure cloud
  • CI/CD avec GitHub Actions
  • Monitoring Prometheus/Grafana
  • Tracing avec OpenTelemetry
  • Logging structuré ELK
  • Tests de résilience Chaos Engineering