Chapitre 524 minIntermédiaire
MVC en C# WinForms
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- découper un projet WinForms en Models, Data, Controllers et Views ;
- écrire un DAO avec MySqlConnector et des requêtes paramétrées ;
- garder le code des formulaires sans SQL ni règle de gestion ;
- reconnaître ce qui rapproche cette organisation du patron MVP.
3 exercices corrigés
Appliquer MVC à une application C# WinForms, c'est sortir du code des formulaires tout ce qui n'est pas de l'affichage : les classes de données vont dans Models, le SQL dans des classes d'accès aux données (les DAO), les règles et la coordination dans des contrôleurs. Le formulaire ne garde que ce qui concerne l'écran : remplir les contrôles, lire la saisie, afficher un message. Ce chapitre construit l'écran de réservation de ResaSalles en .NET 10 avec le pilote MySqlConnector. Les bases de C# et de WinForms sont dans le cours C# et WinForms (créer ton premier projet), dont le chapitre Architecture en couches découpe une application complète en projets.
Le point de départ : tout dans le bouton
Dans une première application WinForms, le code du bouton fait tout :
private void btnReserver_Click(object sender, EventArgs e)
{
using var cnx = new MySqlConnection("Server=localhost;Database=resasalles;Uid=root;Pwd=root;");
cnx.Open();
if ((dtpFin.Value - dtpDebut.Value).TotalHours > 4) { MessageBox.Show("4 heures maximum"); return; }
var cmd = new MySqlCommand("INSERT INTO reservation (salle_id, debut, fin, objet) VALUES ("
+ cboSalles.SelectedValue + ", @d, @f, '" + txtObjet.Text + "')", cnx);
// …
}
Ça marche pour trois boutons. À trente, c'est ingérable : le même SQL est recopié dans plusieurs formulaires, la règle des 4 heures n'est vérifiée que depuis cet écran, et le texte saisi est concaténé dans la requête. On réorganise en quatre dossiers.
L'organisation du projet
ResaSalles/ (projet Windows Forms, .NET 10)
├── Program.cs ← point d'entrée : assemble les couches
├── Models/
│ ├── Salle.cs
│ └── Reservation.cs
├── Data/
│ ├── SalleDao.cs
│ └── ReservationDao.cs
├── Controllers/
│ └── ReservationController.cs
└── Views/
├── FormReservations.cs ← le code des événements
└── FormReservations.Designer.cs ← généré par le concepteur de Visual Studio
Les dossiers se créent dans l'explorateur de solutions (clic droit sur le projet, Ajouter, Nouveau dossier). Par défaut, Visual Studio donne aux classes d'un dossier un espace de noms qui le reprend (ResaSalles.Models) : ajoute les using correspondants en haut des fichiers qui les utilisent. Le pilote s'installe avec NuGet : dotnet add package MySqlConnector (version 2.6 en novembre 2026). Le pilote publié par Oracle, MySql.Data, fonctionne aussi, avec les mêmes classes MySqlConnection et MySqlCommand.
Les modèles : des classes de données
// Models/Salle.cs
namespace ResaSalles.Models;
public class Salle
{
public int Id { get; set; }
public string Nom { get; set; } = "";
public int Capacite { get; set; }
public bool Visio { get; set; }
// Le texte affiché par la ComboBox de la vue.
public override string ToString() => $"{Nom} ({Capacite} places)";
}
// Models/Reservation.cs
namespace ResaSalles.Models;
public class Reservation
{
public int Id { get; set; }
public int SalleId { get; set; }
public int EmployeId { get; set; }
public DateTime Debut { get; set; }
public DateTime Fin { get; set; }
public string Objet { get; set; } = "";
public int Participants { get; set; }
}
Une classe du dossier Models porte des données. Elle ne connaît ni MySQL ni les formulaires. ToString() est une exception tolérée : c'est la façon standard de dire à une ComboBox quel texte afficher pour un objet.
Les DAO : tout le SQL, et rien d'autre
// Data/SalleDao.cs
using MySqlConnector;
using ResaSalles.Models;
namespace ResaSalles.Data;
// Tout le SQL des salles, et rien d'autre : ni MessageBox, ni règle de gestion.
public class SalleDao
{
private readonly string _chaineConnexion;
public SalleDao(string chaineConnexion)
{
_chaineConnexion = chaineConnexion;
}
public List<Salle> Lister()
{
var salles = new List<Salle>();
using var connexion = new MySqlConnection(_chaineConnexion);
connexion.Open();
using var commande = new MySqlCommand("SELECT id, nom, capacite, visio FROM salle ORDER BY nom", connexion);
using var lecteur = commande.ExecuteReader();
while (lecteur.Read())
{
salles.Add(new Salle
{
Id = lecteur.GetInt32("id"),
Nom = lecteur.GetString("nom"),
Capacite = lecteur.GetInt32("capacite"),
Visio = lecteur.GetBoolean("visio"),
});
}
return salles;
}
}
// Data/ReservationDao.cs (extrait : les deux méthodes utilisées pour réserver)
public bool ExisteChevauchement(int salleId, DateTime debut, DateTime fin)
{
using var connexion = new MySqlConnection(_chaineConnexion);
connexion.Open();
using var commande = new MySqlCommand(
"SELECT COUNT(*) FROM reservation WHERE salle_id = @salle AND debut < @fin AND fin > @debut",
connexion);
commande.Parameters.AddWithValue("@salle", salleId);
commande.Parameters.AddWithValue("@debut", debut);
commande.Parameters.AddWithValue("@fin", fin);
return Convert.ToInt64(commande.ExecuteScalar()) > 0;
}
public int Ajouter(Reservation r)
{
using var connexion = new MySqlConnection(_chaineConnexion);
connexion.Open();
using var commande = new MySqlCommand(
"INSERT INTO reservation (salle_id, employe_id, debut, fin, objet, participants) " +
"VALUES (@salle, @employe, @debut, @fin, @objet, @participants)",
connexion);
commande.Parameters.AddWithValue("@salle", r.SalleId);
commande.Parameters.AddWithValue("@employe", r.EmployeId);
commande.Parameters.AddWithValue("@debut", r.Debut);
commande.Parameters.AddWithValue("@fin", r.Fin);
commande.Parameters.AddWithValue("@objet", r.Objet);
commande.Parameters.AddWithValue("@participants", r.Participants);
commande.ExecuteNonQuery();
return (int)commande.LastInsertedId;
}
La classe ReservationDao contient aussi ListerParSalle(int salleId), construite comme SalleDao.Lister() avec un paramètre @salle. Trois habitudes à prendre :
using varferme la connexion, la commande et le lecteur à la fin de la méthode, même si une exception est levée. Sans lui, les connexions restent ouvertes et le serveur finit par refuser les nouvelles.- Les paramètres (
@salle,@objet) transmettent les valeurs séparément du texte SQL : pas d'injection possible, et les dates sont converties correctement, sans souci de format. - Une connexion par opération : on l'ouvre, on s'en sert, on la ferme. Le pilote garde en réalité les connexions dans un pool et les réutilise ; ouvrir une
MySqlConnectionne coûte presque rien.
Le contrôleur : coordonner et vérifier
// Controllers/ReservationController.cs
using ResaSalles.Data;
using ResaSalles.Models;
namespace ResaSalles.Controllers;
// Le contrôleur : reçoit les demandes de la vue, applique les règles, appelle les DAO.
// Il ne connaît aucun contrôle WinForms : on pourrait le brancher sur une autre interface.
public class ReservationController
{
private readonly SalleDao _salleDao;
private readonly ReservationDao _reservationDao;
public ReservationController(SalleDao salleDao, ReservationDao reservationDao)
{
_salleDao = salleDao;
_reservationDao = reservationDao;
}
public List<Salle> Salles() => _salleDao.Lister();
public List<Reservation> ReservationsDe(int salleId) => _reservationDao.ListerParSalle(salleId);
// Renvoie la liste des erreurs ; vide si la réservation est enregistrée.
public List<string> Reserver(Salle salle, Reservation demande)
{
var erreurs = new List<string>();
if (string.IsNullOrWhiteSpace(demande.Objet)) erreurs.Add("L'objet est obligatoire.");
if (demande.Fin <= demande.Debut) erreurs.Add("La fin doit être après le début.");
if (demande.Debut < DateTime.Now) erreurs.Add("Impossible de réserver dans le passé.");
if ((demande.Fin - demande.Debut).TotalHours > 4) erreurs.Add("Une réservation dure 4 heures au plus.");
if (demande.Participants < 1) erreurs.Add("Indique au moins 1 participant.");
else if (demande.Participants > salle.Capacite) erreurs.Add($"La salle {salle.Nom} accueille {salle.Capacite} personnes au plus.");
if (erreurs.Count == 0 && _reservationDao.ExisteChevauchement(salle.Id, demande.Debut, demande.Fin))
{
erreurs.Add("La salle est déjà réservée sur ce créneau.");
}
if (erreurs.Count == 0)
{
demande.SalleId = salle.Id;
demande.Id = _reservationDao.Ajouter(demande);
}
return erreurs;
}
}
Le contrôleur ne contient aucune référence à System.Windows.Forms : ni TextBox, ni MessageBox. Il reçoit des objets (Salle, Reservation) et renvoie des objets (une liste, des messages). C'est ce qui le rend testable : un test peut appeler Reserver sans ouvrir de fenêtre. Il reçoit ses DAO par son constructeur au lieu de les créer : c'est l'injection de dépendances, approfondie au chapitre 7.
Comme le contrôleur PHP du chapitre 3, celui-ci porte encore les règles de gestion. Au chapitre 6, elles partent dans un ReservationService, et le contrôleur devient un simple relais.
La vue : le formulaire
// Views/FormReservations.cs
using ResaSalles.Controllers;
using ResaSalles.Models;
namespace ResaSalles.Views;
// La vue : affiche, lit la saisie, transmet au contrôleur. Aucun SQL, aucune règle de gestion.
public partial class FormReservations : Form
{
private readonly ReservationController _controleur;
private readonly int _employeConnecte;
public FormReservations(ReservationController controleur, int employeConnecte)
{
InitializeComponent();
_controleur = controleur;
_employeConnecte = employeConnecte;
}
private void FormReservations_Load(object sender, EventArgs e)
{
cboSalles.DataSource = _controleur.Salles();
}
private void cboSalles_SelectedIndexChanged(object sender, EventArgs e)
{
if (cboSalles.SelectedItem is Salle salle)
{
dgvReservations.DataSource = _controleur.ReservationsDe(salle.Id);
}
}
private void btnReserver_Click(object sender, EventArgs e)
{
if (cboSalles.SelectedItem is not Salle salle) return;
var demande = new Reservation
{
EmployeId = _employeConnecte,
Debut = dtpDebut.Value,
Fin = dtpFin.Value,
Objet = txtObjet.Text.Trim(),
Participants = (int)nudParticipants.Value,
};
List<string> erreurs = _controleur.Reserver(salle, demande);
if (erreurs.Count > 0)
{
MessageBox.Show(string.Join(Environment.NewLine, erreurs), "Réservation refusée",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
dgvReservations.DataSource = _controleur.ReservationsDe(salle.Id);
}
}
Les contrôles (cboSalles, dgvReservations, dtpDebut, dtpFin, txtObjet, nudParticipants, btnReserver) sont posés avec le concepteur de Visual Studio ; leur déclaration est dans FormReservations.Designer.cs. Pour que les deux sélecteurs de date affichent aussi l'heure, règle leur propriété Format sur Custom et CustomFormat sur dd/MM/yyyy HH:mm.
Chaque gestionnaire d'événement fait trois choses au plus : lire les contrôles, appeler le contrôleur, mettre l'écran à jour. On retrouve le trajet MVC du chapitre 2, avec un événement à la place de la requête HTTP.
Program.cs : assembler les couches
// Program.cs
using ResaSalles.Controllers;
using ResaSalles.Data;
using ResaSalles.Views;
namespace ResaSalles;
internal static class Program
{
[STAThread]
private static void Main()
{
ApplicationConfiguration.Initialize();
string chaine = Environment.GetEnvironmentVariable("RESASALLES_DB")
?? throw new InvalidOperationException("Variable RESASALLES_DB absente");
// On assemble les couches ici, et nulle part ailleurs.
var controleur = new ReservationController(new SalleDao(chaine), new ReservationDao(chaine));
Application.Run(new FormReservations(controleur, employeConnecte: 7));
}
}
Program.cs est le seul endroit qui connaît toutes les pièces : il lit la chaîne de connexion, crée les DAO, les donne au contrôleur, donne le contrôleur au formulaire. L'identifiant de l'employé connecté est écrit en dur ici pour l'exemple ; dans la vraie application, il vient d'un formulaire de connexion affiché avant.
MVC ou MVP ?
Regarde qui fait quoi : le formulaire reçoit les événements, appelle le contrôleur, puis se met à jour lui-même avec ce que le contrôleur renvoie. Le contrôleur ne connaît pas le formulaire. Dans le MVC d'origine, la vue observe le modèle ; dans le MVP, un présentateur pilote une vue passive par une interface (IVueReservations avec des méthodes AfficherSalles, AfficherErreurs). L'organisation de ce chapitre est entre les deux. C'est celle qu'on rencontre dans la plupart des applications WinForms d'entreprise, et c'est ce qu'on appelle « MVC en WinForms » au BTS : l'essentiel est que le formulaire ne contienne ni SQL ni règle de gestion.
Une interface qui ne se fige pas
Toutes les méthodes du chapitre sont synchrones : pendant que la requête SQL s'exécute, le formulaire ne répond plus. Sur un réseau local et des tables de quelques milliers de lignes, c'est imperceptible. Si la base est lointaine, on utilise les versions asynchrones du pilote (OpenAsync, ExecuteReaderAsync) et des gestionnaires async :
private async void cboSalles_SelectedIndexChanged(object sender, EventArgs e)
{
if (cboSalles.SelectedItem is Salle salle)
dgvReservations.DataSource = await _controleur.ReservationsDeAsync(salle.Id);
}
Le gestionnaire d'événement est le seul endroit où async void est admis ; partout ailleurs, une méthode asynchrone renvoie Task ou Task<T>. Le découpage MVC rend ce changement local : seuls les DAO, le contrôleur et les gestionnaires concernés changent de signature.
Exercices
Exercice 1 sur 3 Déplacer le code du bouton Réussi À revoir
Reprends le code « tout dans le bouton » du début du chapitre. Pour chaque instruction, indique dans quelle classe elle doit aller : FormReservations, ReservationController, ReservationDao ou Program.
- La création de la
MySqlConnectionavec la chaîne de connexion. - Le test
(dtpFin.Value - dtpDebut.Value).TotalHours > 4. MessageBox.Show("4 heures maximum").- La requête
INSERT INTO reservation …. - La lecture de
txtObjet.Text.
Voir la réponseMasquer la réponse de l'exercice 1
- La chaîne de connexion est lue dans
Programet transmise aux DAO ; laMySqlConnectionest créée dans chaque méthode deReservationDao. ReservationController(et au chapitre 6,ReservationService), sous la forme(demande.Fin - demande.Debut).TotalHours > 4: la règle porte sur la demande, pas sur les contrôles de l'écran.FormReservations: afficher un message est un travail de vue. Le contrôleur renvoie le texte de l'erreur, la vue décide de la façon de le montrer.ReservationDao.Ajouter, avec des paramètres au lieu de la concaténation.FormReservations: seule la vue connaît les contrôles. Elle range la valeur dans l'objetReservationenvoyé au contrôleur.
Tu avais trouvé ?
Exercice 2 sur 3 Ajouter l'annulation Réussi À revoir
Un salarié doit pouvoir annuler l'une de ses réservations à venir, sélectionnée dans la grille. Écris la méthode du DAO, celle du contrôleur, et le gestionnaire du bouton btnAnnuler. Règles : on ne peut annuler que ses propres réservations, et pas une réservation déjà commencée.
Voir la réponseMasquer la réponse de l'exercice 2
DAO :
public bool Supprimer(int reservationId, int employeId)
{
using var connexion = new MySqlConnection(_chaineConnexion);
connexion.Open();
using var commande = new MySqlCommand(
"DELETE FROM reservation WHERE id = @id AND employe_id = @employe AND debut > NOW()", connexion);
commande.Parameters.AddWithValue("@id", reservationId);
commande.Parameters.AddWithValue("@employe", employeId);
return commande.ExecuteNonQuery() == 1;
}
Contrôleur :
public string? Annuler(Reservation reservation, int employeConnecte)
{
if (reservation.EmployeId != employeConnecte) return "Tu ne peux annuler que tes propres réservations.";
if (reservation.Debut <= DateTime.Now) return "Cette réservation a déjà commencé.";
return _reservationDao.Supprimer(reservation.Id, employeConnecte) ? null : "Réservation introuvable.";
}
Vue :
private void btnAnnuler_Click(object sender, EventArgs e)
{
if (dgvReservations.CurrentRow?.DataBoundItem is not Reservation reservation) return;
if (cboSalles.SelectedItem is not Salle salle) return;
string? erreur = _controleur.Annuler(reservation, _employeConnecte);
if (erreur is not null) MessageBox.Show(erreur);
dgvReservations.DataSource = _controleur.ReservationsDe(salle.Id);
}
Le DELETE répète les conditions (employe_id, debut > NOW()) : même si le contrôleur était contourné, la base ne supprimerait pas la réservation d'un autre. La vérification dans le contrôleur sert à donner un message clair.
Tu avais trouvé ?
Exercice 3 sur 3 Ce que le contrôleur ne doit pas contenir Réussi À revoir
Un étudiant a écrit dans ReservationController : public void Reserver(TextBox txtObjet, DateTimePicker dtpDebut, DateTimePicker dtpFin) { … }. Explique pourquoi c'est une erreur d'architecture et réécris la signature.
Voir la réponseMasquer la réponse de l'exercice 3
Le contrôleur dépend alors des contrôles WinForms : il ne peut plus être testé sans fenêtre, ni réutilisé par une autre interface (une API, une application console d'import). Renommer un contrôle dans le concepteur casse le contrôleur.
Signature correcte : public List<string> Reserver(Salle salle, Reservation demande). La vue construit l'objet Reservation à partir des contrôles ; le contrôleur ne manipule que des objets du modèle et des types simples.
Tu avais trouvé ?