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.
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 :
| Projet | Couche | Contenu | Connaît |
|---|---|---|---|
Gsb.Ui | Présentation | Formulaires, Program.cs | Gsb.Metier (et Gsb.Donnees dans Program.cs seulement) |
Gsb.Metier | Métier | Classes du domaine (Patient, Ordonnance), règles, services, interfaces des dépôts | Rien d'autre |
Gsb.Donnees | Accès aux données | DAO MySQL, contexte Entity Framework, client de l'API | Gsb.Metier |
Gsb.Tests | Tests | Tests unitaires du métier | Gsb.Metier |
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 :
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.
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 :
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 :
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) :
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 :
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.
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.
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 ?
- La classe
Medecin. - La requête
SELECT … FROM medecin ORDER BY nom. - La règle « un médecin doit avoir un numéro RPPS de 11 chiffres ».
- La
MessageBoxqui affiche « Médecin enregistré ». - L'interface
IMedecinRepository. - La classe
MedecinDao. - La création de
new MedecinService(new MedecinDao(connexion)).
Voir la réponseMasquer la réponse de l'exercice 1
Gsb.Metier(modèle).Gsb.Donnees, dansMedecinDao.Gsb.Metier: dans lesetde la propriétéNumeroRpps, ou dansMedecinService(et en plus, pour le confort, dans leValidatingdu formulaire).Gsb.Ui, dans le formulaire.Gsb.Metier: c'est le métier qui déclare ce dont il a besoin.Gsb.Donnees: elle implémenteIMedecinRepositoryavec MySQL.Gsb.Ui, dansProgram.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).
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.
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
- Les tests : avec l'interface, on teste
PatientServiceavec un faux dépôt en mémoire, sans serveur MySQL, en quelques millisecondes (chapitre 13). AvecPatientDao, chaque test exige une base de données. - 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
PatientDaoen dur, le métier dépend de MySQL, etGsb.Metierdevrait référencerGsb.Donnees, ce qui inverse le sens des dépendances.
Tu avais trouvé ?