// Patterns de Présentation — Référence complète

MVC · MVP
MVVM

Les trois patterns de la couche de présentation — principes, schémas, métaphores et exemples TypeScript complets selon les principes de Robert C. Martin.

📖

Uncle Bob — Clean Architecture

Les patterns MVC, MVP et MVVM ne sont que des détails de la couche de présentation. Dans une Clean Architecture, ils sont relégués au rang de “Plugin UI”. Le domaine métier — Use Cases et Entities — ne doit jamais dépendre du pattern qui orchestre son affichage. Ce qui importe, c’est que les dépendances pointent toujours vers l’intérieur.

01

La métaphore du restaurant

Avant de plonger dans les schémas, une métaphore qui unifie les trois patterns. Imagine un restaurant gastronomique. Le client (l’Utilisateur) ne va jamais en cuisine. Il interagit avec une interface (la salle). La cuisine prépare les données (le Model).

Ce qui change entre MVC, MVP et MVVM, c’est qui orchestre, qui parle à qui, et qui porte la responsabilité de la logique de présentation.

👨‍🍳
MVC
Le Maître d'Hôtel
Le Controller prend ta commande, la transmet en cuisine, et dit à la salle comment dresser la table. Mais la salle peut regarder dans la cuisine directement.
Orchestrateur centralCouplage possible
🎭
MVP
Le Puppet Master
La View est une marionnette — elle ne fait rien par elle-même. Le Presenter tire toutes les ficelles et ne connaît la View que via un contrat (interface).
View passiveInterface stricte
📡
MVVM
Le Tableau de Bord
Le ViewModel publie des données en temps réel. La View s'abonne et se met à jour automatiquement — sans qu'on lui demande. Zéro référence directe.
Data bindingRéactivité
02

MVC — Model View Controller

Métaphore Le Controller est le maître d’hôtel. Il reçoit ta commande (action utilisateur), la passe en cuisine (Model) et dit à la salle comment dresser la table (View). Problème : la salle peut regarder directement dans la cuisine — c’est le couplage View → Model, la faille principale du pattern.
Utilisateur
Action UI
1. action
Controller
Orchestre les actions
2. met à jour
Model
Données + logique métier
3. notifie
View
Affichage UI
4. affiche
Utilisateur
Voit le résultat

Le Controller est le pivot central. Il reçoit les actions (requêtes HTTP, clics), interroge ou modifie le Model, puis sélectionne la View à rendre. La View peut parfois observer le Model directement — ce qui crée un couplage indésirable.

1
Utilisateur
Déclenche une action — clic, soumission de formulaire, requête HTTP GET/POST
2
Controller
Reçoit l'action, interprète les paramètres, appelle la bonne méthode du Model
3
Model
Exécute la logique métier, interroge la base de données, retourne les données
4
Model → View
Notifie les Views abonnées (Observer) ou le Controller choisit la View à rendre
5
View
Reçoit les données, génère le rendu HTML/UI, retourne le résultat à l'utilisateur
model/ArticleModel.ts
// MODEL — logique métier + accès aux données // Ne connaît ni la View ni le Controller interface Article { id: number; titre: string; contenu: string; auteur: string; } class ArticleModel { private articles: Article[] = [ { id: 1, titre: "Clean Code", contenu: "Écrire du code lisible...", auteur: "Bob Martin" }, { id: 2, titre: "Clean Architecture", contenu: "Séparer les responsabilités...", auteur: "Bob Martin" }, ]; findById(id: number): Article | undefined { return this.articles.find(a => a.id === id); } findAll(): Article[] { return this.articles; } }
view/ArticleView.ts
// VIEW — rendu HTML uniquement, aucune logique const ArticleView = { render(article: Article): string { return ` <article> <h1>${article.titre}</h1> <p class="auteur">Par ${article.auteur}</p> <p>${article.contenu}</p> </article> `; }, renderNotFound(): string { return `<p class="erreur">Article introuvable</p>`; }, renderListe(articles: Article[]): string { const items = articles .map(a => `<li><a href="/articles/${a.id}">${a.titre}</a></li>`) .join(''); return `<ul>${items}</ul>`; } };
controller/ArticleController.ts
// CONTROLLER — orchestre Model et View // C'est lui qui reçoit les requêtes HTTP class ArticleController { constructor(private model: ArticleModel) {} afficher(req: Request, res: Response): void { const id = Number(req.params.id); const article = this.model.findById(id); if (!article) { res.status(404).send(ArticleView.renderNotFound()); return; } res.send(ArticleView.render(article)); } liste(req: Request, res: Response): void { const articles = this.model.findAll(); res.send(ArticleView.renderListe(articles)); } } // Wiring — Express.js const controller = new ArticleController(new ArticleModel()); app.get('/articles', (req, res) => controller.liste(req, res)); app.get('/articles/:id', (req, res) => controller.afficher(req, res));
✓ Points forts
Simple à comprendre — le pattern standard du web
Frameworks nombreux : Express, NestJS, Django, Rails
Séparation claire des responsabilités initiales
Idéal pour le rendu côté serveur (SSR)
✗ Points faibles
La View peut dépendre du Model directement (couplage)
Controller difficile à tester unitairement (lié à HTTP)
Fat Controller : la logique de présentation dérive vers lui
Moins adapté aux UIs riches et réactives
03

MVP — Model View Presenter

Métaphore La View dans MVP est une marionnette. Elle ne fait rien par elle-même. Le Presenter tire les ficelles : il décide de tout, dit exactement quoi afficher, et la View obéit. Le Presenter ne connaît la View que via une interface (un contrat) — il ne sait même pas si c’est une vraie UI ou un mock de test. C’est là toute la puissance du MVP.
Utilisateur
Action UI
1. action
View
Passive — délègue tout
2. via IView
Presenter
Toute la logique de présentation
3. lit / écrit
Model
Données + métier
Règle fondamentale MVP : La View implémente une interface (IView). Le Presenter ne référence jamais la View concrète — seulement l’interface. Cette séparation est le fondement de la testabilité totale.
view/ILoginView.ts — Le contrat strict
// INTERFACE — le contrat que toute View DOIT respecter // C'est la frontière entre Presenter et View interface ILoginView { afficherChargement(visible: boolean): void; afficherErreur(message: string): void; afficherSucces(utilisateur: Utilisateur): void; getEmail(): string; getMotDePasse(): string; }
model/AuthService.ts
// MODEL — service d'authentification pur interface Utilisateur { email: string; nom: string; } class AuthService { async login(email: string, mdp: string): Promise<Utilisateur> { const res = await fetch('/api/login', { method: 'POST', body: JSON.stringify({ email, mdp }), }); if (!res.ok) throw new Error('Identifiants invalides'); return res.json(); } }
presenter/LoginPresenter.ts — Toute la logique ici
// PRESENTER — logique de présentation testable à 100% // Ne connaît la View QUE via ILoginView class LoginPresenter { constructor( private view: ILoginView, // ← interface, jamais la classe concrète private authService: AuthService ) {} async onLoginClique(): Promise<void> { const email = this.view.getEmail().trim(); const mdp = this.view.getMotDePasse(); if (!email || !mdp) { this.view.afficherErreur('Email et mot de passe requis'); return; } this.view.afficherChargement(true); try { const utilisateur = await this.authService.login(email, mdp); this.view.afficherSucces(utilisateur); } catch (e) { this.view.afficherErreur((e as Error).message); } finally { this.view.afficherChargement(false); } } }
view/LoginView.ts — La marionnette
// VIEW — implémente ILoginView, manipule le DOM // AUCUNE logique ici — que du relais vers le Presenter class LoginView implements ILoginView { private presenter: LoginPresenter; constructor() { this.presenter = new LoginPresenter(this, new AuthService()); document.getElementById('btn-login')! .addEventListener('click', () => this.presenter.onLoginClique()); } getEmail(): string { return (document.getElementById('email') as HTMLInputElement).value; } getMotDePasse(): string { return (document.getElementById('mdp') as HTMLInputElement).value; } afficherChargement(visible: boolean): void { document.getElementById('spinner')!.style.display = visible ? 'block' : 'none'; } afficherErreur(message: string): void { const el = document.getElementById('erreur')!; el.textContent = message; el.style.display = 'block'; } afficherSucces(u: Utilisateur): void { window.location.href = `/bienvenue?nom=${u.nom}`; } }
La puissance du test MVP — Voici pourquoi l’interface ILoginView est un superpouvoir : on peut tester toute la logique de présentation sans démarrer le DOM.
tests/LoginPresenter.test.ts — Zero DOM, zero infra
// MockLoginView implémente ILoginView — c'est un espion class MockLoginView implements ILoginView { erreurAffichee = ''; chargementVisible = false; utilisateurConnecte: Utilisateur | null = null; afficherChargement(v: boolean) { this.chargementVisible = v; } afficherErreur(msg: string) { this.erreurAffichee = msg; } afficherSucces(u: Utilisateur) { this.utilisateurConnecte = u; } getEmail() { return 'test@test.com'; } getMotDePasse() { return '1234'; } } // Test unitaire — pas besoin de navigateur, de DOM, ni de serveur test('affiche une erreur si email vide', async () => { const mock = new MockLoginView(); jest.spyOn(mock, 'getEmail').mockReturnValue(''); const presenter = new LoginPresenter(mock, new AuthService()); await presenter.onLoginClique(); expect(mock.erreurAffichee).toBe('Email et mot de passe requis'); expect(mock.chargementVisible).toBe(false); });
✓ Points forts
Presenter testable à 100% sans DOM ni framework
View totalement passive — zéro logique à tester côté UI
L'interface IView est un contrat strict et explicite
Idéal pour Android natif, iOS UIKit
✗ Points faibles
Beaucoup de boilerplate — une interface par View
Relation 1-to-1 Presenter/View — peu flexible
Presenter peut grossir (God Presenter)
Mise à jour UI manuelle — pas de réactivité automatique
04

MVVM — Model View ViewModel

Métaphore Imagine un tableau de bord dans une salle de marché. Le ViewModel est le serveur de données qui publie des informations en temps réel. Le tableau (View) s’abonne automatiquement : dès qu’une donnée change, l’affichage se met à jour sans qu’on lui demande. La View ne tire pas les données — elle observe et réagit. C’est le Data Binding.
Utilisateur
Interagit
binding bidir.
View
Déclarative / S’abonne
observe / commandes
ViewModel
État observable / Zéro ref. View
lit / écrit
Model
Données + métier
Règle fondamentale MVVM : Le ViewModel expose un état observable. Il ne connaît jamais la View et ne la référence jamais. La View s’abonne à l’état — quand l’état change, l’UI se met à jour automatiquement. C’est le contrat du Data Binding.

Le ViewModel est l’abstraction de l’état de la View. Il n’a aucune dépendance vers l’UI — ni DOM, ni composant, ni framework graphique. Il expose des observables (LiveData, Signal, BehaviorSubject, useState…) que la View consomme déclarativement.

core/Observable.ts — Le moteur du MVVM
// Observable générique — comprendre le mécanisme sans framework class Observable<T> { private valeur: T; private abonnes: Array<(val: T) => void> = []; constructor(valeurInitiale: T) { this.valeur = valeurInitiale; } get(): T { return this.valeur; } set(nouvelleValeur: T): void { this.valeur = nouvelleValeur; this.abonnes.forEach(fn => fn(nouvelleValeur)); // notifie tous les abonnés } abonner(fn: (val: T) => void): void { this.abonnes.push(fn); fn(this.valeur); // émission immédiate de la valeur actuelle } }
viewmodel/CompteurViewModel.ts
// VIEWMODEL — expose des Observables // Ne connaît PAS le DOM — aucun import UI class CompteurViewModel { readonly compteur = new Observable<number>(0); readonly message = new Observable<string>('Prêt'); incrementer(): void { const n = this.compteur.get() + 1; this.compteur.set(n); this.message.set(n >= 10 ? 'Objectif atteint !' : `Plus que ${10 - n} clics`); } reinitialiser(): void { this.compteur.set(0); this.message.set('Réinitialisé'); } }
view/CompteurView.ts — S’abonne, ne tire jamais
// VIEW — s'abonne aux Observables, aucune logique métier class CompteurView { private vm = new CompteurViewModel(); constructor() { const affichageCompteur = document.getElementById('compteur')!; const affichageMessage = document.getElementById('message')!; // BINDING — l'UI se met à jour automatiquement à chaque changement this.vm.compteur.abonner(val => affichageCompteur.textContent = String(val)); this.vm.message.abonner(msg => affichageMessage.textContent = msg); // Events → délégués au ViewModel (commandes) document.getElementById('btn-plus')! .addEventListener('click', () => this.vm.incrementer()); document.getElementById('btn-reset')! .addEventListener('click', () => this.vm.reinitialiser()); } } new CompteurView();
Version React / TypeScript — Dans React, le hook useState + useEffect joue le rôle d’Observable. Le custom hook est le ViewModel.
viewmodel/useArticlesViewModel.ts — Hook = ViewModel
// MODEL — service de données pur interface Article { id: number; titre: string; auteur: string; } class ArticleService { async getAll(): Promise<Article[]> { const res = await fetch('/api/articles'); if (!res.ok) throw new Error('Erreur réseau'); return res.json(); } async supprimer(id: number): Promise<void> { await fetch(`/api/articles/${id}`, { method: 'DELETE' }); } } // VIEWMODEL — hook React = état observable // La View s'abonne à ce hook, jamais au service directement function useArticlesViewModel() { const [articles, setArticles] = useState<Article[]>([]); const [chargement, setChargement] = useState(false); const [erreur, setErreur] = useState<string | null>(null); const service = useMemo(() => new ArticleService(), []); const charger = useCallback(async () => { setChargement(true); try { setArticles(await service.getAll()); } catch (e) { setErreur((e as Error).message); } finally { setChargement(false); } }, [service]); const supprimer = useCallback(async (id: number) => { await service.supprimer(id); setArticles(prev => prev.filter(a => a.id !== id)); }, [service]); useEffect(() => { charger(); }, [charger]); return { articles, chargement, erreur, supprimer }; // état en lecture seule }
view/ArticlesView.tsx — 100% déclarative
// VIEW — zéro logique métier, observe et réagit // Elle NE SAIT PAS comment les données arrivent const ArticlesView: React.FC = () => { const { articles, chargement, erreur, supprimer } = useArticlesViewModel(); if (chargement) return <Spinner />; if (erreur) return <Erreur texte={erreur} />; return ( <ul> {articles.map(article => ( <li key={article.id}> <strong>{article.titre}</strong> — {article.auteur} <button onClick={() => supprimer(article.id)}>Supprimer</button> </li> ))} </ul> ); };
✓ Points forts
View totalement passive — aucun appel explicite
Testabilité maximale du ViewModel sans UI
Parfait pour les UIs réactives et complexes
React, Angular, SwiftUI, Jetpack Compose
✗ Points faibles
Courbe d'apprentissage du data binding
Overhead pour les UIs simples (overkill)
Gestion de la mémoire (désabonnement)
Debugging parfois difficile avec le binding
05

La différence fondamentale en 3 lignes

// MVC — le Controller POUSSE les données à la View view.render(model.getData()); // MVP — le Presenter DIT à la View quoi afficher (via interface) this.view.afficherArticles(articles); // ← appel explicite // MVVM — la View S'ABONNE et réagit toute seule viewModel.articles.abonner(articles => this.render(articles)); // ^^^^^^^^^^^^^^^^^^ Binding automatique — zéro appel explicite
06

Comparatif synthétique

CritèreMVCMVPMVVM
Couplage View / LogiqueMoyenFaibleTrès faible
TestabilitéMoyenneÉlevéeTrès élevée
ComplexitéFaibleMoyenneÉlevée
Relation Présentateur / View1-N1-1 stricteAucune
Réactivité UIManuelleManuelleAutomatique
View passive ?NonOuiTotalement
Contexte idéalWeb SSR
Express, Rails, Django
Mobile legacy
Android, iOS UIKit
UI réactive
React, Angular, Compose
BoilerplateFaibleÉlevé (interfaces)Moyen
07

Quel pattern choisir ?

Selon Robert C. Martin, l’architecture doit servir le cas d’usage, pas l’ego du développeur. Ces patterns sont des détails — choisir le bon dépend du contexte, du framework imposé, et des contraintes de testabilité.

🌐
Choisir MVC quand…
Application web SSR
Application web serveur-side, équipe junior, besoin de rapidité. Le framework impose souvent le pattern. Rendu HTML côté serveur sans état UI complexe.
ExpressNestJSRailsDjangoLaravel
« Utilise MVC quand la Vue n’a pas besoin de mémoire. »
📱
Choisir MVP quand…
Mobile & testabilité critique
Application mobile, legacy code à tester, contexte où le binding automatique n'existe pas. La testabilité du Presenter sans framework est l'avantage décisif.
Android legacyiOS UIKitTests unitaires
« Utilise MVP quand tu dois tester la logique sans l’UI. »
Choisir MVVM quand…
UI réactive et complexe
UI avec état partagé entre composants, framework moderne, besoins de réactivité. Le Data Binding est un avantage structurel, pas un luxe.
ReactAngularJetpack ComposeSwiftUI
« Utilise MVVM quand l’UI doit réagir à l’état, pas l’inverse. »
Rappel Uncle Bob — Clean Architecture : Ces trois patterns sont des détails d’implémentation de la couche de présentation. Dans une Clean Architecture, ils sont relégués au rang de “Plugin UI”. Le domaine métier — Use Cases et Entities — ne doit jamais en dépendre. Le vrai découplage, c’est que le Model ignore totalement quel pattern orchestre son affichage.
Utilisateur / Action UI
View (affichage)
Controller / Presenter / ViewModel
Model (données + métier)
Interface / Contrat IView
Règle / Principe