MFormations
Modern Design Patterns

Chapitre 12

12 — Architectures MVC, MVVM, MVP, Flux & Clean Architecture

12 — Architectures MVC, MVVM, MVP, Flux & Clean Architecture

Chapitre 12 : Architectures MVC, MVVM, MVP, Flux & Clean Architecture

Durée estimée : 5 séances de 3h Objectifs : Comprendre l'évolution des architectures UI, savoir choisir entre MVC/MVVM/MVP/Flux, maîtriser Clean Architecture et Hexagonal Architecture.


Partie 1 : Histoire et Évolution

1.1 Les origines (1979)

Le pattern MVC a été introduit par Trygve Reenskaug chez Xerox PARC pour le langage Smalltalk-80. L'idée révolutionnaire était de séparer :

  • Model : Les données et la logique métier
  • View : La représentation visuelle
  • Controller : La gestion des entrées utilisateur
Diagramme en cours de génération...

1.2 Le problème fondamental

Séparation des préoccupations (SoC) : Comment organiser le code d'une interface utilisateur pour que :

  • La logique métier soit indépendante de l'UI
  • Les changements UI n'affectent pas la logique métier
  • Le code soit testable
  • L'équipe puisse travailler en parallèle

Partie 2 : MVC — Model-View-Controller

2.1 Structure

Diagramme en cours de génération...

2.2 Implémentation classique

// Model
class TodoModel {
    private todos: Array<{ id: number; text: string; completed: boolean }> = [];
    private listeners: Array<() => void> = [];

    add(text: string): void {
        this.todos.push({ id: Date.now(), text, completed: false });
        this.notify();
    }

    toggle(id: number): void {
        const todo = this.todos.find(t => t.id === id);
        if (todo) todo.completed = !todo.completed;
        this.notify();
    }

    getAll() { return [...this.todos]; }

    subscribe(listener: () => void): () => void {
        this.listeners.push(listener);
        return () => { this.listeners = this.listeners.filter(l => l !== listener); };
    }

    private notify(): void {
        this.listeners.forEach(l => l());
    }
}

// View
class TodoView {
    private app: HTMLElement;
    private input: HTMLInputElement;
    private list: HTMLElement;
    private addButton: HTMLElement;

    constructor() {
        this.app = document.getElementById('app')!;
        this.app.innerHTML = `
            <input id="todo-input" type="text" />
            <button id="add-btn">Add</button>
            <ul id="todo-list"></ul>
        `;
        this.input = document.getElementById('todo-input') as HTMLInputElement;
        this.list = document.getElementById('todo-list')!;
        this.addButton = document.getElementById('add-btn')!;
    }

    render(todos: Array<{ id: number; text: string; completed: boolean }>): void {
        this.list.innerHTML = todos.map(todo => `
            <li class="${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
                <span>${todo.text}</span>
                <button class="toggle-btn">Toggle</button>
            </li>
        `).join('');
    }

    getInputValue(): string { return this.input.value; }
    clearInput(): void { this.input.value = ''; }

    bindAddTodo(handler: () => void): void {
        this.addButton.addEventListener('click', handler);
        this.input.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') handler();
        });
    }

    bindToggleTodo(handler: (id: number) => void): void {
        this.list.addEventListener('click', (e) => {
            const target = e.target as HTMLElement;
            if (target.classList.contains('toggle-btn')) {
                const id = Number(target.closest('li')!.dataset.id);
                handler(id);
            }
        });
    }
}

// Controller
class TodoController {
    constructor(
        private model: TodoModel,
        private view: TodoView
    ) {
        this.view.bindAddTodo(this.handleAddTodo.bind(this));
        this.view.bindToggleTodo(this.handleToggleTodo.bind(this));
        this.model.subscribe(() => this.view.render(this.model.getAll()));
        this.view.render(this.model.getAll());
    }

    private handleAddTodo(): void {
        const text = this.view.getInputValue().trim();
        if (text) {
            this.model.add(text);
            this.view.clearInput();
        }
    }

    private handleToggleTodo(id: number): void {
        this.model.toggle(id);
    }
}

// Bootstrap
const model = new TodoModel();
const view = new TodoView();
const controller = new TodoController(model, view);

2.3 Variantes du MVC

VarianteFluxUtilisation
Classic MVCModel notify ViewSmalltalk, iOS
MVC WebRequest→Controller→Model→ViewSpring MVC, Rails, Laravel
MVC JavaScriptModel→View, Controller gère eventsBackbone.js

Partie 3 : MVP — Model-View-Presenter

3.1 Structure

Diagramme en cours de génération...

3.2 Implémentation

interface UserView {
    showUsers(users: User[]): void;
    showError(message: string): void;
    showLoading(): void;
    hideLoading(): void;
    getUserSearchTerm(): string;
}

class UserPresenter {
    constructor(
        private model: UserModel,
        private view: UserView
    ) {}

    async onSearch(): Promise<void> {
        const term = this.view.getUserSearchTerm();
        if (!term) {
            this.view.showError('Please enter a search term');
            return;
        }

        this.view.showLoading();
        try {
            const users = await this.model.search(term);
            this.view.showUsers(users);
        } catch (err) {
            this.view.showError('Failed to search users');
        } finally {
            this.view.hideLoading();
        }
    }
}

Partie 4 : MVVM — Model-View-ViewModel

4.1 Structure

Le MVVM a été introduit par John Gossman (Microsoft) pour WPF/Silverlight. Il se base sur le data binding bidirectionnel.

Diagramme en cours de génération...

4.2 Implémentation avec Vue.js

// Model (TypeScript)
interface User {
    id: number;
    name: string;
    email: string;
}

// ViewModel (Vue.js Composition API)
import { ref, computed, onMounted } from 'vue';

export function useUserViewModel() {
    const users = ref<User[]>([]);
    const searchTerm = ref('');
    const loading = ref(false);
    const error = ref<string | null>(null);

    const filteredUsers = computed(() =>
        users.value.filter(u =>
            u.name.toLowerCase().includes(searchTerm.value.toLowerCase())
        )
    );

    async function loadUsers() {
        loading.value = true;
        error.value = null;
        try {
            const response = await fetch('/api/users');
            users.value = await response.json();
        } catch (e) {
            error.value = 'Failed to load users';
        } finally {
            loading.value = false;
        }
    }

    onMounted(loadUsers);

    return { users: filteredUsers, searchTerm, loading, error, loadUsers };
}

// View (Vue SFC)
<template>
  <div>
    <input v-model="searchTerm" placeholder="Search..." />
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">{{ error }}</div>
    <ul v-else>
      <li v-for="user in users" :key="user.id">
        {{ user.name }} - {{ user.email }}
      </li>
    </ul>
  </div>
</template>

Partie 5 : Flux / Redux — Unidirectional Data Flow

5.1 Problème résolu

Facebook a créé Flux suite aux problèmes de MVC pour les applications complexes (messenger, notifications). Le problème principal : les vues pouvaient modifier le modèle qui notifiait d'autres vues → cascade incontrôlable.

5.2 Flux

Diagramme en cours de génération...

5.3 Redux

Redux simplifie Flux avec un seul store et des reducers purs :

import { createStore } from 'redux';

// State
interface AppState {
    todos: Todo[];
    filter: FilterType;
}

// Action
type Action =
    | { type: 'ADD_TODO'; payload: string }
    | { type: 'TOGGLE_TODO'; payload: number }
    | { type: 'SET_FILTER'; payload: FilterType };

// Reducer (pure function)
function todoReducer(state: AppState, action: Action): AppState {
    switch (action.type) {
        case 'ADD_TODO':
            return {
                ...state,
                todos: [...state.todos, {
                    id: Date.now(),
                    text: action.payload,
                    completed: false
                }]
            };
        case 'TOGGLE_TODO':
            return {
                ...state,
                todos: state.todos.map(todo =>
                    todo.id === action.payload
                        ? { ...todo, completed: !todo.completed }
                        : todo
                )
            };
        default:
            return state;
    }
}

// Store
const store = createStore(todoReducer, { todos: [], filter: 'ALL' });

// Dispatch
store.dispatch({ type: 'ADD_TODO', payload: 'Learn Redux' });

// Subscribe (Observer pattern)
store.subscribe(() => console.log(store.getState()));

5.4 Avantages du flux unidirectionnel

  1. Prévisible : Une seule direction de flux de données
  2. Débogable : Time-travel debugging, log d'actions
  3. Testable : Les reducers sont des fonctions pures
  4. Maintenable : Séparation claire action→reducer→state

Partie 6 : Hexagonal Architecture (Ports & Adapters)

6.1 Principe

L'Hexagonal Architecture (Alistair Cockburn) place la logique métier au centre, avec des ports (interfaces) et des adaptateurs (implémentations).

Diagramme en cours de génération...

6.2 Code

// Domain Entity
class Order {
    constructor(
        public id: string,
        public items: OrderItem[],
        public status: OrderStatus
    ) {}

    getTotal(): number {
        return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    }
}

// Port — Repository Interface
interface OrderRepository {
    findById(id: string): Promise<Order | null>;
    save(order: Order): Promise<void>;
}

// Port — Presenter Interface
interface OrderPresenter {
    showOrder(order: Order): void;
    showError(message: string): void;
}

// Use Case
class GetOrderUseCase {
    constructor(
        private repository: OrderRepository,
        private presenter: OrderPresenter
    ) {}

    async execute(orderId: string): Promise<void> {
        try {
            const order = await this.repository.findById(orderId);
            if (!order) {
                this.presenter.showError('Order not found');
                return;
            }
            this.presenter.showOrder(order);
        } catch (err) {
            this.presenter.showError('Failed to retrieve order');
        }
    }
}

// Adapter — Database
class PostgresOrderRepository implements OrderRepository {
    async findById(id: string): Promise<Order | null> {
        const result = await db.query('SELECT * FROM orders WHERE id = $1', [id]);
        return result.rows[0] ? this.toDomain(result.rows[0]) : null;
    }

    async save(order: Order): Promise<void> {
        await db.query('INSERT INTO orders ...', [/* ... */]);
    }

    private toDomain(row: any): Order {
        return new Order(row.id, row.items, row.status);
    }
}

Partie 7 : Clean Architecture (Robert C. Martin)

7.1 Principe

La Clean Architecture organise le code en cercles concentriques. La règle d'or : les dépendances vont de l'extérieur vers l'intérieur.

Diagramme en cours de génération...

7.2 Règles de dépendance

  1. Le cercle intérieur ne sait rien du cercle extérieur
  2. Les noms dans le cercle intérieur ne doivent pas être mentionnés à l'extérieur
  3. Les Use Cases orchestrent le flux de données
  4. Les Entities encapsulent les règles métier critiques

7.3 Implémentation

// === Circle 4: Domain ===
class User {
    constructor(
        public id: string,
        public email: Email,
        public name: string
    ) {}

    changeEmail(newEmail: Email): void {
        this.email = newEmail;
        // Domain validation
    }
}

// Value Object
class Email {
    private constructor(public readonly value: string) {
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
            throw new Error('Invalid email');
        }
    }

    static create(value: string): Email {
        return new Email(value);
    }
}

// === Circle 3: Use Cases ===
interface RegisterUserInput {
    email: string;
    name: string;
    password: string;
}

interface RegisterUserOutput {
    userId: string;
    success: boolean;
}

class RegisterUserUseCase {
    constructor(
        private userRepository: UserRepository,
        private emailService: EmailService
    ) {}

    async execute(input: RegisterUserInput): Promise<RegisterUserOutput> {
        const email = Email.create(input.email);
        
        const existing = await this.userRepository.findByEmail(email);
        if (existing) {
            throw new Error('Email already registered');
        }

        const user = new User(
            generateId(),
            email,
            input.name
        );

        await this.userRepository.save(user);
        await this.emailService.sendWelcomeEmail(user);

        return { userId: user.id, success: true };
    }
}

// === Circle 2: Adapters ===
interface UserRepository {
    findByEmail(email: Email): Promise<User | null>;
    save(user: User): Promise<void>;
}

interface EmailService {
    sendWelcomeEmail(user: User): Promise<void>;
}

// Controller (adapter)
class RegisterUserController {
    constructor(private useCase: RegisterUserUseCase) {}

    async handle(request: HttpRequest): Promise<HttpResponse> {
        try {
            const result = await this.useCase.execute({
                email: request.body.email,
                name: request.body.name,
                password: request.body.password
            });
            return { statusCode: 201, body: result };
        } catch (err) {
            return { statusCode: 400, body: { error: err.message } };
        }
    }
}

Partie 8 : Comparaison et Choix

8.1 Tableau comparatif

CritèreMVCMVPMVVMFlux/ReduxClean Arch
Data BindingManuelManuelAutomatiqueManuelManuel
TestabilitéMoyenneHauteHauteHauteTrès haute
ComplexitéFaibleMoyenneMoyenneHauteHaute
FlowBidirectionnelUnidirectionnelBidirectionnelUnidirectionnelDépend du use case
Quand choisirPetites appsApps testablesApps riches (WPF, Vue)Apps complexesApps enterprise

8.2 Guide de sélection

Petite app simple → MVC
App testable → MVP
App riche en bindings → MVVM
App complexe / state global → Redux
App enterprise → Clean / Hexagonal

Résumé

  • MVC : Le classique, simple mais limité pour les apps complexes
  • MVP : Haute testabilité, présentateur sans référence à l'UI
  • MVVM : Data binding automatique, idéal pour les frameworks modernes
  • Flux/Redux : Flux unidirectionnel, state management prévisible
  • Hexagonal/Clean : Indépendance framework/DB, testabilité maximale

Prochain chapitre : Architectures Microservices, Event-Driven, CQRS.