Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
Angular

Le modele mvvm mvw dans angular

Où doit vivre chaque ligne de code ? À partir d’une même fonctionnalité (l’inscription aux formations), cette leçon compare MVC avec Symfony, MVVM avec Angular et MVVM avec Flutter. Elle montre un con...

0 chapitres 0 QCM Accès gratuit
Dans cet article

À propos de ce cours

Où doit vivre chaque ligne de code ? À partir d’une même fonctionnalité (l’inscription aux formations), cette leçon compare MVC avec Symfony, MVVM avec Angular et MVVM avec Flutter. Elle montre un contre-exemple réel, sa correction et des tests unitaires.
  1. Objectifs

    • Comprendre pourquoi on organise une application selon un modèle d'architecture (design pattern) et ce qu'est la séparation des responsabilités
    • Connaître le modèle MVC (Model-View-Controller) et son fonctionnement dans un framework côté serveur comme Symfony
    • Connaître le modèle MVVM (Model-View-ViewModel) et le rôle de la liaison de données
    • Comprendre pourquoi Angular se présente comme un framework MVW (« Model-View-Whatever ») et identifier, dans un projet Angular, la Vue, le ViewModel et le Modèle
    • Construire une fonctionnalité réelle en MVVM avec Angular 22 : modèle typé, services, signaux, composants, liaisons
    • Repérer un composant « qui fait tout » et le découper ; tester le Modèle et le ViewModel sans afficher la page
    • Comparer avec le MVVM de Flutter (ViewModel, Repository, Service) et avec le MVC de Symfony, sur la même fonctionnalité
  1. Présentation

    • Une petite application peut tenir dans un seul fichier. Mais dès qu'elle grandit, on y mélange vite l'affichage, les calculs, les règles de gestion et l'accès aux données : le code devient difficile à lire, à modifier et à tester.
    • Les modèles d'architecture répondent à ce problème avec une idée simple, la séparation des responsabilités (separation of concerns) : chaque partie du code a un seul rôle.
    • Trois rôles reviennent dans tous ces modèles :
      • le Modèle: les données et les règles métier (« une inscription est limitée à 3 formations ») ;
      • la Vue: ce que l'utilisateur voit ;
      • un intermédiaire qui relie les deux : le Contrôleur en MVC, le ViewModel en MVVM.
    • Vous avez déjà rencontré ces modèles : un site Symfony suit le modèle MVC; une application Flutter organisée selon le guide officiel suit le modèle MVVM. Cette leçon montre comment Angular se situe entre les deux, à partir d'une même fonctionnalité réelle : l'inscription aux formations du centre FormaPlus.
  1. Le modèle MVC

  • Le modèle MVC (Model-View-Controller) a été imaginé par Trygve Reenskaug en 1979, pour le langage Smalltalk. Il est devenu le modèle de référence des frameworks web côté serveur: Symfony, Laravel, Ruby on Rails, ASP.NET MVC…
  • Dans une application web MVC, chaque action de l'utilisateur produit une requête HTTP:
    1. le Contrôleur reçoit la requête et lit ses paramètres ;
    2. il demande les données au Modèle (entités, dépôts, base de données) ;
    3. il choisit une Vue et lui transmet les données ;
    4. la Vue produit la page HTML, renvoyée au navigateur.

Schéma du modèle MVC : navigateur, contrôleur, modèle et vue

  • Exemple réel avec Symfony : la liste des formations, filtrée par un texte et un niveau. À gauche le contrôleur src/Controller/FormationController.php, à droite le dépôt src/Repository/FormationRepository.php (partie Modèle, avec l'entité Formation).
<?php

namespace App\Controller;

use App\Repository\FormationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class FormationController extends AbstractController
{
    #[Route('/formations', name: 'formation_index')]
    public function index(Request $request, FormationRepository $repository): Response
    {
        // 1. Lire la requête
        $texte = $request->query->getString('q');
        $niveau = $request->query->getString('niveau');

        // 2. Interroger le modèle
        $formations = $repository->rechercher($texte, $niveau ?: null);

        // 3. Choisir la vue et lui passer les données
        return $this->render('formation/index.html.twig', [
            'formations' => $formations,
            'q' => $texte,
            'niveau' => $niveau,
        ]);
    }
}
<?php

namespace App\Repository;

use App\Entity\Formation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/** @extends ServiceEntityRepository<Formation> */
class FormationRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Formation::class);
    }

    /** @return Formation[] */
    public function rechercher(string $texte, ?string $niveau): array
    {
        $qb = $this->createQueryBuilder('f')
            ->where('LOWER(f.titre) LIKE :texte')
            ->setParameter('texte', '%' . mb_strtolower($texte) . '%')
            ->orderBy('f.titre');

        if ($niveau !== null) {
            $qb->andWhere('f.niveau = :niveau')->setParameter('niveau', $niveau);
        }

        return $qb->getQuery()->getResult();
    }
}
<!-- templates/formation/index.html.twig -->
{% extends 'base.html.twig' %}

{% block body %}
  <form method="get">
    <input type="search" name="q" value="{{ q }}" placeholder="Rechercher une formation…">
    <select name="niveau">
      <option value="">Tous</option>
      {% for n in ['Débutant', 'Intermédiaire', 'Avancé'] %}
        <option value="{{ n }}" {{ n == niveau ? 'selected' }}>{{ n }}</option>
      {% endfor %}
    </select>
    <button>Filtrer</button>
  </form>

  <p>{{ formations|length }} formation(s) trouvée(s)</p>
  {% for f in formations %}
    <article>
      <h3>{{ f.titre }}</h3>
      <p>{{ f.niveau }} – {{ f.dureeHeures }} h</p>
      <p>{{ f.prix|number_format(3, '.', ' ') }} DT</p>
    </article>
  {% else %}
    <p>Aucune formation ne correspond à votre recherche.</p>
  {% endfor %}
{% endblock %}
  • Le contrôleur est le chef d'orchestre: il ne contient ni requête SQL (c'est le dépôt) ni HTML (c'est le gabarit Twig).
  • Le filtre fonctionne avec un formulaire GET: taper « dév » et valider envoie la requête /formations?q=dév, et le serveur renvoie une nouvelle page complète.
  •  
  • Les limites de MVC pour une application dans le navigateur : chaque interaction (filtrer, ajouter une formation à une sélection, afficher un total) demande un aller-retour avec le serveur et recharge la page. Pour une interface réactive, qui se met à jour instantanément, il faut que la logique de l'écran vive dans le navigateur : c'est le terrain de MVVM.
  1. Le modèle MVVM

  • Le modèle MVVM (Model-View-ViewModel) a été présenté par John Gossman (Microsoft) en 2005 pour les interfaces WPF. Il a ensuite été adopté par Knockout.js, Vue.js, Android (Jetpack), SwiftUI, et c'est l'architecture recommandée par la documentation de Flutter.
  • Il remplace le contrôleur par un ViewModel, littéralement le « modèle de la vue » :
    • la Vue affiche l'écran ; elle ne contient aucune logique métier ;
    • le ViewModel contient l'état de l'écran (le texte recherché, la liste filtrée, le message affiché…) et les commandes (les actions possibles : s'inscrire, réinitialiser…). Il ne connaît pas la Vue ;
    • le Modèle contient les données et les règles métier, indépendamment de tout écran.
  • La pièce maîtresse est la liaison de données (data binding) : la Vue est liée à l'état du ViewModel. Quand cet état change, l'affichage se met à jour automatiquement, sans qu'aucun code ne « pousse » la nouvelle page.

Schéma du modèle MVVM : vue, ViewModel et modèle reliés par la liaison de données

  MVC (serveur) MVVM (client)
Intermédiaire Contrôleur : traite une requête, puis disparaît ViewModel : vit aussi longtemps que l'écran, garde son état
Mise à jour de l'écran le contrôleur renvoie une nouvelle page la liaison de données met à jour la vue
Qui connaît qui ? le contrôleur choisit la vue la vue connaît le ViewModel ; le ViewModel ignore la vue
Réactivité un aller-retour serveur par action immédiate, dans le navigateur ou l'application
Exemples Symfony, Laravel, Rails Angular, Vue.js, Flutter, SwiftUI
  1. Angular, un framework MVW

  • La première version, AngularJS (2010), était présentée comme un framework MVC. Mais son fonctionnement (un $scope lié à la vue) ressemblait davantage à MVVM, et les débats étaient sans fin.
  • En 2012, Igor Minar, de l'équipe AngularJS, a proposé de clore la discussion : AngularJS serait un framework MVW, Model-View-Whatever (« Modèle-Vue-ce que vous voulez »). La formule est restée dans le slogan du projet : « Superheroic JavaScript MVW Framework ». On parle aussi de MV*.
  • Le message : peu importe le nom de l'intermédiaire, l'essentiel est de bien séparer le Modèle, la Vue, et la logique qui les relie.
  • Depuis Angular 2 (2016), l'application est faite de composants, et la correspondance avec MVVM est très nette :
Rôle MVVM Dans Angular Dans notre exemple
Vue le template HTML du composant, avec ses liaisons catalogue.html, carte-formation.html, recap-panier.html
ViewModel la classe du composant : signaux, computed, méthodes class Catalogue
Modèle les interfaces (forme des données) et les services (accès aux données, règles métier, état partagé) Formation, FormationApi, Panier
Liaison {{ }}, [ ], ( ), [( )], @if, @for + signaux [(ngModel)]="recherche"

Correspondance entre MVVM et Angular : template, classe du composant, services

  •  
  • Attention au vocabulaire : dans Angular, le mot model désigne aussi les fonctions model() (inputs à double sens) et la directive ngModel. Le « Modèle » de MVVM, lui, ce sont vos interfaces et vos services.
  1. Les liaisons : le lien entre la Vue et le ViewModel

  • Les liaisons d'Angular jouent exactement le rôle prévu par MVVM : elles relient le template à la classe, dans un sens ou dans l'autre.

Les liaisons Angular entre la vue et le ViewModel : interpolation, propriété, événement, double sens

  • État → Vue (interpolation, propriété, @if, @for) : la vue lit l'état du ViewModel.
  • Vue → ViewModel (événements) : la vue déclenche une commande; c'est le ViewModel qui décide quoi faire.
  • Double sens ([(ngModel)]) : un champ de formulaire et un signal restent synchronisés. Taper dans le champ modifie le signal ; modifier le signal (bouton « Réinitialiser ») vide le champ.
  • Les signaux complètent le dispositif : quand un signal change, Angular sait précisément quelles parties des vues le lisent et ne met à jour qu'elles.
  1. Exemple complet : l'inscription aux formations

  • On construit l'écran d'inscription de FormaPlus : un catalogue chargé depuis une API, une recherche, un filtre par niveau, des cartes avec un bouton « S'inscrire », et un récapitulatif de la sélection. Les règles métier:
    • une formation complète (0 place) ne peut pas être choisie ;
    • une même formation ne peut être choisie qu'une fois ;
    • au maximum 3 formations par inscription ;
    • remise de 10 % dès 2 formations.

Application Angular d'inscription aux formations organisée en MVVM

ng new formaplus-mvvm --style=css --ssr=false
cd formaplus-mvvm
ng g interface models/formation
ng g s services/formation-api
ng g s services/panier
ng g c catalogue/catalogue
ng g c catalogue/carte-formation
ng g c catalogue/recap-panier
ng g pipe shared/dinars
  • Les données viennent d'un fichier public/api/formations.json, servi par ng serve comme une vraie API (une formation est complète, pour tester les règles) :
# public/api/formations.json
[
  { "id": 1, "titre": "Développement Angular", "niveau": "Intermédiaire", "dureeHeures": 120, "prix": 1200, "places": 8 },
  { "id": 2, "titre": "TypeScript pour débutants", "niveau": "Débutant", "dureeHeures": 40, "prix": 450, "places": 12 },
  { "id": 3, "titre": "Administration Linux", "niveau": "Avancé", "dureeHeures": 80, "prix": 900, "places": 0 },
  { "id": 4, "titre": "Développement mobile Flutter", "niveau": "Intermédiaire", "dureeHeures": 100, "prix": 1100, "places": 5 },
  { "id": 5, "titre": "Bootstrap 5 et responsive design", "niveau": "Débutant", "dureeHeures": 30, "prix": 350, "places": 15 },
  { "id": 6, "titre": "Sécurité des applications web", "niveau": "Avancé", "dureeHeures": 60, "prix": 800, "places": 6 }
]

Organisation en couches du projet Angular MVVM : vue, ViewModel, modèle, API

  1. 1. Le Modèle : interfaces et services

  • Le Modèle ne connaît aucun composant ni aucun écran. Il se compose de types, d'un service d'accès aux données et d'un service qui porte l'état partagé et les règles.
// src/app/models/formation.ts
export type Niveau = 'Débutant' | 'Intermédiaire' | 'Avancé';

export interface Formation {
  id: number;
  titre: string;
  niveau: Niveau;
  dureeHeures: number;
  prix: number;
  places: number;
}

export type Resultat = { ok: true } | { ok: false; raison: string };
// src/app/services/formation-api.ts
import { HttpClient } from '@angular/common/http';
import { Service, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { Formation } from '../models/formation';

@Service()
export class FormationApi {
  private readonly http = inject(HttpClient);

  lister(): Observable<Formation[]> {
    return this.http.get<Formation[]>('/api/formations.json');
  }
}
// src/app/services/panier.ts
import { Service, computed, signal } from '@angular/core';
import { Formation, Resultat } from '../models/formation';

@Service()
export class Panier {
  static readonly MAXIMUM = 3;
  static readonly TAUX_REMISE = 0.1;

  private readonly _lignes = signal<Formation[]>([]);

  readonly lignes = this._lignes.asReadonly();
  readonly nombre = computed(() => this._lignes().length);
  readonly sousTotal = computed(() => this._lignes().reduce((s, f) => s + f.prix, 0));
  readonly remise = computed(() => (this.nombre() >= 2 ? this.sousTotal() * Panier.TAUX_REMISE : 0));
  readonly total = computed(() => this.sousTotal() - this.remise());

  contient(id: number): boolean {
    return this._lignes().some((f) => f.id === id);
  }

  ajouter(formation: Formation): Resultat {
    if (formation.places === 0) {
      return { ok: false, raison: `« ${formation.titre} » est complète.` };
    }
    if (this.contient(formation.id)) {
      return { ok: false, raison: `« ${formation.titre} » est déjà dans votre sélection.` };
    }
    if (this.nombre() >= Panier.MAXIMUM) {
      return { ok: false, raison: `Maximum ${Panier.MAXIMUM} formations par inscription.` };
    }
    this._lignes.update((lignes) => [...lignes, formation]);
    return { ok: true };
  }

  retirer(id: number): void {
    this._lignes.update((lignes) => lignes.filter((f) => f.id !== id));
  }

  vider(): void {
    this._lignes.set([]);
  }
}
  • FormationApi ne fait qu'une chose : aller chercher les données. Il ne garde aucun état. Le client HTTP se configure dans app.config.ts avec provideHttpClient() (intercepteurs, options…).
  • Panier garde la sélection dans un signal privé (_lignes) et n'expose qu'une version en lecture seule (asReadonly()) : personne ne peut la modifier sans passer par ajouter() et ses règles.
  • Les totaux sont des computed: ils se recalculent seuls quand la sélection change.
  • ajouter() renvoie un Resultat au lieu d'afficher un message : le Modèle dit pourquoi il refuse, c'est la vue qui décidera comment l'afficher.
  •  
  • @Service() est le décorateur généré par la CLI d'Angular 22 : le service est créé une seule fois et partagé par toute l'application. Dans les versions précédentes, on écrit @Injectable({ providedIn: 'root' }).
  1. 2. Le ViewModel : la classe du composant

// src/app/catalogue/catalogue/catalogue.ts
import { Component, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { Formation, Niveau } from '../../models/formation';
import { FormationApi } from '../../services/formation-api';
import { Panier } from '../../services/panier';
import { CarteFormation } from '../carte-formation/carte-formation';

@Component({
  selector: 'app-catalogue',
  imports: [FormsModule, CarteFormation],
  styleUrl: './catalogue.css',
  templateUrl: './catalogue.html',
})
export class Catalogue {
  private readonly api = inject(FormationApi);
  readonly panier = inject(Panier);

  // État de la vue
  readonly niveaux: (Niveau | 'Tous')[] = ['Tous', 'Débutant', 'Intermédiaire', 'Avancé'];
  readonly recherche = signal('');
  readonly niveau = signal<Niveau | 'Tous'>('Tous');
  readonly message = signal<string | null>(null);

  // Données du modèle, converties en signal
  readonly formations = toSignal(this.api.lister());

  // État dérivé
  readonly chargement = computed(() => this.formations() === undefined);
  readonly filtrees = computed(() => {
    const texte = this.recherche().trim().toLowerCase();
    const niveau = this.niveau();
    return (this.formations() ?? []).filter(
      (f) => f.titre.toLowerCase().includes(texte) && (niveau === 'Tous' || f.niveau === niveau),
    );
  });

  // Commandes
  inscrire(formation: Formation): void {
    const resultat = this.panier.ajouter(formation);
    this.message.set(resultat.ok ? `« ${formation.titre} » ajoutée à votre sélection.` : resultat.raison);
  }

  reinitialiser(): void {
    this.recherche.set('');
    this.niveau.set('Tous');
  }
}
  • La classe est organisée comme un ViewModel :
    • dépendances: le Modèle, obtenu par inject();
    • état de la vue: recherche, niveau, message (des signaux modifiables) ;
    • données du modèle: toSignal() transforme la réponse HTTP (un Observable) en signal, qui vaut undefined tant que la réponse n'est pas arrivée ;
    • état dérivé: chargement et filtrees, des computed recalculés automatiquement ;
    • commandes: inscrire() et reinitialiser(), appelées par la vue.
  • Le ViewModel ne manipule jamais le DOM et ne contient aucune règle métier : il prépare ce que la vue doit afficher et transmet les actions au Modèle.
  •  
  • Public ou protected? Le guide de style d'Angular conseille protected pour les membres utilisés seulement par le template. Ici, l'état est public pour que les tests puissent le lire : un ViewModel expose son état, c'est son rôle.
  1. 3. La Vue : les templates

<!-- catalogue.html -->
<section>
  <div class="filtres">
    <input type="search" placeholder="Rechercher une formation…" [(ngModel)]="recherche" />
    <select [(ngModel)]="niveau">
      @for (n of niveaux; track n) {
        <option [value]="n">{{ n }}</option>
      }
    </select>
    <button type="button" (click)="reinitialiser()">Réinitialiser</button>
  </div>

  @if (chargement()) {
    <p>Chargement du catalogue…</p>
  } @else {
    <p class="compte">{{ filtrees().length }} formation(s) trouvée(s)</p>
    <div class="grille">
      @for (f of filtrees(); track f.id) {
        <app-carte-formation [formation]="f" [selectionnee]="panier.contient(f.id)" (inscrire)="inscrire(f)" />
      } @empty {
        <p>Aucune formation ne correspond à votre recherche.</p>
      }
    </div>
  }

  @if (message(); as m) {
    <p class="message">{{ m }}</p>
  }
</section>
  • Le template ne fait que lire l'état (chargement(), filtrees(), message()) et déclencher des commandes (inscrire(f), reinitialiser()). Il n'y a ni filtre, ni calcul de total, ni vérification de règle.
  • La carte est un composant de présentation (on dit aussi « composant muet », dumb component) : elle reçoit une formation par input() et signale le clic par output(), sans savoir ce qui se passera ensuite. Elle est réutilisable partout.
// carte-formation.ts
import { Component, input, output } from '@angular/core';
import { Formation } from '../../models/formation';
import { DinarsPipe } from '../../shared/dinars-pipe';

@Component({
  selector: 'app-carte-formation',
  imports: [DinarsPipe],
  styleUrl: './carte-formation.css',
  templateUrl: './carte-formation.html',
})
export class CarteFormation {
  formation = input.required<Formation>();
  selectionnee = input(false);
  inscrire = output<void>();
}
<!-- carte-formation.html -->
<article class="carte" [class.choisie]="selectionnee()">
  <h3>{{ formation().titre }}</h3>
  <p class="niveau">{{ formation().niveau }} – {{ formation().dureeHeures }} h</p>
  <p class="prix">{{ formation().prix | dinars }}</p>
  @if (formation().places === 0) {
    <span class="complet">Complet</span>
  } @else {
    <p class="places">{{ formation().places }} places</p>
  }
  <button type="button" (click)="inscrire.emit()" [disabled]="selectionnee()">
    {{ selectionnee() ? 'Sélectionnée' : "S'inscrire" }}
  </button>
</article>
  • Le récapitulatif lit directement le service Panier: il n'a besoin d'aucune information du catalogue.
// recap-panier.ts
import { Component, inject } from '@angular/core';
import { Panier } from '../../services/panier';
import { DinarsPipe } from '../../shared/dinars-pipe';

@Component({
  selector: 'app-recap-panier',
  imports: [DinarsPipe],
  styleUrl: './recap-panier.css',
  templateUrl: './recap-panier.html',
})
export class RecapPanier {
  readonly panier = inject(Panier);
}
<!-- recap-panier.html -->
<aside>
  <h2>Ma sélection ({{ panier.nombre() }}/3)</h2>
  @for (f of panier.lignes(); track f.id) {
    <div class="ligne">
      <span>{{ f.titre }}</span>
      <span>{{ f.prix | dinars }}</span>
      <button type="button" (click)="panier.retirer(f.id)" aria-label="Retirer">✕</button>
    </div>
  } @empty {
    <p class="vide">Aucune formation sélectionnée.</p>
  }
  @if (panier.nombre() > 0) {
    <div class="totaux">
      <div><span>Sous-total</span><span>{{ panier.sousTotal() | dinars }}</span></div>
      @if (panier.remise() > 0) {
        <div class="remise"><span>Remise 10 %</span><span>− {{ panier.remise() | dinars }}</span></div>
      }
      <div class="total"><span>Total</span><span>{{ panier.total() | dinars }}</span></div>
    </div>
  }
</aside>
// src/app/app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [provideBrowserGlobalErrorListeners(), provideHttpClient(), provideRouter(routes)],
};
// src/app/shared/dinars-pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'dinars' })
export class DinarsPipe implements PipeTransform {
  transform(montant: number): string {
    return `${montant.toFixed(3)} DT`;
  }
}
// src/app/app.ts
import { Component } from '@angular/core';
import { Catalogue } from './catalogue/catalogue/catalogue';
import { RecapPanier } from './catalogue/recap-panier/recap-panier';

@Component({
  selector: 'app-root',
  imports: [Catalogue, RecapPanier],
  styleUrl: './app.css',
  templateUrl: './app.html',
})
export class App {}
<!-- src/app/app.html -->
<header>
  <h1>FormaPlus</h1>
  <p>Inscription aux formations</p>
</header>
<main>
  <app-catalogue />
  <app-recap-panier />
</main>
  • Les fichiers CSS sont fournis dans le projet téléchargeable. Lancer ng serve, puis tester la recherche, le filtre par niveau et les règles d'inscription :

Deux états du même ViewModel Angular : recherche filtrée et limite de trois formations

  1. 4. Le trajet d'une action

  • Suivons ce qui se passe quand l'utilisateur clique sur « S'inscrire » : l'information descend de la Vue vers le Modèle, puis l'état remonte vers toutes les vues concernées.

Trajet d'un clic dans une application Angular MVVM : vue, ViewModel, modèle, mise à jour

  • C'est le flux de données unidirectionnel: les données descendent du Modèle vers la Vue, les événements remontent de la Vue vers le Modèle. On sait toujours où chercher : un affichage faux vient de l'état, un état faux vient d'une commande.
  1. Contre-exemple : le composant « qui fait tout »

  • Voici la même fonctionnalité écrite sans séparation des responsabilités, telle qu'on la rencontre souvent :

Contre-exemple Angular : composant qui mélange accès aux données, règles métier et affichage

  • Ce composant fonctionne presque. Avec Angular 22, il a même un bug bien réel : la liste reste vide au chargement, car les données arrivent par fetch dans une propriété simple, et Angular n'est pas prévenu qu'il doit mettre la vue à jour. Elles n'apparaissent qu'au premier caractère tapé dans la recherche.
  • Le découpage MVVM corrige tout :
Problème du composant « qui fait tout » Solution MVVM
fetch dans ngOnInit, vue non prévenue FormationApi + toSignal(): le signal prévient Angular
filtre écrit dans le template computed filtrees dans le ViewModel
règles métier dans le composant Panier.ajouter(), réutilisable par tous les écrans
total() recalculé à chaque vérification computed: recalculé seulement quand la sélection change
any partout interface Formation: erreurs détectées à la compilation
impossible à tester sans afficher la page Modèle et ViewModel testés séparément
  1. Tester le Modèle et le ViewModel

  • Un des grands bénéfices de MVVM : la logique est dans des classes qu'on peut tester sans afficher la page. Angular 22 utilise Vitest avec ng test.
// src/app/services/panier.spec.ts
import { TestBed } from '@angular/core/testing';
import { Formation } from '../models/formation';
import { Panier } from './panier';

const angular: Formation = { id: 1, titre: 'Développement Angular', niveau: 'Intermédiaire', dureeHeures: 120, prix: 1200, places: 8 };
const flutter: Formation = { id: 4, titre: 'Développement mobile Flutter', niveau: 'Intermédiaire', dureeHeures: 100, prix: 1100, places: 5 };
const linux: Formation = { id: 3, titre: 'Administration Linux', niveau: 'Avancé', dureeHeures: 80, prix: 900, places: 0 };
const bootstrap: Formation = { id: 5, titre: 'Bootstrap 5', niveau: 'Débutant', dureeHeures: 30, prix: 350, places: 15 };
const ts: Formation = { id: 2, titre: 'TypeScript', niveau: 'Débutant', dureeHeures: 40, prix: 450, places: 12 };

describe('Panier (modèle)', () => {
  let panier: Panier;

  beforeEach(() => {
    panier = TestBed.inject(Panier);
  });

  it('ajoute une formation disponible', () => {
    expect(panier.ajouter(angular)).toEqual({ ok: true });
    expect(panier.nombre()).toBe(1);
    expect(panier.total()).toBe(1200);
  });

  it('refuse une formation complète', () => {
    const r = panier.ajouter(linux);
    expect(r.ok).toBe(false);
    expect(panier.nombre()).toBe(0);
  });

  it('refuse un doublon', () => {
    panier.ajouter(angular);
    expect(panier.ajouter(angular).ok).toBe(false);
  });

  it('applique 10 % de remise dès 2 formations', () => {
    panier.ajouter(angular);
    panier.ajouter(flutter);
    expect(panier.sousTotal()).toBe(2300);
    expect(panier.remise()).toBe(230);
    expect(panier.total()).toBe(2070);
  });

  it('limite la sélection à 3 formations', () => {
    panier.ajouter(angular);
    panier.ajouter(flutter);
    panier.ajouter(ts);
    expect(panier.ajouter(bootstrap)).toEqual({ ok: false, raison: 'Maximum 3 formations par inscription.' });
  });
});
// src/app/catalogue/catalogue/catalogue.spec.ts
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { Formation } from '../../models/formation';
import { Catalogue } from './catalogue';

const donnees: Formation[] = [
  { id: 1, titre: 'Développement Angular', niveau: 'Intermédiaire', dureeHeures: 120, prix: 1200, places: 8 },
  { id: 2, titre: 'TypeScript pour débutants', niveau: 'Débutant', dureeHeures: 40, prix: 450, places: 12 },
  { id: 3, titre: 'Administration Linux', niveau: 'Avancé', dureeHeures: 80, prix: 900, places: 0 },
];

describe('Catalogue (ViewModel)', () => {
  let vm: Catalogue;
  let http: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()],
    });
    vm = TestBed.createComponent(Catalogue).componentInstance;
    http = TestBed.inject(HttpTestingController);
  });

  it('est en chargement tant que les données ne sont pas arrivées', () => {
    expect(vm.chargement()).toBe(true);
    http.expectOne('/api/formations.json').flush(donnees);
    expect(vm.chargement()).toBe(false);
    expect(vm.filtrees().length).toBe(3);
  });

  it('filtre par texte et par niveau', () => {
    http.expectOne('/api/formations.json').flush(donnees);
    vm.recherche.set('DÉV');
    expect(vm.filtrees().map((f) => f.id)).toEqual([1]);
    vm.recherche.set('');
    vm.niveau.set('Débutant');
    expect(vm.filtrees().map((f) => f.id)).toEqual([2]);
  });

  it('affiche la raison quand une inscription est refusée', () => {
    http.expectOne('/api/formations.json').flush(donnees);
    vm.inscrire(donnees[2]);
    expect(vm.message()).toBe('« Administration Linux » est complète.');
  });
});
  • provideHttpClientTesting() remplace le vrai serveur : HttpTestingController vérifie que la bonne URL est appelée et fournit les données de test (flush).
ng test --watch=false

Résultat des tests unitaires Vitest du modèle et du ViewModel Angular

  1. MVVM dans Flutter

  • Le guide d'architecture officiel de Flutter (docs.flutter.dev/app-architecture) recommande MVVM, avec deux couches :
    • la couche UI: les Views (widgets) et les ViewModels;
    • la couche Data, qui joue le rôle du Modèle : les Repositories (source de vérité, cache, règles) et les Services (appels à l'API, sans état).
  • Flutter n'a ni template HTML ni liaison automatique : le ViewModel étend ChangeNotifier et appelle notifyListeners() quand son état change ; la vue s'y abonne avec ListenableBuilder et se reconstruit.

Architecture MVVM recommandée par Flutter : View, ViewModel, Repository, Service

  • Voici la même fonctionnalité en Flutter, organisée selon ce guide :
lib/
├── main.dart
├── data/
│   ├── models/formation.dart
│   ├── services/formation_api.dart
│   └── repositories/
│       ├── formation_repository.dart
│       └── panier_repository.dart
└── ui/catalogue/
    ├── view_models/catalogue_view_model.dart
    └── widgets/catalogue_screen.dart
// lib/data/services/formation_api.dart
import 'dart:convert';

import 'package:http/http.dart' as http;

import '../models/formation.dart';

/// Service : appelle l'API, ne garde aucun état.
class FormationApi {
  FormationApi({required this.baseUrl});

  final String baseUrl;

  Future<List<Formation>> lister() async {
    final reponse = await http.get(Uri.parse('$baseUrl/api/formations.json'));
    if (reponse.statusCode != 200) {
      throw Exception('Erreur HTTP ${reponse.statusCode}');
    }
    final liste = jsonDecode(utf8.decode(reponse.bodyBytes)) as List<dynamic>;
    return liste
        .map((e) => Formation.fromJson(e as Map<String, dynamic>))
        .toList();
  }
}
// lib/data/repositories/formation_repository.dart
import '../models/formation.dart';
import '../services/formation_api.dart';

/// Repository : source de vérité des formations (avec cache).
class FormationRepository {
  FormationRepository({required FormationApi api}) : _api = api;

  final FormationApi _api;
  List<Formation>? _cache;

  Future<List<Formation>> formations() async => _cache ??= await _api.lister();
}
// lib/data/repositories/panier_repository.dart
import 'package:flutter/foundation.dart';

import '../models/formation.dart';

/// Repository : état partagé de la sélection et règles métier.
class PanierRepository extends ChangeNotifier {
  static const maximum = 3;
  static const tauxRemise = 0.1;

  final List<Formation> _lignes = [];

  List<Formation> get lignes => List.unmodifiable(_lignes);
  int get nombre => _lignes.length;
  double get sousTotal => _lignes.fold<double>(0, (s, f) => s + f.prix);
  double get remise => nombre >= 2 ? sousTotal * tauxRemise : 0.0;
  double get total => sousTotal - remise;

  bool contient(int id) => _lignes.any((f) => f.id == id);

  /// Renvoie null si l'ajout réussit, sinon la raison du refus.
  String? ajouter(Formation formation) {
    if (formation.places == 0) return '« ${formation.titre} » est complète.';
    if (contient(formation.id)) {
      return '« ${formation.titre} » est déjà dans votre sélection.';
    }
    if (nombre >= maximum) return 'Maximum $maximum formations par inscription.';
    _lignes.add(formation);
    notifyListeners();
    return null;
  }

  void retirer(int id) {
    _lignes.removeWhere((f) => f.id == id);
    notifyListeners();
  }
}
// lib/ui/catalogue/view_models/catalogue_view_model.dart
import 'package:flutter/foundation.dart';

import '../../../data/models/formation.dart';
import '../../../data/repositories/formation_repository.dart';
import '../../../data/repositories/panier_repository.dart';

/// ViewModel : état de l'écran et commandes appelées par la vue.
class CatalogueViewModel extends ChangeNotifier {
  CatalogueViewModel({
    required FormationRepository formationRepository,
    required PanierRepository panier,
  })  : _formationRepository = formationRepository,
        _panier = panier {
    _panier.addListener(notifyListeners);
    charger();
  }

  static const niveaux = ['Tous', 'Débutant', 'Intermédiaire', 'Avancé'];

  final FormationRepository _formationRepository;
  final PanierRepository _panier;

  List<Formation> _formations = [];
  bool _chargement = true;
  String _recherche = '';
  String _niveau = 'Tous';
  String? _message;

  bool get chargement => _chargement;
  String get niveau => _niveau;
  String? get message => _message;

  // État dérivé
  List<Formation> get filtrees {
    final texte = _recherche.trim().toLowerCase();
    return _formations
        .where((f) =>
            f.titre.toLowerCase().contains(texte) &&
            (_niveau == 'Tous' || f.niveau == _niveau))
        .toList();
  }

  bool estSelectionnee(Formation f) => _panier.contient(f.id);

  // Commandes
  Future<void> charger() async {
    _chargement = true;
    notifyListeners();
    _formations = await _formationRepository.formations();
    _chargement = false;
    notifyListeners();
  }

  void changerRecherche(String valeur) {
    _recherche = valeur;
    notifyListeners();
  }

  void changerNiveau(String valeur) {
    _niveau = valeur;
    notifyListeners();
  }

  void inscrire(Formation formation) {
    final erreur = _panier.ajouter(formation);
    _message = erreur ?? '« ${formation.titre} » ajoutée à votre sélection.';
    notifyListeners();
  }

  @override
  void dispose() {
    _panier.removeListener(notifyListeners);
    super.dispose();
  }
}
// lib/ui/catalogue/widgets/catalogue_screen.dart
import 'package:flutter/material.dart';

import '../view_models/catalogue_view_model.dart';

/// Vue : affiche l'état du ViewModel et lui transmet les actions.
class CatalogueScreen extends StatelessWidget {
  const CatalogueScreen({super.key, required this.viewModel});

  final CatalogueViewModel viewModel;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('FormaPlus – Inscription')),
      body: ListenableBuilder(
        listenable: viewModel,
        builder: (context, _) {
          if (viewModel.chargement) {
            return const Center(child: CircularProgressIndicator());
          }
          return Column(
            children: [
              Padding(
                padding: const EdgeInsets.all(8),
                child: TextField(
                  decoration: const InputDecoration(
                    hintText: 'Rechercher une formation…',
                  ),
                  onChanged: viewModel.changerRecherche,
                ),
              ),
              DropdownButton<String>(
                value: viewModel.niveau,
                items: [
                  for (final n in CatalogueViewModel.niveaux)
                    DropdownMenuItem(value: n, child: Text(n)),
                ],
                onChanged: (n) => viewModel.changerNiveau(n!),
              ),
              Text('${viewModel.filtrees.length} formation(s) trouvée(s)'),
              Expanded(
                child: ListView(
                  children: [
                    for (final f in viewModel.filtrees)
                      Card(
                        child: ListTile(
                          title: Text(f.titre),
                          subtitle: Text(
                            '${f.niveau} – ${f.dureeHeures} h – '
                            '${f.prix.toStringAsFixed(3)} DT',
                          ),
                          trailing: ElevatedButton(
                            onPressed: viewModel.estSelectionnee(f)
                                ? null
                                : () => viewModel.inscrire(f),
                            child: Text(viewModel.estSelectionnee(f)
                                ? 'Sélectionnée'
                                : "S'inscrire"),
                          ),
                        ),
                      ),
                  ],
                ),
              ),
              if (viewModel.message != null)
                Padding(
                  padding: const EdgeInsets.all(8),
                  child: Text(viewModel.message!),
                ),
            ],
          );
        },
      ),
    );
  }
}
// lib/main.dart
import 'package:flutter/material.dart';

import 'data/repositories/formation_repository.dart';
import 'data/repositories/panier_repository.dart';
import 'data/services/formation_api.dart';
import 'ui/catalogue/view_models/catalogue_view_model.dart';
import 'ui/catalogue/widgets/catalogue_screen.dart';

void main() {
  // Assemblage des couches (injection de dépendances « à la main »)
  final api = FormationApi(baseUrl: 'http://10.0.2.2:4200'); // émulateur Android
  final formationRepository = FormationRepository(api: api);
  final panier = PanierRepository();

  runApp(
    MaterialApp(
      title: 'FormaPlus',
      home: CatalogueScreen(
        viewModel: CatalogueViewModel(
          formationRepository: formationRepository,
          panier: panier,
        ),
      ),
    ),
  );
}
// test/panier_repository_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:formaplus_mvvm/data/models/formation.dart';
import 'package:formaplus_mvvm/data/repositories/panier_repository.dart';

const angular = Formation(id: 1, titre: 'Développement Angular',
    niveau: 'Intermédiaire', dureeHeures: 120, prix: 1200, places: 8);
const flutter = Formation(id: 4, titre: 'Développement mobile Flutter',
    niveau: 'Intermédiaire', dureeHeures: 100, prix: 1100, places: 5);
const linux = Formation(id: 3, titre: 'Administration Linux',
    niveau: 'Avancé', dureeHeures: 80, prix: 900, places: 0);

void main() {
  test('refuse une formation complète', () {
    final panier = PanierRepository();
    expect(panier.ajouter(linux), '« Administration Linux » est complète.');
    expect(panier.nombre, 0);
  });

  test('applique 10 % de remise dès 2 formations', () {
    final panier = PanierRepository();
    panier.ajouter(angular);
    panier.ajouter(flutter);
    expect(panier.sousTotal, 2300);
    expect(panier.remise, 230);
    expect(panier.total, 2070);
  });
}
  •  
  • Pour tester avec l'API d'Angular : lancer ng serve dans le projet Angular ; depuis l'émulateur Android, l'ordinateur hôte est joignable à l'adresse 10.0.2.2. Ajouter le paquet http avec flutter pub add http, et la permission INTERNET pour un appareil réel.
  • On retrouve les mêmes rôles qu'en Angular, avec d'autres outils :
Notion Angular Flutter
Vue template HTML du composant méthode build() d'un widget
ViewModel classe du composant classe qui étend ChangeNotifier
État modifiable signal() champ privé + notifyListeners()
État dérivé computed() getter (get filtrees)
Mise à jour de la vue automatique (liaisons + signaux) ListenableBuilder reconstruit le widget
Commande (click)="inscrire(f)" onPressed: () => viewModel.inscrire(f)
Saisie [(ngModel)]="recherche" TextField(onChanged: viewModel.changerRecherche)
Données asynchrones Observable + toSignal() Future + await
Modèle partagé service @Service() Repository
Injection de dépendances inject() (automatique) paramètres du constructeur (ou paquet provider)
Composant enfant input() / output() paramètres du constructeur / fonctions de rappel
Tests Vitest, TestBed flutter_test
  1. Comparaison : MVC, MVVM Angular, MVVM Flutter

Même fonctionnalité en Symfony MVC, Angular MVVM et Flutter MVVM

Critère MVC (Symfony) MVVM (Angular) MVVM (Flutter)
Où s'exécute la logique d'écran ? sur le serveur dans le navigateur sur le téléphone
Intermédiaire Contrôleur classe du composant ViewModel (ChangeNotifier)
Durée de vie de l'intermédiaire une requête celle du composant celle de l'écran
Vue gabarit Twig → HTML template + liaisons arbre de widgets
Mise à jour après une action nouvelle page automatique (signaux) notifyListeners() → reconstruction
Modèle entités Doctrine, dépôts, services interfaces, services modèles, Repositories, Services
Échange avec le serveur HTML complet JSON (API) JSON (API)
Référencement (SEO) naturel nécessite le rendu serveur (SSR) sans objet (application mobile)
  • Ces architectures ne s'opposent pas, elles se complètent: dans un projet réel, Symfony peut fournir l'API (/api/formations, en MVC côté serveur) consommée à la fois par l'application Angular et par l'application Flutter (en MVVM côté client).
  1. Bonnes pratiques MVVM dans Angular

    • La vue affiche, le ViewModel prépare, le Modèle décide. Si un template contient un calcul, il a sa place dans un computed; si un composant contient une règle métier, elle a sa place dans un service.
    • Typer le Modèle avec des interfaces : jamais de any.
    • Garder l'état dans des signaux; exposer l'état partagé en lecture seule (asReadonly()) et ne le modifier que par des méthodes du service.
    • Distinguer deux sortes de composants: les composants « écrans » (smart), qui injectent les services, et les composants de présentation (dumb), qui ne communiquent que par input() et output().
    • Séparer l'accès aux données (FormationApi) de l'état et des règles (Panier).
    • Tester le Modèle et le ViewModel: ce sont eux qui contiennent la logique.
  1. Activité 1 : identifier les rôles

    • Dans votre projet forma-plus du TP 1, faire un tableau de tous les fichiers de src/app et indiquer pour chacun s'il appartient à la Vue, au ViewModel ou au Modèle.
    • Repérer au moins un endroit où les rôles sont mélangés (donnée écrite en dur dans un composant, calcul dans un template…).
  1. Activité 2 : reproduire l'exemple

    • Reproduire le projet formaplus-mvvm et vérifier les quatre règles métier dans le navigateur.
    • Lancer ng test et obtenir les 8 tests réussis.
  1. Activité 3 : une nouvelle règle métier

    • Ajouter la règle : « on ne peut pas choisir deux formations de niveau Avancé ». Où l'écrire ? Combien de fichiers faut-il modifier ?
    • Ajouter le test correspondant dans panier.spec.ts.
  1. Activité 4 : enrichir le ViewModel

    • Ajouter un tri (par prix croissant, par durée, par titre) avec un <select> lié à un signal tri, et l'intégrer au computed filtrees.
    • Afficher « Aucune formation disponible » quand toutes les formations filtrées sont complètes. Le template ne doit contenir aucun calcul : créer un computed.
  1. Activité 5 : refactoriser le contre-exemple

    • Copier le composant CatalogueTout dans un projet, l'afficher et constater le bug de chargement.
    • Le refactoriser étape par étape en MVVM : interface, service d'accès aux données, service Panier, signaux et computed. Faire un commit à chaque étape.
  1. Activité 6 : la version Flutter

    • Dans votre environnement Flutter, créer le projet formaplus_mvvm avec les fichiers de la leçon, lancer ng serve côté Angular et tester l'application dans l'émulateur.
    • Ajouter à l'écran Flutter le récapitulatif de la sélection (un widget qui écoute PanierRepository), puis lancer flutter test.
  1. Activité 7 : comparer avec MVC

    • Dans le dossier symfony fourni, lire le contrôleur et le gabarit Twig. Rédiger un paragraphe qui explique ce qui se passe, côté serveur et côté navigateur, quand on filtre la liste en Symfony et en Angular.
    • Publier vos projets sur GitHub avec un README.md qui présente l'architecture choisie.
  1. Erreurs fréquentes

Symptôme Cause Solution
La liste reste vide après un chargement, puis apparaît au premier clic données placées dans une propriété simple après un traitement asynchrone (fetch, setTimeout, subscribe) utiliser un signal (toSignal(), signal.set())
La même règle est écrite dans plusieurs composants règle métier placée dans un composant la déplacer dans un service du Modèle
Un composant de présentation injecte des services rôles mélangés ne garder que input() / output(); injecter dans le composant écran
Un template de 200 lignes plein d'expressions calculs dans la vue créer des computed et découper en composants
L'état partagé est modifié de partout signal du service exposé en écriture exposer asReadonly() et des méthodes
Flutter : l'écran ne se met pas à jour notifyListeners() oublié, ou vue non enveloppée dans ListenableBuilder appeler notifyListeners() après chaque changement d'état
  1. À retenir

    • MVC: un Contrôleur reçoit une requête, interroge le Modèle et choisit une Vue ; modèle typique des frameworks serveur (Symfony).
    • MVVM: un ViewModel garde l'état de l'écran et expose des commandes ; la Vue lui est liée et se met à jour automatiquement.
    • MVW (« Model-View-Whatever ») : la formule d'AngularJS ; l'important est la séparation des responsabilités, pas le nom de l'intermédiaire.
    • Dans Angular : Vue = template, ViewModel = classe du composant (signaux, computed, méthodes), Modèle = interfaces + services.
    • Les données descendent (Modèle → ViewModel → Vue), les événements remontent (Vue → ViewModel → Modèle).
    • Dans Flutter : View (widgets) + ViewModel (ChangeNotifier) + Repository et Service (couche Data).
    • Bénéfices : code lisible, règles réutilisables, logique testable sans interface.
Que l'on écrive en Symfony, en Angular ou en Flutter, la question reste la même : « où ce code doit-il vivre ? ». Les données et les règles dans le Modèle, l'état de l'écran dans le ViewModel, l'affichage dans la Vue.