Traducteur entre la BDD et le domaine
Bootcode IWA-S04 — Semaine 16, Jour 3
1. Créer une entité TypeORM
Séparée de l'objet métier
2. Écrire un Mapper
Avec toDomain() et fromDomain()
3. Utiliser le Mapper
Dans le Repository PostgreSQL
4. Vérifier le round-trip
objet → entité → objet = identique
Deux représentations
Objet métier vs entité BDD
Pourquoi séparer ?
Nommage, colonnes techniques, décorateurs
Le Mapper : toDomain() et fromDomain()
Traducteur bidirectionnel
Le Mapper vit dans l'infra
À côté des entités TypeORM
Seul le Repository PostgreSQL l'utilise
Le use case ne voit jamais l'entité
Module 1
Objet métier ≠entité BDD
🧠Objet métier (Domain)
createdAt💾 Entité TypeORM (Infra)
created_at@Column💡 Elles se ressemblent, mais servent des buts différents
Le domaine pense "métier", la BDD pense "stockage"
// domain/models/Article.ts
export interface Article {
id: string;
title: string;
content: string;
authorId: string;
createdAt: Date;
}
💡 Propre, simple, aucun décorateur
camelCase, types clairs — c'est ce que le use case manipule
// infra/entities/ArticleEntity.ts
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity({ name: "articles" })
export class ArticleEntity {
@PrimaryColumn()
id: string;
@Column()
title: string;
@Column({ type: "text" })
content: string;
@Column({ name: "author_id" })
authorId: string;
@Column({ name: "created_at", type: "timestamp" })
createdAt: Date;
}
💡 Décorateurs, snake_case dans la BDD, types SQL
C'est ce que TypeORM utilise pour parler Ă PostgreSQL
Module 2
Trois raisons concrètes
đź§ Domaine (camelCase)
createdAt
authorId
isPublished
đź’ľ BDD (snake_case)
created_at
author_id
is_published
đź’ˇ Convention JavaScript vs convention SQL
Le Mapper fait la conversion automatiquement
L'entité TypeORM a des colonnes que le domaine ne veut PAS voir
// Entité TypeORM — colonnes techniques
@Column({ name: "updated_at" })
updatedAt: Date; // géré par la BDD
@Column({ name: "version" })
version: number; // optimistic locking
@Column({ name: "deleted_at", nullable: true })
deletedAt: Date | null; // soft delete
✅ Le Mapper cache ces détails au domaine
L'objet métier ne contient que ce qui compte pour la logique
❌ Entité TypeORM dans le domaine
@Entity // ❌
@Column // ❌
import { Entity } from "typeorm" // ❌
Le domaine dépend de TypeORM !
✅ Objet métier pur
export interface Article {
id: string;
title: string;
}
Aucune dépendance technique !
Module 3
toDomain() et fromDomain()
Deux méthodes, deux sens
toDomain()
BDD → Domaine (lecture)
Entité TypeORM → Objet métier
fromDomain()
Domaine → BDD (écriture)
Objet métier → Entité TypeORM
đź’ˇ "to" = vers quoi je convertis
toDomain = je convertis VERS le domaine
// infra/mappers/ArticleMapper.ts
import { ArticleEntity } from "../entities/ArticleEntity";
import { Article } from "../../domain/models/Article";
export class ArticleMapper {
// BDD → Domaine (lecture)
static toDomain(entity: ArticleEntity): Article {
return {
id: entity.id,
title: entity.title,
content: entity.content,
authorId: entity.authorId,
createdAt: entity.createdAt,
};
}
// Domaine → BDD (écriture)
static fromDomain(article: Article): ArticleEntity {
const entity = new ArticleEntity();
entity.id = article.id;
entity.title = article.title;
entity.content = article.content;
entity.authorId = article.authorId;
entity.createdAt = article.createdAt;
return entity;
}
}
// infra/repositories/PostgresArticleRepository.ts
import { ArticleMapper } from "../mappers/ArticleMapper";
async save(article: Article): Promise<void> {
// Domaine → BDD
const entity = ArticleMapper.fromDomain(article);
await this.db.getRepository(ArticleEntity).save(entity);
}
async findById(id: string): Promise<Article | null> {
const entity = await this.db.findOneBy({ id });
// BDD → Domaine
return entity ? ArticleMapper.toDomain(entity) : null;
}
đź’ˇ Le Repository est le SEUL Ă utiliser le Mapper
Le use case reçoit/tire des objets métier — jamais d'entités
Module 4
Vérifier que la conversion est sans perte
// Test : objet → entité → objet = identique
test("round-trip Article", () => {
const original: Article = {
id: "123",
title: "Mon article",
content: "Contenu",
authorId: "auth-1",
createdAt: new Date(),
};
// Domaine → BDD → Domaine
const entity = ArticleMapper.fromDomain(original);
const result = ArticleMapper.toDomain(entity);
expect(result).toEqual(original); // âś…
});
💡 Si le round-trip échoue, vous perdez des données !
Vérifiez que chaque champ est converti dans les deux sens
❌ Utiliser l'entité TypeORM directement dans le use case
✅ Le use case ne voit QUE l'objet métier — le Mapper convertit
❌ Oublier de convertir snake_case ↔ camelCase
✅ Le Mapper gère la conversion — created_at ↔ createdAt
❌ Confondre toDomain et fromDomain
âś… "to" = vers quoi je convertis. toDomain = VERS le domaine
❌ L'erreur
// domain/usecases/GetArticle.ts
import { ArticleEntity } from
"typeorm" // ❌
async execute(id) {
const entity = await
repo.findById(id);
return entity.title; // ❌
}
Le domaine dépend de TypeORM !
âś… La solution
// domain/usecases/GetArticle.ts
import { Article } from
"../models/Article" // âś…
async execute(id) {
const article: Article =
await repo.findById(id);
return article.title; // âś…
}
Le domaine reste pur !
📌 L'entité TypeORM ≠l'objet métier
📌 toDomain = BDD → domaine, fromDomain = domaine → BDD
📌 Le Mapper vit dans infra/, à côté des entités
📌 Le use case ne voit JAMAIS l'entité TypeORM
🔄
Mapper = traducteur
Entre BDD et domaine
📥
toDomain = lecture
BDD → objet métier
📤
fromDomain = écriture
Objet métier → BDD
đźš«
Use case = jamais d'entité
Que des objets métier
Créez votre Mapper et testez le round-trip
Demain : Le Use Case — une classe par action