MVP + WinForms en 2026 : l'art de ne pas coder du spaghetti (from scratch, et c'est drôle)

WinForms, c'est pas mort : c'est juste en manque d'architecture. Tutorial from scratch pour monter une app Model-View-Presenter avec le Generic Host d'ASP.NET Core, injection de dépendances et tests qui tournent sur Linux.

MVP + WinForms en 2026 : l'art de ne pas coder du spaghetti (from scratch, et c'est drôle)

En 2026, la question qui hante encore les couloirs des entreprises : « WinForms ? C'est pas ce truc qui date de l'ère des dinosaures, avec des boutons gris ? »

Et pourtant. WinForms est toujours VASTÉMENT déployé, toujours maintenu par Microsoft, et toujours la façon la plus rapide au monde de sortir une app de bureau Windows sans souffrir. Le vrai problème, ce n'est pas WinForms — c'est le code spaghetti qui vit à l'intérieur des Form. Tu sais, celui où le bouton Valider envoie un e-mail, réalise un virement et grille le bacon directement dans le Click handler.

Aujourd'hui, on fait les choses proprement. On va monter une vrais app WinForms modèle Vue-Présentateur (MVP) avec le Generic Host du SDK — le truc ASP.NET Core — et l'injection de dépendances, le tout from scratch et testable sans jamais ouvrir une seule fenêtre.

Et oui, ça va être drôle. Parce qu'on code mieux quand on rit.

Prérequis : le SDK .NET 8 (ou plus). Windows pour exécuter l'app, mais — surprise — on peut compiler et tester le cœur sur Linux. On verra ça. Et un sens de l'humour développé.

1. Le pattern MVP, expliqué à ton grand-père

Le MVP (Model-View-Presenter), c'est le vieux sage du trio MVC. Trois acteurs, trois rôles, et chacun reste dans sa lane :

ActeurRôleInterdit de
ModelLes données (TaskItem, etc.)Parler à l'UI
ViewL'interface + le Form qui l'implémenteContenir de la logique métier
PresenterLe chef d'orchestre : écoute la view, parle au modelAvoir une référence à System.Windows.Forms

La clé magique : la View est une interface. Le Presenter ne connaît jamais le Form concret, juste le contrat. Comme ça, tu peux tester 100% de ta logique avec un mock, sans jamais cliquer sur une fenêtre. Le Présentateur 🥇 — le ça c'est lui qui remporte le prix du joueur le plus discipliné.

2. On part de zéro : le projet

Ouvre ton terminal préféré (PowerShell ou le terminal de ton choix, on ne juge pas), et crée la structure :

dotnet new sln -n MvpDemo
dotnet new classlib -n MvpTaskManager.Core -o MvpTaskManager.Core -f net8.0
dotnet new winforms -n MvpTaskManager -o MvpTaskManager
dotnet new xunit -n MvpTaskManager.Tests -o MvpTaskManager.Tests
dotnet sln add MvpTaskManager.Core MvpTaskManager MvpTaskManager.Tests

Pitfall #1 — « 🚫 dotnet new winforms » sur Linux. Le template WinForms n'est pas dans le SDK de base, et sous Linux tu vas te prendre un NETSDK1100 en pleine face si tu ne dis pas que tu veux cibler Windows. Deux lignes à ajouter dans chaque .csproj qui touche du Windows :

<UseWindowsForms>true</UseWindowsForms>
<EnableWindowsTargeting>true</EnableWindowsTargeting>

Sans EnableWindowsTargeting, .NET râle parce que tu builds du Windows depuis un système pas-Windows. C'est la ceinture de sécurité du SDK. Boucle-la.

Pourquoi un projet Core séparé ? Parce que le cœur (Model + View-interface + Presenter) n'a aucune raison de dépendre de WinForms. En le mettant dans une librairie net8.0 pure (pas -windows), tu peux le compiler et le tester sur n'importe quelle machine, y compris Linux ou un CI. C'est pas joli, ça ?

3. Le model — la pauvre victime innocente

Le Model, c'est la donnée brute. Pas de logique. Pas de fla-fla. Juste une classe à laquelle on ne demande rien d'autre que d'exister.

namespace MvpTaskManager.Core.Models;

public class TaskItem
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public bool IsDone { get; set; }

    public override string ToString() => IsDone ? $"[x] {Title}" : $"[ ] {Title}";
}

4. La vue — un contrat, pas une fenêtre

La View est une interface. Elle définit ce que le présentateur peut voir et faire, mais pas comment c'est peint à l'écran. C'est le contrat de travail entre les deux.

using MvpTaskManager.Core.Models;

namespace MvpTaskManager.Core.Views;

public interface ITaskView
{
    // Événements déclenchés par l'UI (ce que l'utilisateur fait)
    event EventHandler? AddRequested;
    event EventHandler? RemoveRequested;
    event EventHandler? ToggleRequested;

    // Propriétés que le Presenter lit/écrit
    string TitleInput { get; }
    TaskItem? SelectedTask { get; }

    // Hydratation de la liste
    void BindTasks(IEnumerable<TaskItem> tasks);

    // Petit feedback sans état
    void ShowStatus(string message);
}

Remarque le flux : les clics partent comme des événements, les données reviennent comme des méthodes. Le Presenter ne sait pas qu'il y a un ListBox, un Button ou un StatusStrip quelque part. Pour lui, la vue c'est une boîte noire políe.

5. Le form — la marionnette (la vraie fenêtre)

Le MainForm implémente l'interface. Ici on a le droit d'utiliser des Button, des TextBox et des coordonnées — mais strictement aucun cerveau. C'est de la présentation pure.

using MvpTaskManager.Core.Models;
using MvpTaskManager.Core.Presenters;
using MvpTaskManager.Core.Views;

namespace MvpTaskManager.Views;

public partial class MainForm : Form, ITaskView
{
    private readonly TextBox _titleInput = new();
    private readonly Button _addButton = new();
    private readonly Button _toggleButton = new();
    private readonly Button _removeButton = new();
    private readonly ListBox _listBox = new();
    private readonly StatusStrip _status = new();

    private TaskPresenter _presenter = null!;

    public MainForm()
    {
        BuildUi();
        _presenter = new TaskPresenter(this); // le Form s'auto-offre comme view
    }

    public event EventHandler? AddRequested;
    public event EventHandler? RemoveRequested;
    public event EventHandler? ToggleRequested;

    public string TitleInput => _titleInput.Text;
    public TaskItem? SelectedTask => _listBox.SelectedItem as TaskItem;

    public void BindTasks(IEnumerable<TaskItem> tasks)
        => _listBox.DataSource = tasks.ToList();

    public void ShowStatus(string message)
        => _status.Items[0].Text = message;

    private void BuildUi()
    {
        Text = "MVP Task Manager — 100% architecture, 0% café";
        Width = 520;
        Height = 430;
        StartPosition = FormStartPosition.CenterScreen;

        _titleInput.Location = new Point(12, 12);
        _titleInput.Size = new Size(330, 30);

        _addButton.Text = "Ajouter ➕";
        _addButton.Location = new Point(350, 10);
        _addButton.Size = new Size(120, 34);
        _addButton.Click += (_, _) => AddRequested?.Invoke(this, EventArgs.Empty);

        _listBox.Location = new Point(12, 55);
        _listBox.Size = new Size(458, 260);

        _toggleButton.Text = "Cocher / Décocher ☑";
        _toggleButton.Location = new Point(12, 325);
        _toggleButton.Size = new Size(220, 34);
        _toggleButton.Click += (_, _) => ToggleRequested?.Invoke(this, EventArgs.Empty);

        _removeButton.Text = "Supprimer 🗑";
        _removeButton.Location = new Point(246, 325);
        _removeButton.Size = new Size(224, 34);
        _removeButton.Click += (_, _) => RemoveRequested?.Invoke(this, EventArgs.Empty);

        _status.Items.Add(string.Empty);

        Controls.Add(_titleInput);
        Controls.Add(_addButton);
        Controls.Add(_toggleButton);
        Controls.Add(_removeButton);
        Controls.Add(_listBox);
        Controls.Add(_status);

        // Enter dans le TextBox = bouton Ajouter (merci, ergonomie)
        _titleInput.KeyDown += (_, e) =>
        {
            if (e.KeyCode == Keys.Enter)
            {
                AddRequested?.Invoke(this, EventArgs.Empty);
                e.Handled = true;
            }
        };
    }
}

Le geste du maître : chaque bouton lève un événement vers le Presenter. Le Form ne décide de rien. C'est un serveur, pas un chef. Quand ton Form devient une façade maillée d'événements et que toute la logique trône dans le Presenter, tu as gagné la partie.

6. Le présentateur — le cerveau de l'opération

Et maintenant le boss final. Le Presenter écoute les événements de la vue, manipule le model, et renvoie des ordres. Zéro référence à WinForms — c'est ce qui le rend testable partout.

using MvpTaskManager.Core.Models;
using MvpTaskManager.Core.Views;

namespace MvpTaskManager.Core.Presenters;

public class TaskPresenter
{
    private readonly ITaskView _view;
    private readonly List<TaskItem> _tasks = new();
    private int _nextId = 1;

    public TaskPresenter(ITaskView view)
    {
        _view = view;

        // Abonnement aux événements de la view
        _view.AddRequested    += OnAdd;
        _view.RemoveRequested += OnRemove;
        _view.ToggleRequested += OnToggle;

        SeedSampleData();
        RefreshGrid();
    }

    private void OnAdd(object? sender, EventArgs e)
    {
        if (string.IsNullOrWhiteSpace(_view.TitleInput))
        {
            _view.ShowStatus("T'as rien tapé, banane.");
            return;
        }

        _tasks.Add(new TaskItem { Id = _nextId++, Title = _view.TitleInput.Trim() });
        RefreshGrid();
        _view.ShowStatus($"Ajouté : « {_tasks[^1].Title} »");
    }

    private void OnToggle(object? sender, EventArgs e)
    {
        var task = _view.SelectedTask;
        if (task is null)
        {
            _view.ShowStatus("Sélectionne une tâche avant de cocher.");
            return;
        }
        task.IsDone = !task.IsDone;
        RefreshGrid();
        _view.ShowStatus(task.IsDone ? $"Terminé : « {task.Title} » ✨" : $"Reprise : « {task.Title} » 😤");
    }

    private void OnRemove(object? sender, EventArgs e)
    {
        var task = _view.SelectedTask;
        if (task is null)
        {
            _view.ShowStatus("Sélectionne une tâche à supprimer.");
            return;
        }
        _tasks.Remove(task);
        RefreshGrid();
        _view.ShowStatus($"Supprimé... et personne ne le pleurera. 🪦");
    }

    private void RefreshGrid()
        => _view.BindTasks(_tasks.OrderBy(t => t.IsDone).ThenBy(t => t.Id));

    private void SeedSampleData()
    {
        _tasks.Add(new TaskItem { Id = _nextId++, Title = "Manger des chips", IsDone = false });
        _tasks.Add(new TaskItem { Id = _nextId++, Title = "Réparer le café",   IsDone = true });
        _tasks.Add(new TaskItem { Id = _nextId++, Title = "Faire de l'exercice (demain)", IsDone = false });
    }
}

La validation, l'ajout, le cocher, le supprimer — tout vit ici. Et comme le Presenter ne parle qu'à une interface, on peut l'instancier avec une fausse vue et vérifier qu'il se comporte comme un chef.

7. Le coup de génie ASP.NET Core : le Generic Host

Oui, tu as bien lu. On va greffer le cœur d'ASP.NET Core — le Generic Host — dans une app WinForms. C'est permis. C'est même génial. Ça te donne un conteneur d'injection de dépendances professionnel, de la configuration, et pas un seul port malade de Node.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MvpTaskManager.Core.Presenters;
using MvpTaskManager.Core.Views;
using MvpTaskManager.Views;

namespace MvpTaskManager;

internal static class Program
{
    [STAThread]
    static void Main()
    {
        ApplicationConfiguration.Initialize();

        var host = Host.CreateDefaultBuilder()
            .ConfigureServices(services =>
            {
                // Enregistre la View comme singleton (une fenêtre, un destin)
                services.AddSingleton<MainForm>();        // le Form concret
                services.AddSingleton<ITaskView>(sp => sp.GetRequiredService<MainForm>()); // le contrat
                services.AddSingleton<TaskPresenter>();   // le chef d'orchestre
            })
            .Build();

        using var serviceScope = host.Services.CreateScope();
        var mainForm = serviceScope.ServiceProvider.GetRequiredService<MainForm>();
        Application.Run(mainForm);
    }
}

Qu'est-ce qu'on gagne ? L'injection de dépendances. Si ton presenter a besoin d'un service (un dépôt, une API, un service de café ☕), tu l'enregistres ici et le framework le fournit tout seul. Plus de new partout. Plus de constructeurs monstres. Juste la paix.

Pitfall #2 — le package ! Il te faut le NuGet Microsoft.Extensions.Hosting dans le projet WinForms. Sans lui, Host.CreateDefaultBuilder() n'existe pas et tu te demandes pourquoi ton code ne compile pas. dotnet add package Microsoft.Extensions.Hosting — fait.

Pourquoi <MainForm> ET <ITaskView> ? Le Form est enregistré deux fois : une fois comme lui-même (pour lancer l'app), une fois comme son contrat (pour que le Presenter puisse être testé ou résolu proprement). L'expression lambda dit « donne-lui le même singleton ». Simple, non ?

8. La preuve que ça marche : les tests

Voilà la cerise. Parce que la View est une interface, on peut écrire un mock bête et tester le Presenter sans jamais ouvrir une fenêtre. Ça, c'est le « testable à la base » promis plus haut.

using MvpTaskManager.Core.Models;
using MvpTaskManager.Core.Presenters;
using MvpTaskManager.Core.Views;

namespace MvpTaskManager.Tests;

public class FakeTaskView : ITaskView
{
    public event EventHandler? AddRequested;
    public event EventHandler? RemoveRequested;
    public event EventHandler? ToggleRequested;

    public string TitleInput { get; set; } = string.Empty;
    public TaskItem? SelectedTask { get; set; }
    public List<TaskItem> BoundTasks { get; } = new();
    public string? LastStatus { get; private set; }

    public void BindTasks(IEnumerable<TaskItem> tasks)
    {
        BoundTasks.Clear();
        BoundTasks.AddRange(tasks);
    }

    public void ShowStatus(string message) => LastStatus = message;

    public void RaiseAdd()    => AddRequested?.Invoke(this, EventArgs.Empty);
    public void RaiseToggle() => ToggleRequested?.Invoke(this, EventArgs.Empty);
    public void RaiseRemove() => RemoveRequested?.Invoke(this, EventArgs.Empty);
}

Et trois tests, parce que trois, c'est le minimum syndical :

[Fact]
public void Adding_Empty_Title_Is_Rejected_With_Sass()
{
    var view = new FakeTaskView { TitleInput = "   " };
    var _ = new TaskPresenter(view);

    view.RaiseAdd();

    Assert.Contains("banane", view.LastStatus);
    Assert.Equal(3, view.BoundTasks.Count);
}

[Fact]
public void Adding_Task_Appears_In_Grid()
{
    var view = new FakeTaskView { TitleInput = "Faire le ménage" };
    var _ = new TaskPresenter(view);

    view.RaiseAdd();

    Assert.Contains(view.BoundTasks, t => t.Title == "Faire le ménage");
}

[Fact]
public void Toggling_Done_Task_Unchecks_It()
{
    var view = new FakeTaskView();
    var _ = new TaskPresenter(view);
    view.SelectedTask = view.BoundTasks.First(t => t.IsDone);

    view.RaiseToggle();

    Assert.False(view.SelectedTask.IsDone);
}

Lance dotnet test et…

Passed!  - Failed: 0, Passed: 3, Skipped: 0, Total: 3

Trois verts. Et ce test tourne sur n'importe quelle plateforme, parce que le cœur (Core) ne dépend pas de WinForms. Un CI Linux qui teste ton app de bureau Windows. 2026, mec. La magie.

9. Le bilan — pourquoi tu devrais t'y mettre

On a monté, de zéro, une app WinForms avec :

  • Le pattern MVP avec une vue purement déclarative (interface)
  • Le Generic Host ASP.NET Core pour l'injection de dépendances
  • Séparation Core (testable partout) / WinForms (présentation)
  • Des tests unitaires qui passent sans ouvrir une seule fenêtre

Ce n'est pas « juste une vieille techno ». C'est une vieille techno avec une ceinture et des bretelles. Le code spaghetti meurt, le code testable prospère, et ton futur toi (et ton futur collègue qui doit maintenir ça) te diront merci.

Le vrai secret : MVP, ce n'est pas une religion, c'est de la séparation des soucis. Que tu l'appelles MVP, MVVM ou « organise ton fricot » — tant que la logique ne vit pas dans le Click handler et que tu peux la tester, tu es dans la bonne direction. Allez, va coder. Et si quelqu'un te demande pourquoi ton app est si propre, dis-lui que c'est grâce au présentateur. 🤙