Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 819 minIntermédiaire

Architecture en couches : interface, métier, accès aux données

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • découper une application WinForms en trois couches et en projets ;
  • placer chaque morceau de code dans la bonne couche ;
  • faire dépendre le métier d'une interface plutôt que de MySQL ;
  • assembler les objets au démarrage, dans Program.cs.

3 exercices corrigés1 schéma

Une architecture en couches découpe une application en parties qui ont chacune une seule responsabilité : l'interface affiche et recueille les saisies, la couche métier applique les règles de gestion, la couche d'accès aux données dialogue avec la base. Chaque couche n'appelle que celle du dessous. En C#, chaque couche devient un projet de la solution, ce qui rend la séparation visible et vérifiée par le compilateur. La théorie générale (MVC, MVP, rôle de chaque composant) est dans le cours Architecture MVC (le modèle MVC, architecture en couches) ; ce chapitre l'applique à une application WinForms.

Le formulaire qui fait tout

Voici le code qu'on écrit naturellement au début : le clic sur « Enregistrer » ouvre la connexion, construit la requête et affiche le résultat, le tout dans le formulaire.

csharp
private void btnEnregistrer_Click(object? sender, EventArgs e)
{
    using var conn = new MySqlConnection("Server=localhost;Database=gsb_ordonnances;User ID=root;Password=root");
    conn.Open();
    var cmd = new MySqlCommand("INSERT INTO patient (nom, prenom) VALUES (@nom, @prenom)", conn);
    cmd.Parameters.AddWithValue("@nom", txtNom.Text);
    cmd.Parameters.AddWithValue("@prenom", txtPrenom.Text);
    cmd.ExecuteNonQuery();
    MessageBox.Show("Patient ajouté");
}

Ça marche. Puis les ennuis arrivent :

  • la chaîne de connexion est recopiée dans chaque formulaire : changer de serveur oblige à modifier dix fichiers ;
  • la règle « deux patients ne peuvent pas avoir le même numéro de sécurité sociale » doit être recodée dans la fiche de création, la fiche de modification et l'import ;
  • impossible de tester la règle sans ouvrir une fenêtre et sans base de données ;
  • remplacer MySQL par une API (chapitre 11) oblige à réécrire tous les formulaires.

Trois couches, quatre projets

On sépare l'application en trois couches, plus un projet de tests :

ProjetCoucheContenuConnaît
Gsb.UiPrésentationFormulaires, Program.csGsb.Metier (et Gsb.Donnees dans Program.cs seulement)
Gsb.MetierMétierClasses du domaine (Patient, Ordonnance), règles, services, interfaces des dépôtsRien d'autre
Gsb.DonneesAccès aux donnéesDAO MySQL, contexte Entity Framework, client de l'APIGsb.Metier
Gsb.TestsTestsTests unitaires du métierGsb.Metier
Gsb.UiprésentationFrmPatients, FrmConnexion…Program.cs : assemble les objetsGsb.Teststests unitairesPatientServiceTestsFauxDepotPatients (en mémoire)Gsb.MetiermétierPatient, Ordonnance, PatientServiceIPatientRepository (interface)Gsb.Donneesaccès aux donnéesPatientDao : IPatientRepositoryimplémente l'interfaceProgram.csseulementGsb.Metier ne référence aucun projet : ni WinForms, ni MySQL.
Toutes les références pointent vers la couche métier

Le point clé est le sens des références. Le métier est au centre et ne dépend de rien : ni de WinForms, ni de MySQL. C'est lui qui déclare ce dont il a besoin, sous forme d'interface (IPatientRepository : « quelqu'un qui sait lister, ajouter, modifier des patients ») ; la couche données fournit une classe qui l'implémente. On parle d'inversion de dépendance.

Pour créer les références dans Visual Studio : clic droit sur le projet Gsb.Ui > Ajouter > Référence de projet, coche Gsb.Metier et Gsb.Donnees. En ligne de commande :

bash
dotnet add Gsb.Ui reference Gsb.Metier Gsb.Donnees
dotnet add Gsb.Donnees reference Gsb.Metier
dotnet add Gsb.Tests reference Gsb.Metier

Le compilateur fait désormais respecter l'architecture : depuis Gsb.Metier, il est impossible d'écrire new MySqlConnection(…), le paquet n'y est pas référencé.

La couche métier : modèles, règles et services

Elle contient trois sortes de classes.

Les modèles décrivent les données du domaine et protègent leurs propres règles. Patient refuse un nom vide ; Ordonnance refuse une ligne de durée hors limites ou un médicament en double.

csharp
namespace Gsb.Metier;

public record LigneOrdonnance(string CodeMedicament, string Posologie, int DureeJours);

public class RegleMetierException(string message) : Exception(message);

public class Ordonnance
{
    public const int DureeMaxJours = 90;

    public int NumOrdonnance { get; set; }
    public DateTime DateEmission { get; set; } = DateTime.Now;
    public int NumMedecin { get; set; }
    public int NumPatient { get; set; }
    public Medecin? Medecin { get; set; }
    public Patient? Patient { get; set; }

    private readonly List<LigneOrdonnance> _lignes = [];
    public IReadOnlyList<LigneOrdonnance> Lignes => _lignes;

    public void AjouterLigne(LigneOrdonnance ligne)
    {
        if (ligne.DureeJours is < 1 or > DureeMaxJours)
            throw new RegleMetierException($"La durée doit être comprise entre 1 et {DureeMaxJours} jours.");
        if (_lignes.Any(l => l.CodeMedicament == ligne.CodeMedicament))
            throw new RegleMetierException($"Le médicament {ligne.CodeMedicament} figure déjà sur l'ordonnance.");
        _lignes.Add(ligne);
    }

    public void VerifierAvantEnregistrement()
    {
        if (NumMedecin == 0 || NumPatient == 0)
            throw new RegleMetierException("Choisis un médecin et un patient.");
        if (_lignes.Count == 0)
            throw new RegleMetierException("Une ordonnance contient au moins un médicament.");
    }
}

La liste des lignes est privée et exposée en lecture seule (IReadOnlyList) : on ne peut ajouter une ligne qu'en passant par AjouterLigne, donc par ses contrôles. C'est l'encapsulation au service des règles de gestion.

Les interfaces de dépôt disent ce dont le métier a besoin pour lire et écrire, sans dire comment :

csharp
namespace Gsb.Metier;

public interface IPatientRepository
{
    Task<List<Patient>> ListerAsync();
    Task<Patient?> TrouverAsync(int numPatient);
    Task<bool> NumeroSecuExisteAsync(string numeroSecu, int saufNumPatient);
    Task<int> AjouterAsync(Patient patient);
    Task ModifierAsync(Patient patient);
    Task SupprimerAsync(int numPatient);
}

Les méthodes renvoient des Task : elles seront asynchrones dans leur implémentation MySQL (chapitre 7).

Les services orchestrent un cas d'utilisation et appliquent les règles qui ont besoin des données :

csharp
namespace Gsb.Metier;

public class PatientService(IPatientRepository depot)
{
    public Task<List<Patient>> ListerAsync() => depot.ListerAsync();

    public async Task EnregistrerAsync(Patient patient)
    {
        if (patient.DateNaissance > DateTime.Today)
            throw new RegleMetierException("La date de naissance ne peut pas être dans le futur.");

        if (patient.NumeroSecu is not null
            && await depot.NumeroSecuExisteAsync(patient.NumeroSecu, patient.NumPatient))
            throw new RegleMetierException("Un autre patient a déjà ce numéro de sécurité sociale.");

        if (patient.NumPatient == 0)
            patient.NumPatient = await depot.AjouterAsync(patient);
        else
            await depot.ModifierAsync(patient);
    }

    public Task SupprimerAsync(int numPatient) => depot.SupprimerAsync(numPatient);
}

PatientService(IPatientRepository depot) est un constructeur principal : le service reçoit son dépôt de l'extérieur au lieu de le créer (new PatientDao()). C'est l'injection de dépendance par le constructeur. Le service ne sait pas si ce dépôt parle à MySQL, à une API, ou s'il s'agit d'un faux dépôt en mémoire écrit pour les tests (chapitre 13).

Le numéro saufNumPatient permet la modification : quand on modifie Marie Dupont, son propre numéro existe déjà en base, mais c'est le sien. Pour un nouveau patient, NumPatient vaut 0 et aucun patient n'a ce numéro.

Le service des ordonnances suit le même modèle. Il reçoit en plus l'utilisateur connecté, pour appliquer la règle « seul un médecin crée une ordonnance » (chapitre 12) :

csharp
namespace Gsb.Metier;

public class Utilisateur
{
    public int NumUtilisateur { get; set; }
    public string Login { get; set; } = "";
    public string HashMotDePasse { get; set; } = "";
    public string Role { get; set; } = "";   // "medecin" ou "secretaire"
}

public interface IOrdonnanceRepository
{
    Task<List<OrdonnanceLisible>> ListerLisiblesAsync();
    Task<int> EnregistrerAsync(Ordonnance ordonnance);
}

public class OrdonnanceService(IOrdonnanceRepository depot, Utilisateur connecte)
{
    public Task<List<OrdonnanceLisible>> ListerLisiblesAsync() => depot.ListerLisiblesAsync();

    public async Task<int> EnregistrerAsync(Ordonnance ordonnance)
    {
        if (connecte.Role != "medecin")
            throw new RegleMetierException("Seul un médecin peut créer une ordonnance.");
        ordonnance.VerifierAvantEnregistrement();
        return await depot.EnregistrerAsync(ordonnance);
    }
}

Le service vérifie tout ce qui relève du métier (le rôle, puis le contenu de l'ordonnance) avant d'appeler le dépôt : le DAO n'a plus qu'à écrire en base. OrdonnanceLisible est le petit record d'affichage présenté juste en dessous.

La couche données : les DAO

Un DAO (Data Access Object) regroupe toutes les requêtes d'une table. PatientDao implémente IPatientRepository avec MySqlConnector : c'est l'objet du chapitre 9. Le mot dépôt (repository) désigne la même idée vue depuis le métier : une collection d'objets qu'on peut interroger, sans se soucier de la façon dont elle est stockée.

Pour les écrans qui affichent le résultat d'une jointure, on crée une petite classe de transport, un DTO (Data Transfer Object), qui contient exactement les colonnes affichées. Un record suffit :

csharp
public record OrdonnanceLisible(int NumOrdonnance, DateTime DateEmission, string Patient, string Medecin);

La couche présentation : des formulaires minces

Le formulaire ne contient plus ni SQL ni règle. Il reçoit le service par son constructeur, l'appelle, et affiche le résultat ou le message d'erreur.

csharp
public partial class FrmPatients : Form
{
    private readonly PatientService _service;

    public FrmPatients(PatientService service, Utilisateur utilisateur)
    {
        InitializeComponent();
        _service = service;
        Text = $"GSB Ordonnances – {utilisateur.Login}";
    }

    private async Task EnregistrerAsync(Patient patient)
    {
        try
        {
            await _service.EnregistrerAsync(patient);
            await ChargerAsync();
        }
        catch (RegleMetierException ex)
        {
            MessageBox.Show(ex.Message, "Enregistrement refusé",
                MessageBoxButtons.OK, MessageBoxIcon.Warning);
        }
    }
}

Un test simple vérifie l'architecture : lance une recherche de MySqlCommand (ou SELECT) dans tout le projet Gsb.Ui. Il ne doit y avoir aucun résultat.

Assembler les objets au démarrage

Quelqu'un doit bien créer le DAO et le passer au service, puis le service au formulaire. Ce rôle revient à Program.cs, le point de composition : le seul endroit de l'interface qui connaît la couche données.

csharp
using Gsb.Donnees;
using Gsb.Metier;

static void Main()
{
    ApplicationConfiguration.Initialize();

    string chaine = LireChaineConnexion();   // chapitre 12 : jamais en dur dans le code
    var connexion = new Connexion(chaine);
    var patientService = new PatientService(new PatientDao(connexion));
    var authService = new AuthService(new UtilisateurDao(connexion));

    using var frmConnexion = new FrmConnexion(authService);
    if (frmConnexion.ShowDialog() != DialogResult.OK) return;

    Application.Run(new FrmPatients(patientService, frmConnexion.Utilisateur!));
}

OrdonnanceService a besoin de l'utilisateur connecté : on le crée donc après la fenêtre de connexion (new OrdonnanceService(new OrdonnanceDao(connexion), frmConnexion.Utilisateur!)) et on le passe au formulaire qui en a besoin, comme patientService.

Passer à Entity Framework (chapitre 10) ou à une API ne change qu'une ligne : new PatientService(new EfPatientRepository(contexte)). Les formulaires et le service ne bougent pas.

Exercices

Exercice 1 sur 3 Ranger le code Réussi À revoir

Dans quel projet (Gsb.Ui, Gsb.Metier, Gsb.Donnees) places-tu chaque élément ?

  1. La classe Medecin.
  2. La requête SELECT … FROM medecin ORDER BY nom.
  3. La règle « un médecin doit avoir un numéro RPPS de 11 chiffres ».
  4. La MessageBox qui affiche « Médecin enregistré ».
  5. L'interface IMedecinRepository.
  6. La classe MedecinDao.
  7. La création de new MedecinService(new MedecinDao(connexion)).
Voir la réponseMasquer la réponse de l'exercice 1
  1. Gsb.Metier (modèle).
  2. Gsb.Donnees, dans MedecinDao.
  3. Gsb.Metier : dans le set de la propriété NumeroRpps, ou dans MedecinService (et en plus, pour le confort, dans le Validating du formulaire).
  4. Gsb.Ui, dans le formulaire.
  5. Gsb.Metier : c'est le métier qui déclare ce dont il a besoin.
  6. Gsb.Donnees : elle implémente IMedecinRepository avec MySQL.
  7. Gsb.Ui, dans Program.cs, le point de composition.

Tu avais trouvé ?

Exercice 2 sur 3 Repérer la violation Réussi À revoir

Ce gestionnaire est dans FrmOrdonnance.cs. Quelles règles d'architecture enfreint-il ? Réécris-le en supposant qu'un OrdonnanceService existe avec une méthode EnregistrerAsync(Ordonnance).

csharp
private void btnEnregistrer_Click(object? sender, EventArgs e)
{
    if (lstLignes.Items.Count == 0)
    {
        MessageBox.Show("Ajoute au moins un médicament.");
        return;
    }
    using var conn = new MySqlConnection(Properties.Settings.Default.Chaine);
    conn.Open();
    var cmd = new MySqlCommand("INSERT INTO ordonnance (numMedecin, numPatient) VALUES ("
        + cboMedecin.SelectedValue + ", " + _numPatient + ")", conn);
    cmd.ExecuteNonQuery();
}
Voir la réponseMasquer la réponse de l'exercice 2

Trois problèmes : du SQL dans la vue (accès aux données hors de la couche données) ; une règle métier (« au moins un médicament ») vérifiée seulement dans l'interface ; une requête construite par concaténation, au lieu d'une requête paramétrée. On peut ajouter que les lignes de l'ordonnance ne sont jamais enregistrées et qu'il n'y a pas de transaction.

csharp
private async void btnEnregistrer_Click(object? sender, EventArgs e)
{
    try
    {
        _ordonnance.NumMedecin = (int)(cboMedecin.SelectedValue ?? 0);
        _ordonnance.NumPatient = _numPatient;
        await _ordonnanceService.EnregistrerAsync(_ordonnance);   // vérifie, puis enregistre en transaction
        DialogResult = DialogResult.OK;
    }
    catch (RegleMetierException ex)
    {
        MessageBox.Show(ex.Message, "Ordonnance incomplète",
            MessageBoxButtons.OK, MessageBoxIcon.Warning);
    }
}

La vérification « au moins un médicament » est dans Ordonnance.VerifierAvantEnregistrement(), appelée par le service ; le SQL paramétré et la transaction sont dans OrdonnanceDao (chapitre 9).

Tu avais trouvé ?

Exercice 3 sur 3 Pourquoi une interface ? Réussi À revoir

Ton binôme propose de supprimer IPatientRepository et d'écrire directement public class PatientService(PatientDao depot). « Ça fait un fichier de moins. » Donne deux arguments pour garder l'interface.

Voir la réponseMasquer la réponse de l'exercice 3
  1. Les tests : avec l'interface, on teste PatientService avec un faux dépôt en mémoire, sans serveur MySQL, en quelques millisecondes (chapitre 13). Avec PatientDao, chaque test exige une base de données.
  2. Le changement de stockage : passer à Entity Framework ou à une API se fait en écrivant une autre classe qui implémente l'interface, sans modifier le service ni les formulaires. Avec PatientDao en dur, le métier dépend de MySQL, et Gsb.Metier devrait référencer Gsb.Donnees, ce qui inverse le sens des dépendances.

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Connexion C# à MySQL avec MySqlConnectorRevoir le sommaire du cours