Assembler toutes les couches en un projet complet
Bootcode IWA-S04 — Semaine 16, Jour 5
1. Structurer le projet
domain/, infra/, api/
2. Repository pattern
Interface + Postgres + InMemory
3. Mapper pattern
toDomain / fromDomain
4. Use cases + tests
Un use case par action, testé avec InMemory
5. Assembler dans main.ts
Le seul fichier qui connaît toutes les couches
Récapitulatif de la semaine
Couches, Repository, Mapper, Use Case — tout assemblé
La structure finale
domain/ (models, repositories, usecases), infra/, api/
Le flux complet en live
Requête HTTP → controller → use case → repository → BDD → retour
Les tests
Un test par use case avec InMemoryRepository
Correspondance avec Bootcode & Bravo
packages/iac/core/ et adapters/ — vous êtes prêts
Module 1
Les 4 patterns de la semaine, assemblés
🏗️
J1 — Architecture en couches
API, Domain, Infrastructure
📦
J2 — Repository
Interface dans domain, implémentation dans infra
🔄
J3 — Mapper
toDomain / fromDomain entre BDD et domaine
⚡
J4 — Use Case
Une classe, une action, execute()
🎯 Aujourd'hui : tout assembler dans un projet
Requête HTTP arrive
→ Express routeController reçoit la requête
→ appelle useCase.execute()Use case exécute la logique
→ validation, règles métierRepository persiste
→ Mapper convertit, TypeORM écritRéponse HTTP renvoyée
→ objet métier → JSONModule 2
domain/ · infra/ · api/
src/
domain/
models/
Article.ts
repositories/
ArticleRepository.ts (interface)
usecases/
CreateArticle.ts
GetArticle.ts
DeleteArticle.ts
infra/
entities/
ArticleEntity.ts (TypeORM)
mappers/
ArticleMapper.ts
repositories/
PostgresArticleRepository.ts
InMemoryArticleRepository.ts
api/
controllers/
ArticleController.ts
routes.ts
main.ts (assemblage)
Le domaine est INDÉPENDANT
Pas d'import de TypeORM, Express, ou quoi que ce soit de technique.
API
Connaît Domain
+ Express
Domain
Connaît RIEN
Logique pure
Infra
Connaît Domain
+ TypeORM
✅ Domain au centre, Infra et API s'adaptent à lui
C'est le Dependency Inversion — l'infra dépend du domaine, jamais l'inverse.
Module 3
Domain · Infra · API
// domain/models/Article.ts
export interface Article {
id: string;
title: string;
content: string;
authorId: string;
createdAt: Date;
}
// domain/repositories/ArticleRepository.ts
export interface ArticleRepository {
save(article: Article): Promise<void>;
findById(id: string): Promise<Article | null>;
delete(id: string): Promise<void>;
}
💡 Aucun import technique — que de la logique pure
// domain/usecases/CreateArticle.ts
import { Article, ArticleRepository } from "../";
export class CreateArticle {
constructor(private repo: ArticleRepository) {}
async execute(cmd: { title: string; content: string; authorId: string }): Promise<Article> {
const article: Article = {
id: crypto.randomUUID(),
title: cmd.title,
content: cmd.content,
authorId: cmd.authorId,
createdAt: new Date(),
};
await this.repo.save(article);
return article;
}
}
💡 Le use case dépend de l'INTERFACE, pas de l'implémentation
// infra/entities/ArticleEntity.ts
@Entity({ name: "articles" })
export class ArticleEntity {
@PrimaryColumn() id: string;
@Column() title: string;
@Column() content: string;
@Column({ name: "author_id" }) authorId: string;
@Column({ name: "created_at" }) createdAt: Date;
}
// infra/mappers/ArticleMapper.ts
export class ArticleMapper {
static toDomain(e: ArticleEntity): Article { /* ... */ }
static fromDomain(a: Article): ArticleEntity { /* ... */ }
}
// infra/repositories/PostgresArticleRepository.ts
import { ArticleRepository, Article } from "../../domain";
import { ArticleMapper } from "../mappers/ArticleMapper";
export class PostgresArticleRepository implements ArticleRepository {
constructor(private db: DataSource) {}
async save(article: Article): Promise<void> {
const entity = ArticleMapper.fromDomain(article);
await this.db.getRepository(ArticleEntity).save(entity);
}
async findById(id: string): Promise<Article | null> {
const entity = await this.db.getRepository(ArticleEntity).findOneBy({ id });
return entity ? ArticleMapper.toDomain(entity) : null;
}
}
💡 Implémente l'interface du domaine + utilise le Mapper
// api/controllers/ArticleController.ts
import { CreateArticle, GetArticle } from "../../domain/usecases";
export class ArticleController {
constructor(
private createArticle: CreateArticle,
private getArticle: GetArticle,
) {}
async create(req, res) {
const article = await this.createArticle.execute(req.body);
res.status(201).json(article);
}
async get(req, res) {
const article = await this.getArticle.execute(req.params.id);
res.json(article);
}
}
💡 Le controller ne fait que déléguer à execute()
Module 4
main.ts connecte tout · InMemory pour les tests
// main.ts — assemblage des couches
import { DataSource } from "typeorm";
import { PostgresArticleRepository } from "./infra/repositories/PostgresArticleRepository";
import { CreateArticle, GetArticle } from "./domain/usecases";
import { ArticleController } from "./api/controllers/ArticleController";
const db = new DataSource({ /* config */ });
// Infra : instancie le repository concret
const articleRepo = new PostgresArticleRepository(db);
// Domain : injecte le repo dans les use cases
const createArticle = new CreateArticle(articleRepo);
const getArticle = new GetArticle(articleRepo);
// API : injecte les use cases dans le controller
const controller = new ArticleController(createArticle, getArticle);
// Branchement des routes Express
app.post("/articles", (req, res) => controller.create(req, res));
app.get("/articles/:id", (req, res) => controller.get(req, res));
💡 main.ts = le SEUL endroit qui connaît toutes les couches
Le domaine, lui, ne sait même pas que Postgres existe.
// infra/repositories/InMemoryArticleRepository.ts
export class InMemoryArticleRepository implements ArticleRepository {
private articles: Article[] = [];
async save(article: Article): Promise<void> {
this.articles.push(article);
}
async findById(id: string): Promise<Article | null> {
return this.articles.find(a => a.id === id) ?? null;
}
async delete(id: string): Promise<void> {
this.articles = this.articles.filter(a => a.id !== id);
}
}
💡 Même interface que Postgres — zéro BDD nécessaire
Les tests sont rapides, isolés, et prouvent que l'architecture fonctionne.
// CreateArticle.test.ts
test("crée et persiste un article", async () => {
const repo = new InMemoryArticleRepository();
const useCase = new CreateArticle(repo);
const article = await useCase.execute({
title: "Hello",
content: "Mon contenu",
authorId: "user-1",
});
expect(article.title).toBe("Hello");
expect(repo.articles).toHaveLength(1);
});
💡 Un test par use case — la preuve que l'architecture fonctionne
Module 5
Du projet au monorepo Bootcode
Notre projet
src/domain/
src/infra/
src/api/
src/main.ts
Monorepo Bootcode
packages/iac/core/
packages/iac/adapters/
packages/iac/api/
packages/iac/main.ts
💡 Même architecture, à plus grande échelle
Vous pouvez maintenant lire le monorepo Bootcode comme un livre ouvert.
⚠️ C'est cumulatif
Les étudiants qui n'ont pas suivi les semaines précédentes auront du mal. Revoir J1-J4 si besoin.
⚠️ Le projet final peut sembler gros
Décomposer en étapes claires : 1) structure, 2) domain, 3) infra, 4) api, 5) main.ts, 6) tests.
⚠️ Ne pas oublier les tests
C'est la preuve que l'architecture fonctionne. Un test par use case avec InMemoryRepository.
⚠️ Importer TypeORM dans le domaine
Jamais ! Le domaine reste pur. L'infra s'adapte au domaine, pas l'inverse.
📌 Tout assembler — voir les patterns travailler ensemble
📌 Le domaine est INDÉPENDANT
Pas d'import de TypeORM, Express, ou quoi que ce soit
📌 main.ts connecte les couches
Le seul fichier qui connaît tout
📌 Les tests prouvent l'architecture
Un test par use case avec InMemoryRepository
🏗️
3 couches
API · Domain · Infra
🔄
Repository + Mapper
Interface + conversion
⚡
Use cases + execute()
Une classe par action
🧪
Tests = preuve
InMemoryRepository pour chaque use case
Vous êtes prêts à lire et contribuer au code professionnel
Le monorepo Bootcode n'a plus de secrets pour vous.
Prochaine semaine : de nouveaux défis !
Assemblez votre API blog complète
Semaine 16 terminée — architecture en couches maîtrisée