Home
2 janvier 202510 min de lecture

Le DDD avec Nest.js expliqué à mon frère de 6 ans (diagnostiqué HPI)

Domain Driven DesignSanté Mentale

Aujourd'hui, je vais t'expliquer un truc encore plus tordu que les 3 derniers films Marvel sortis chez Sony Pictures : le Domain-Driven Design avec Nest.js.

Pourquoi on s'inflige ça ?

Y a deux types de devs dans ce monde :

  • Ceux qui balancent tout leur code dans un fichier app.ts de 8000 lignes en mode YOLO
  • Ceux qui ont déjà dû maintenir le code du premier type et qui ont juré "plus jamais ça", la tête dans un coussin, entre deux séances chez le psy

Le DDD, c'est pour la deuxième catégorie. C'est pour les gens beaux, propres, et qui font du yoga.

Les Couches du DDD (comme la délicieuse complexité d'un oignon)

Le Domain

C'est LA partie importante, le reste, c'est juste de la déco.

Imagine qu'un domaine, c'est comme les règles d'un jeu vidéo : le commun des mortels ne sait pas comment la PS5 fonctionne, mais tout le monde sait qu'un champignon fait grandir Mario.

Un exemple avec une entité User :

// domain/entities/user.entity.ts
export class User {
  private _numberOfRagequits: number = 0;
  private _lastLogin?: Date;
  private _banStatus: BanStatus = BanStatus.CLEAN;

  ragequit(): void {
    this._numberOfRagequits++;
    if (this._numberOfRagequits >= 3) {
      this._banStatus = BanStatus.BANNED;
      throw new DomainError("Triple ragequit ? Sérieux ? Va boire une tisane...");
    }
  }

  // Des Value Objects, parce que c'est la classe
  private _email: EmailAddress;
  private _username: Username;
}

// Value Objects (parce que string c'est pour les faibles)
export class EmailAddress {
  private constructor(private readonly value: string) {}

  static create(email: string): EmailAddress {
    if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
      throw new Error("C'est pas un email ça, même mon chat tétraplégique tape mieux");
    }
    return new EmailAddress(email);
  }
}

Les Aggregates

Imagine que tu as une entité Order (commande) qui contient des OrderLines (6 ans et tu parles déjà anglais courament, j'ai envie de te frapper). Si tu les traites séparément, c'est comme si tu laissais tes potes prendre les cartes de ton deck Pokemon un par un : tu vas forcément perdre le contrôle.

// domain/aggregates/order.aggregate.ts
import { AggregateRoot } from '@nestjs/cqrs';

export class Order extends AggregateRoot {
  private readonly id: string; // Unique, par nature
  private readonly customerId: string; // L'ID du client 
  private readonly orderLines: OrderLine[] = []; // Pour bourrer le caddie
  private status: OrderStatus
  private totalAmount: number; // La douloureuse

  constructor(id: string, customerId: string) {
    super();
    this.id = id;
    this.customerId = customerId;
    this.status = OrderStatus.DRAFT;
    this.totalAmount = 0; // Petit joueur
  }

  // Ajouter un article 
  public addOrderLine(product: Product, quantity: number): void {
    if (this.orderLines.length >= 6) {
      throw new Error('Ca rentrera jamais dans ta clio');
    }

    if (this.status !== OrderStatus.DRAFT) {
      throw new Error('Fallait y penser avant (j ai piqué la punchline à la gendarmerie)');
    }

    const orderLine = new OrderLine(
      this.generateOrderLineId(), 
      this.id,
      product.id,
      quantity,
      product.price
    );

    this.orderLines.push(orderLine);
    this.recalculateTotal(); // Consultation du plan epargne logement
  }

  // Virer un article
  public removeOrderLine(lineId: string): void {
    const index = this.orderLines.findIndex(line => line.id === lineId);
    if (index === -1) {
      throw new Error('Cet article n a pas d existence propre, probablement car il n existe pas');
    }

    this.orderLines.splice(index, 1); // petit ange parti trop tôt
    this.recalculateTotal(); 
  }

  // Valider la commande
  public submit(): void {
    if (this.orderLines.length === 0) {
      throw new Error('Merci pour cette commande sans article, vous voulez un sac pour emballer du vide ?');
    }

    if (this.status !== OrderStatus.DRAFT) {
      throw new Error('Cette commande est déjà partie');
    }

    this.status = OrderStatus.SUBMITTED; 
    this.apply(new OrderSubmittedEvent(this.id)); // Adieu mon 10 metres carrés sur Nation
  }

  private recalculateTotal(): void {
    this.totalAmount = this.orderLines.reduce(
      (total, line) => total + line.getSubtotal(),
      0
    ); 
  }

  private generateOrderLineId(): string {
    return `${this.id}-line-${this.orderLines.length + 1}`; 
  }
}

// Les OrderLines
class OrderLine {
  constructor(
    public readonly id: string, 
    public readonly orderId: string,
    public readonly productId: string,
    private quantity: number, 
    private readonly unitPrice: number 
  ) {}

  public getSubtotal(): number {
    return this.quantity * this.unitPrice; // La douloureuse
  }
}

// Les états de la commande
enum OrderStatus {
  DRAFT = 'DRAFT', // "C'est compliqué"
  SUBMITTED = 'SUBMITTED', // "En couple"
  PAID = 'PAID', // "Marié"
  SHIPPED = 'SHIPPED' // "En voyage de noces"
}

@Injectable()
export class OrderService {
  constructor(
    @InjectRepository(Order)
    private orderRepository: Repository<Order>
  ) {}

  // Commande vide, avec le secret espoir de vendre quelque chose
  async createOrder(customerId: string): Promise<Order> {
    const order = new Order(uuid(), customerId);
    return this.orderRepository.save(order);
  }

  async addProductToOrder(orderId: string, productId: string, quantity: number): Promise<void> {
    const order = await this.orderRepository.findOne(orderId);
    if (!order) {
      throw new Error('J ai perdu votre commande, mais c est pas ma faute, je suis un simple stagiaire');
    }

    const product = await this.productRepository.findOne(productId);
    if (!product) {
      throw new Error('Cet article n a pas d existence propre, probablement car il n existe pas');
    }

    order.addOrderLine(product, quantity);
    await this.orderRepository.save(order);
  }
}

Les Domain Services

Parfois, tu as une logique qui implique plusieurs entités. C'est comme quand tu dois organiser un combat Pokemon : tu as besoin des deux dresseurs ET de leurs Pokemon :

// domain/entities/trainer.entity.ts
export class Trainer {
  constructor(
    public id: string,
    public name: string,
    public pokemons: Pokemon[]
  ) {}

  selectPokemon(pokemonId: string): Pokemon {
    const pokemon = this.pokemons.find(p => p.id === pokemonId);
    if (!pokemon) {
      throw new Error('Pokemon introuvable, il a peut être démanagé chez Palworld ?');
    }
    return pokemon;
  }
}

// domain/entities/pokemon.entity.ts
export class Pokemon {
  constructor(
    public id: string,
    public name: string,
    public hp: number,
    public attack: number,
    public defense: number
  ) {}

  isKO(): boolean {
    return this.hp <= 0;
  }

  receiveDamage(damage: number): void {
    this.hp = Math.max(0, this.hp - damage);
  }
}

// domain/services/pokemon-battle.service.ts
@Injectable()
export class PokemonBattleService {
  constructor() {}

  initiateBattle(
    trainer1: Trainer,
    trainer2: Trainer,
    pokemon1Id: string,
    pokemon2Id: string
  ): BattleResult {
    const pokemon1 = trainer1.selectPokemon(pokemon1Id);
    const pokemon2 = trainer2.selectPokemon(pokemon2Id);

    return this.processBattle(
      {trainer: trainer1, pokemon: pokemon1},
      {trainer: trainer2, pokemon: pokemon2}
    );
  }

  private processBattle(
    participant1: {trainer: Trainer; pokemon: Pokemon},
    participant2: {trainer: Trainer; pokemon: Pokemon}
  ): BattleResult {
    const rounds: BattleRound[] = [];
    let currentRound = 1;

    // Combat jusqu'à ce qu'un Pokémon soit KO
    while (!participant1.pokemon.isKO() && !participant2.pokemon.isKO()) {
      // Calcul des dégâts pour chaque tour
      const damage1 = this.calculateDamage(participant1.pokemon, participant2.pokemon);
      const damage2 = this.calculateDamage(participant2.pokemon, participant1.pokemon);

      // Application des dégâts
      participant2.pokemon.receiveDamage(damage1);
      if (!participant2.pokemon.isKO()) {
        participant1.pokemon.receiveDamage(damage2);
      }

      rounds.push({
        roundNumber: currentRound,
        damage1,
        damage2,
        pokemon1HP: participant1.pokemon.hp,
        pokemon2HP: participant2.pokemon.hp
      });

      currentRound++;
    }

    // Détermination du vainqueur
    const winner = participant1.pokemon.isKO() ? participant2 : participant1;

    return {
      winner: winner.trainer,
      winningPokemon: winner.pokemon,
      rounds
    };
  }

  private calculateDamage(attacker: Pokemon, defender: Pokemon): number {
    // Formule simplifiée de calcul des dégâts
    const baseDamage = (attacker.attack / defender.defense) * 20;
    // Ajout d'un élément aléatoire (85-100% des dégâts de base)
    const multiplier = 0.85 + Math.random() * 0.15;
    return Math.floor(baseDamage * multiplier);
  }
}

// types/battle.types.ts
interface BattleRound {
  roundNumber: number;
  damage1: number;
  damage2: number;
  pokemon1HP: number;
  pokemon2HP: number;
}

interface BattleResult {
  winner: Trainer;
  winningPokemon: Pokemon;
  rounds: BattleRound[];
}

// Exemple d'utilisation dans un controller
@Controller('battles')
export class BattleController {
  constructor(private readonly battleService: PokemonBattleService) {}

  @Post()
  async createBattle(@Body() battleData: CreateBattleDto): Promise<BattleResult> {
    const { trainer1Id, trainer2Id, pokemon1Id, pokemon2Id } = battleData;
    
    // En pratique, tu récupères les dresseurs depuis la BDD
    const trainer1 = await this.trainerRepository.findById(trainer1Id);
    const trainer2 = await this.trainerRepository.findById(trainer2Id);

    return this.battleService.initiateBattle(
      trainer1,
      trainer2,
      pokemon1Id,
      pokemon2Id
    );
  }
}

L'Application Layer

C'est la couche qui gère les trucs chiants.

Elle s'occupe des use cases, de l'orchestration, bref, tout ce qui n'est pas assez noble pour être dans le domaine.

// application/services/user-registration.service.ts
@Injectable()
export class UserRegistrationService {
  constructor(
    private readonly userRepo: UserRepository,
    private readonly eventBus: EventBus,
    private readonly mailService: MailService,
  ) {}

  async registerUser(command: RegisterUserCommand): Promise<void> {
    // Vérifie si l'username est déjà pris
    if (await this.userRepo.findByUsername(command.username)) {
      throw new ApplicationError("Désolé, xXDarkSasuke2012Xx est déjà pris");
    }

    const user = User.create({
      username: command.username,
      email: command.email,
    });

    await this.userRepo.save(user);
    
    // Publie un événement parce que c'est swag
    this.eventBus.publish(new UserRegisteredEvent(user.id));
    
    // Envoie un mail de bienvenue
    await this.mailService.sendWelcomeMail(user.email);
  }
}

L'Infrastructure (aka "l'absence totale de fun")

C'est là que tu mets tout ce qui fait mal aux yeux : les appels à la base de données, les requêtes HTTP, les appels à des services externes... Bref, tout ce qui peut (et va très certainement) planter à 3h du matin un dimanche (alors que t'es censé être au lit, et moi en boite à la recherche de mes 25 ans)

// infrastructure/persistence/mongodb/user.repository.ts
@Injectable()
export class MongoUserRepository implements UserRepository {
  constructor(
    @InjectModel(UserDocument.name)
    private readonly userModel: Model<UserDocument>,
  ) {}

  async findById(id: UserId): Promise<User | null> {
    const doc = await this.userModel.findById(id.value);
    if (!doc) {
      return null; // Comme mes chances de gagner au loto
    }
    
    return this.toDomain(doc); // Magie noire ici
  }

  // Plein d'autres méthodes super intéressantes
}

Les Events

Le truc cool avec le DDD, c'est que tu peux balancer des events partout.

// domain/events/user-banned.event.ts
export class UserBannedEvent implements DomainEvent {
  constructor(
    public readonly userId: UserId,
    public readonly reason: BanReason,
    public readonly timestamp: Date = new Date(),
  ) {}
}

// application/event-handlers/user-banned.handler.ts
@EventHandler(UserBannedEvent)
export class UserBannedHandler {
  async handle(event: UserBannedEvent) {
    // Envoie un mail à l'utilisateur
    // Notifie les admins
    // Met à jour les stats
    // Poste un meme sur Discord
    // Fais pleurer le joueur
    // Appelle sa maman avec les logs d'insultes
    // Refais pleurer le joueur
  }
}

C'etait rapide

Voilà mon petit bonhomme, je sais que t'as probablement déjà écrit un meilleur framework que Nest.js pendant que je t'expliquais tout ça, mais au moins maintenant tu sais pourquoi les devs seniors ont ce regard vide pendant les daily.

Si t'as pas tout compris, t'inquiète, moi non plus. C'est pour ça que j'ai mis tout mon code sur GitHub avec la mention "ne pas utiliser en prod" (spoiler : il est en prod).

PS : Si t'as vraiment 6 ans et que t'as tout compris, contacte-moi, j'ai un poste de tech lead à pourvoir.

Contact

Parlons de votre prochain projet !

Email

gregory@babonaux.com

Téléphone

+33 6 50 83 23 05