Architecture Clean et MVVM dans .NET MAUI : Guide Complet avec CommunityToolkit, Injection de Dépendances et Navigation Shell

Guide pratique pour structurer vos applications .NET MAUI avec MVVM et CommunityToolkit.Mvvm, injection de dépendances, pattern Repository, navigation Shell et Clean Architecture. Exemples de code C# concrets et stratégies de tests unitaires.

Introduction : Pourquoi l'Architecture est Fondamentale pour les Applications Mobiles

Soyons honnêtes : on a tous déjà écrit une application mobile où tout le code finissait dans le code-behind. Ça marche, bien sûr. Pendant un temps. Puis les fonctionnalités s'accumulent, les bugs se multiplient, et chaque modification devient un exercice de funambulisme où toucher une ligne casse trois écrans. Si vous avez vécu ça (et je parie que oui), vous savez exactement pourquoi l'architecture logicielle n'est pas un luxe académique — c'est une nécessité pratique.

Avec .NET MAUI 10, Microsoft nous offre un framework cross-platform mature qui intègre nativement les outils nécessaires pour construire des applications bien architecturées : injection de dépendances, navigation Shell, data binding performant, et un écosystème de packages comme le CommunityToolkit.Mvvm qui élimine une grande partie du code répétitif.

Dans ce guide, on va construire ensemble une architecture robuste pour une application .NET MAUI en combinant :

  • Le pattern MVVM (Model-View-ViewModel) comme fondation de la couche présentation
  • Le CommunityToolkit.Mvvm 8.x et ses générateurs de source pour réduire le boilerplate
  • L'injection de dépendances intégrée à MAUI
  • Le pattern Repository pour abstraire l'accès aux données
  • La navigation Shell avec passage de paramètres
  • Les principes de Clean Architecture pour structurer le projet en couches
  • Les tests unitaires rendus possibles par cette architecture

On utilisera comme fil conducteur une application de gestion de tâches — un classique, certes, mais redoutablement efficace pour illustrer tous les concepts. Chaque section contiendra du code C# réel et directement utilisable. Allez, on y va.

Le Pattern MVVM dans .NET MAUI

Les Trois Piliers : Model, View, ViewModel

Le pattern MVVM sépare votre application en trois responsabilités distinctes :

  • Model : vos données et votre logique métier. C'est le cœur de votre application, indépendant de toute interface graphique.
  • View : l'interface utilisateur, définie en XAML dans .NET MAUI. Elle ne contient aucune logique métier.
  • ViewModel : le pont entre les deux. Il expose les données du Model sous une forme que la View peut consommer via le data binding, et traduit les actions de l'utilisateur en opérations sur le Model.

Le principe fondamental est simple : la View ne connaît pas le Model, et le Model ne connaît pas la View. Le ViewModel orchestre tout, et le data binding assure la communication bidirectionnelle entre la View et le ViewModel.

Le Data Binding en .NET MAUI

Le data binding, c'est le mécanisme qui connecte les propriétés du ViewModel aux éléments visuels de la View. Quand une propriété change dans le ViewModel, l'interface se met à jour automatiquement — et inversement. C'est un peu magique la première fois qu'on le voit en action.

Voici un Model simple :

public class TaskItem
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public bool IsCompleted { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public DateTime? CompletedAt { get; set; }
}

Et voici un ViewModel écrit de manière traditionnelle, sans toolkit, pour comprendre le mécanisme sous-jacent :

public class TaskListViewModel : INotifyPropertyChanged
{
    private ObservableCollection<TaskItem> _tasks = new();
    private bool _isLoading;
    private string _searchText = string.Empty;

    public event PropertyChangedEventHandler? PropertyChanged;

    public ObservableCollection<TaskItem> Tasks
    {
        get => _tasks;
        set
        {
            if (_tasks != value)
            {
                _tasks = value;
                OnPropertyChanged();
            }
        }
    }

    public bool IsLoading
    {
        get => _isLoading;
        set
        {
            if (_isLoading != value)
            {
                _isLoading = value;
                OnPropertyChanged();
            }
        }
    }

    public string SearchText
    {
        get => _searchText;
        set
        {
            if (_searchText != value)
            {
                _searchText = value;
                OnPropertyChanged();
            }
        }
    }

    protected void OnPropertyChanged([CallerMemberName] string? name = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

Vous voyez le problème ? Pour trois propriétés, on a déjà une quantité considérable de code répétitif. Imaginez un ViewModel avec quinze propriétés et dix commandes — ça devient vite ingérable. C'est exactement là que le CommunityToolkit entre en jeu.

Le XAML Correspondant

Côté View, le binding se fait naturellement :

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:MonApp.ViewModels"
             x:Class="MonApp.Views.TaskListPage"
             x:DataType="vm:TaskListViewModel">

    <VerticalStackLayout Padding="16" Spacing="12">
        <SearchBar Text="{Binding SearchText}"
                   Placeholder="Rechercher une tâche..." />

        <ActivityIndicator IsRunning="{Binding IsLoading}"
                           IsVisible="{Binding IsLoading}" />

        <CollectionView ItemsSource="{Binding Tasks}">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="model:TaskItem">
                    <Frame Padding="12" Margin="0,4">
                        <VerticalStackLayout>
                            <Label Text="{Binding Title}"
                                   FontAttributes="Bold"
                                   FontSize="16" />
                            <Label Text="{Binding Description}"
                                   TextColor="Gray" />
                        </VerticalStackLayout>
                    </Frame>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
    </VerticalStackLayout>
</ContentPage>

Notez l'attribut x:DataType qui active le compiled binding dans .NET MAUI. C'est une optimisation importante : au lieu de résoudre les bindings par réflexion à l'exécution, le compilateur génère du code typé. Les erreurs de binding sont détectées à la compilation, et les performances s'en trouvent nettement améliorées.

CommunityToolkit.Mvvm et les Générateurs de Source

Adieu le Boilerplate

Le package CommunityToolkit.Mvvm (version 8.x) est développé par Microsoft et la communauté .NET. Il utilise les générateurs de source C# pour produire automatiquement tout le code répétitif que l'on écrivait manuellement. Honnêtement, une fois qu'on y a goûté, c'est difficile de revenir en arrière.

Installez-le via NuGet :

dotnet add package CommunityToolkit.Mvvm --version 8.4.0

ObservableProperty : Propriétés Réactives

L'attribut [ObservableProperty] génère automatiquement la propriété publique avec notification de changement à partir d'un champ privé :

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;

public partial class TaskListViewModel : ObservableObject
{
    [ObservableProperty]
    private ObservableCollection<TaskItem> _tasks = new();

    [ObservableProperty]
    private bool _isLoading;

    [ObservableProperty]
    private string _searchText = string.Empty;

    [ObservableProperty]
    private TaskItem? _selectedTask;
}

Le générateur de source crée pour vous les propriétés Tasks, IsLoading, SearchText et SelectedTask avec tout le mécanisme INotifyPropertyChanged. La classe doit être partial pour que le code généré puisse s'y intégrer.

Vous pouvez aussi réagir aux changements de propriétés en définissant des méthodes partielles :

public partial class TaskListViewModel : ObservableObject
{
    [ObservableProperty]
    private string _searchText = string.Empty;

    // Appelée automatiquement quand SearchText change
    partial void OnSearchTextChanged(string value)
    {
        FilterTasks(value);
    }

    // Appelée juste avant que SearchText change
    partial void OnSearchTextChanging(string value)
    {
        // Logique de validation par exemple
    }

    private void FilterTasks(string query)
    {
        // Logique de filtrage
    }
}

RelayCommand et AsyncRelayCommand

Les commandes permettent de lier des actions utilisateur (clic sur un bouton, pull-to-refresh) à des méthodes du ViewModel. Le toolkit propose [RelayCommand] pour les commandes synchrones et asynchrones :

public partial class TaskListViewModel : ObservableObject
{
    private readonly ITaskRepository _taskRepository;

    [ObservableProperty]
    private ObservableCollection<TaskItem> _tasks = new();

    [ObservableProperty]
    private bool _isLoading;

    public TaskListViewModel(ITaskRepository taskRepository)
    {
        _taskRepository = taskRepository;
    }

    [RelayCommand]
    private async Task LoadTasksAsync()
    {
        try
        {
            IsLoading = true;
            var items = await _taskRepository.GetAllAsync();
            Tasks = new ObservableCollection<TaskItem>(items);
        }
        catch (Exception ex)
        {
            await Shell.Current.DisplayAlert("Erreur",
                $"Impossible de charger les tâches : {ex.Message}", "OK");
        }
        finally
        {
            IsLoading = false;
        }
    }

    [RelayCommand]
    private async Task DeleteTaskAsync(TaskItem task)
    {
        bool confirm = await Shell.Current.DisplayAlert(
            "Confirmation",
            $"Supprimer \"{task.Title}\" ?",
            "Supprimer", "Annuler");

        if (confirm)
        {
            await _taskRepository.DeleteAsync(task.Id);
            Tasks.Remove(task);
        }
    }

    [RelayCommand]
    private async Task ToggleCompletionAsync(TaskItem task)
    {
        task.IsCompleted = !task.IsCompleted;
        task.CompletedAt = task.IsCompleted ? DateTime.UtcNow : null;
        await _taskRepository.UpdateAsync(task);
    }

    [RelayCommand]
    private async Task NavigateToDetailAsync(TaskItem task)
    {
        await Shell.Current.GoToAsync($"taskDetail?id={task.Id}");
    }

    [RelayCommand]
    private async Task AddNewTaskAsync()
    {
        await Shell.Current.GoToAsync("taskDetail");
    }
}

Le générateur crée automatiquement les propriétés LoadTasksCommand, DeleteTaskCommand, ToggleCompletionCommand, etc. Pour les commandes asynchrones, il gère aussi la propriété IsRunning sur le command object, ce qui permet de désactiver un bouton pendant l'exécution. Franchement, c'est remarquablement bien pensé.

Côté XAML, on lie simplement :

<Button Text="Ajouter une tâche"
        Command="{Binding AddNewTaskCommand}" />

<CollectionView ItemsSource="{Binding Tasks}">
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="model:TaskItem">
            <SwipeView>
                <SwipeView.RightItems>
                    <SwipeItems>
                        <SwipeItem Text="Supprimer"
                                   BackgroundColor="Red"
                                   Command="{Binding Source={RelativeSource
                                       AncestorType={x:Type vm:TaskListViewModel}},
                                       Path=DeleteTaskCommand}"
                                   CommandParameter="{Binding .}" />
                    </SwipeItems>
                </SwipeView.RightItems>
                <Frame Padding="12" Margin="0,4">
                    <Grid ColumnDefinitions="*,Auto">
                        <VerticalStackLayout>
                            <Label Text="{Binding Title}" FontAttributes="Bold" />
                            <Label Text="{Binding Description}" TextColor="Gray" />
                        </VerticalStackLayout>
                        <CheckBox Grid.Column="1"
                                  IsChecked="{Binding IsCompleted}" />
                    </Grid>
                </Frame>
            </SwipeView>
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

NotifyCanExecuteChangedFor et NotifyPropertyChangedFor

Deux attributs supplémentaires qui méritent qu'on s'y arrête. Ils permettent de créer des dépendances entre propriétés et commandes :

public partial class TaskDetailViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
    private string _title = string.Empty;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(CharactersRemaining))]
    private string _description = string.Empty;

    public int CharactersRemaining => 500 - (Description?.Length ?? 0);

    private bool CanSave() => !string.IsNullOrWhiteSpace(Title);

    [RelayCommand(CanExecute = nameof(CanSave))]
    private async Task SaveAsync()
    {
        // Sauvegarde...
    }
}

Quand Title change, le toolkit notifie automatiquement SaveCommand de réévaluer son état CanExecute. Quand Description change, la propriété CharactersRemaining notifie la View de sa mise à jour. Tout ça sans écrire une seule ligne de plomberie. Plutôt élégant.

Injection de Dépendances Intégrée

Configuration dans MauiProgram.cs

.NET MAUI intègre nativement le conteneur d'injection de dépendances de Microsoft (Microsoft.Extensions.DependencyInjection). Tout se configure dans MauiProgram.cs, le point d'entrée de votre application :

using Microsoft.Extensions.Logging;
using MonApp.Repositories;
using MonApp.Services;
using MonApp.ViewModels;
using MonApp.Views;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });

        // Services
        builder.Services.AddSingleton<DatabaseService>();
        builder.Services.AddSingleton<ITaskRepository, SqliteTaskRepository>();
        builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
        builder.Services.AddTransient<INotificationService, LocalNotificationService>();

        // ViewModels
        builder.Services.AddTransient<TaskListViewModel>();
        builder.Services.AddTransient<TaskDetailViewModel>();
        builder.Services.AddTransient<SettingsViewModel>();

        // Pages
        builder.Services.AddTransient<TaskListPage>();
        builder.Services.AddTransient<TaskDetailPage>();
        builder.Services.AddTransient<SettingsPage>();

#if DEBUG
        builder.Logging.AddDebug();
#endif

        return builder.Build();
    }
}

Comprendre les Durées de Vie

Le choix de la durée de vie d'un service est crucial — et c'est une source fréquente de bugs quand il est mal fait :

  • Singleton (AddSingleton) : une seule instance pour toute la durée de vie de l'application. Idéal pour les services de base de données, le cache, la configuration. Attention : un singleton ne doit jamais dépendre d'un service transient ou scoped.
  • Transient (AddTransient) : une nouvelle instance à chaque demande. C'est le choix standard pour les ViewModels et les Pages. Chaque navigation crée un ViewModel frais, sans état résiduel d'une navigation précédente.
  • Scoped (AddScoped) : une instance par "scope". En MAUI, les scopes sont moins courants que dans ASP.NET Core, mais peuvent être utiles pour des opérations unitaires comme une transaction de base de données.

En pratique, la règle est simple : les services partagés en Singleton, les ViewModels et Pages en Transient. C'est un bon point de départ qui convient à la grande majorité des cas.

Injection dans les ViewModels et les Pages

Une fois les services enregistrés, l'injection se fait par constructeur. .NET MAUI résout automatiquement les dépendances :

public partial class TaskDetailViewModel : ObservableObject, IQueryAttributable
{
    private readonly ITaskRepository _taskRepository;
    private readonly INavigationService _navigationService;
    private readonly INotificationService _notificationService;

    [ObservableProperty]
    private TaskItem _task = new();

    [ObservableProperty]
    private bool _isEditing;

    public TaskDetailViewModel(
        ITaskRepository taskRepository,
        INavigationService navigationService,
        INotificationService notificationService)
    {
        _taskRepository = taskRepository;
        _navigationService = navigationService;
        _notificationService = notificationService;
    }

    [RelayCommand]
    private async Task SaveAsync()
    {
        if (IsEditing)
        {
            await _taskRepository.UpdateAsync(Task);
        }
        else
        {
            await _taskRepository.AddAsync(Task);
        }

        await _notificationService.ShowAsync("Tâche sauvegardée !");
        await _navigationService.GoBackAsync();
    }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("id", out var idObj) && int.TryParse(idObj.ToString(), out int id))
        {
            IsEditing = true;
            LoadTask(id);
        }
    }

    private async void LoadTask(int id)
    {
        var task = await _taskRepository.GetByIdAsync(id);
        if (task is not null)
        {
            Task = task;
        }
    }
}

Côté Page, le ViewModel est injecté et assigné comme BindingContext :

public partial class TaskDetailPage : ContentPage
{
    public TaskDetailPage(TaskDetailViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}

C'est tout. Pas de new, pas de localisation de service manuelle. Le conteneur DI fait le travail pour vous.

Le Pattern Repository pour l'Accès aux Données

Définir l'Abstraction

Le pattern Repository place une interface entre votre logique métier et la source de données. Pourquoi c'est important ? Parce que demain, vous voudrez peut-être remplacer SQLite par une API REST, ou simplement mocker l'accès aux données dans vos tests. Si votre ViewModel appelle directement SQLite, vous êtes coincé.

On commence par définir l'interface :

public interface ITaskRepository
{
    Task<IReadOnlyList<TaskItem>> GetAllAsync();
    Task<TaskItem?> GetByIdAsync(int id);
    Task<IReadOnlyList<TaskItem>> SearchAsync(string query);
    Task<int> AddAsync(TaskItem task);
    Task UpdateAsync(TaskItem task);
    Task DeleteAsync(int id);
    Task<int> GetPendingCountAsync();
}

Cette interface appartient à la couche Domain ou Application de votre architecture. Elle ne référence aucune technologie de persistance — et c'est précisément le but.

Implémentation avec SQLite

L'implémentation concrète utilise sqlite-net-pcl, le package SQLite le plus populaire pour .NET MAUI :

dotnet add package sqlite-net-pcl
dotnet add package SQLitePCLRaw.bundle_green

D'abord, un service de base de données qui gère la connexion :

using SQLite;

public class DatabaseService
{
    private SQLiteAsyncConnection? _database;
    private readonly string _dbPath;

    public DatabaseService()
    {
        _dbPath = Path.Combine(
            FileSystem.AppDataDirectory, "tasks.db3");
    }

    private async Task<SQLiteAsyncConnection> GetConnectionAsync()
    {
        if (_database is not null)
            return _database;

        _database = new SQLiteAsyncConnection(_dbPath,
            SQLiteOpenFlags.Create |
            SQLiteOpenFlags.ReadWrite |
            SQLiteOpenFlags.SharedCache);

        await _database.CreateTableAsync<TaskItem>();

        return _database;
    }

    public async Task<SQLiteAsyncConnection> GetDatabaseAsync()
        => await GetConnectionAsync();
}

Puis l'implémentation du repository :

public class SqliteTaskRepository : ITaskRepository
{
    private readonly DatabaseService _databaseService;

    public SqliteTaskRepository(DatabaseService databaseService)
    {
        _databaseService = databaseService;
    }

    public async Task<IReadOnlyList<TaskItem>> GetAllAsync()
    {
        var db = await _databaseService.GetDatabaseAsync();
        var items = await db.Table<TaskItem>()
            .OrderByDescending(t => t.CreatedAt)
            .ToListAsync();
        return items.AsReadOnly();
    }

    public async Task<TaskItem?> GetByIdAsync(int id)
    {
        var db = await _databaseService.GetDatabaseAsync();
        return await db.Table<TaskItem>()
            .FirstOrDefaultAsync(t => t.Id == id);
    }

    public async Task<IReadOnlyList<TaskItem>> SearchAsync(string query)
    {
        var db = await _databaseService.GetDatabaseAsync();
        var items = await db.Table<TaskItem>()
            .Where(t => t.Title.Contains(query)
                      || t.Description.Contains(query))
            .ToListAsync();
        return items.AsReadOnly();
    }

    public async Task<int> AddAsync(TaskItem task)
    {
        var db = await _databaseService.GetDatabaseAsync();
        await db.InsertAsync(task);
        return task.Id;
    }

    public async Task UpdateAsync(TaskItem task)
    {
        var db = await _databaseService.GetDatabaseAsync();
        await db.UpdateAsync(task);
    }

    public async Task DeleteAsync(int id)
    {
        var db = await _databaseService.GetDatabaseAsync();
        await db.DeleteAsync<TaskItem>(id);
    }

    public async Task<int> GetPendingCountAsync()
    {
        var db = await _databaseService.GetDatabaseAsync();
        return await db.Table<TaskItem>()
            .Where(t => !t.IsCompleted)
            .CountAsync();
    }
}

Demain, si vous migrez vers une API REST, vous créez une classe ApiTaskRepository : ITaskRepository et vous changez une seule ligne dans MauiProgram.cs. Aucun ViewModel n'est impacté. C'est toute la puissance de l'abstraction.

Navigation Shell et Routing

Configurer la Shell

La navigation Shell dans .NET MAUI offre un système de routing basé sur des URI, un peu comme ce qu'on trouve dans le web. On définit la structure dans AppShell.xaml :

<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:views="clr-namespace:MonApp.Views"
       x:Class="MonApp.AppShell"
       FlyoutBehavior="Disabled">

    <TabBar>
        <ShellContent Title="Tâches"
                      Icon="icon_tasks.png"
                      ContentTemplate="{DataTemplate views:TaskListPage}"
                      Route="tasks" />
        <ShellContent Title="Paramètres"
                      Icon="icon_settings.png"
                      ContentTemplate="{DataTemplate views:SettingsPage}"
                      Route="settings" />
    </TabBar>
</Shell>

Les pages qui ne font pas partie de la hiérarchie visuelle (comme une page de détail) doivent être enregistrées comme routes dans le code-behind :

public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();

        Routing.RegisterRoute("taskDetail", typeof(TaskDetailPage));
        Routing.RegisterRoute("taskEdit", typeof(TaskEditPage));
    }
}

Navigation avec GoToAsync

La navigation se fait via Shell.Current.GoToAsync en utilisant des URI relatives ou absolues :

// Navigation simple
await Shell.Current.GoToAsync("taskDetail");

// Avec un paramètre dans l'URI
await Shell.Current.GoToAsync($"taskDetail?id={task.Id}");

// Avec plusieurs paramètres
await Shell.Current.GoToAsync(
    $"taskDetail?id={task.Id}&mode=edit");

// Retour en arrière
await Shell.Current.GoToAsync("..");

// Navigation absolue (remet la pile à zéro)
await Shell.Current.GoToAsync("//tasks");

// Passage d'objets complexes via un dictionnaire
var parameters = new Dictionary<string, object>
{
    { "task", selectedTask },
    { "isReadOnly", true }
};
await Shell.Current.GoToAsync("taskDetail", parameters);

Recevoir les Paramètres avec IQueryAttributable

L'interface IQueryAttributable permet au ViewModel de recevoir les paramètres de navigation proprement :

public partial class TaskDetailViewModel : ObservableObject, IQueryAttributable
{
    private readonly ITaskRepository _taskRepository;

    [ObservableProperty]
    private TaskItem _task = new();

    [ObservableProperty]
    private bool _isReadOnly;

    public TaskDetailViewModel(ITaskRepository taskRepository)
    {
        _taskRepository = taskRepository;
    }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        // Paramètres passés via le dictionnaire (objets complexes)
        if (query.TryGetValue("task", out var taskObj) && taskObj is TaskItem task)
        {
            Task = task;
        }
        // Paramètres passés via l'URI (chaînes de caractères)
        else if (query.TryGetValue("id", out var idObj)
                 && int.TryParse(idObj?.ToString(), out int id))
        {
            LoadTaskAsync(id).ConfigureAwait(false);
        }

        if (query.TryGetValue("isReadOnly", out var readOnlyObj))
        {
            IsReadOnly = readOnlyObj is true
                         || (readOnlyObj is string s
                             && bool.TryParse(s, out bool val)
                             && val);
        }
    }

    private async Task LoadTaskAsync(int id)
    {
        var task = await _taskRepository.GetByIdAsync(id);
        if (task is not null)
        {
            Task = task;
        }
    }
}

Encapsuler la Navigation dans un Service

Pour éviter que vos ViewModels dépendent directement de Shell.Current (ce qui rend les tests nettement plus difficiles), on peut encapsuler la navigation dans un service dédié :

public interface INavigationService
{
    Task GoToAsync(string route);
    Task GoToAsync(string route, IDictionary<string, object> parameters);
    Task GoBackAsync();
}

public class ShellNavigationService : INavigationService
{
    public async Task GoToAsync(string route)
    {
        await Shell.Current.GoToAsync(route);
    }

    public async Task GoToAsync(string route, IDictionary<string, object> parameters)
    {
        await Shell.Current.GoToAsync(route, parameters);
    }

    public async Task GoBackAsync()
    {
        await Shell.Current.GoToAsync("..");
    }
}

Ce service est enregistré en Singleton dans MauiProgram.cs et injecté dans les ViewModels. En test, on le remplace par un mock — simple et efficace.

Structurer un Projet .NET MAUI en Clean Architecture

La Philosophie des Couches

La Clean Architecture, popularisée par Robert C. Martin, organise le code en cercles concentriques. La règle fondamentale est la règle de dépendance : les couches internes ne connaissent jamais les couches externes. Le domaine métier est au centre, ignorant tout de l'interface utilisateur ou de la base de données.

Pour une application .NET MAUI, on peut adapter cette architecture avec pragmatisme. Inutile de créer cinq projets pour une application de taille modeste — croyez-moi, j'ai vu des projets sombrer sous le poids de leur propre architecture. Voici une structure en dossiers qui applique les principes sans la complexité inutile :

MonApp/
├── App.xaml / App.xaml.cs
├── AppShell.xaml / AppShell.xaml.cs
├── MauiProgram.cs
│
├── Domain/                          # Couche Domaine
│   ├── Models/
│   │   ├── TaskItem.cs
│   │   ├── Category.cs
│   │   └── UserPreferences.cs
│   ├── Enums/
│   │   ├── TaskPriority.cs
│   │   └── TaskStatus.cs
│   └── Interfaces/
│       ├── ITaskRepository.cs
│       ├── ICategoryRepository.cs
│       └── INotificationService.cs
│
├── Application/                     # Couche Application
│   ├── Services/
│   │   ├── TaskService.cs
│   │   └── SyncService.cs
│   ├── DTOs/
│   │   ├── TaskSummaryDto.cs
│   │   └── CreateTaskDto.cs
│   └── Validators/
│       └── TaskValidator.cs
│
├── Infrastructure/                  # Couche Infrastructure
│   ├── Persistence/
│   │   ├── DatabaseService.cs
│   │   ├── SqliteTaskRepository.cs
│   │   └── SqliteCategoryRepository.cs
│   ├── Services/
│   │   ├── LocalNotificationService.cs
│   │   ├── ConnectivityService.cs
│   │   └── SecureStorageService.cs
│   └── Api/
│       ├── ApiClient.cs
│       └── ApiTaskRepository.cs
│
├── Presentation/                    # Couche Présentation
│   ├── ViewModels/
│   │   ├── TaskListViewModel.cs
│   │   ├── TaskDetailViewModel.cs
│   │   └── SettingsViewModel.cs
│   ├── Views/
│   │   ├── TaskListPage.xaml / .cs
│   │   ├── TaskDetailPage.xaml / .cs
│   │   └── SettingsPage.xaml / .cs
│   ├── Controls/
│   │   ├── TaskCardView.xaml / .cs
│   │   └── PriorityBadge.xaml / .cs
│   └── Converters/
│       ├── BoolToColorConverter.cs
│       └── DateToRelativeStringConverter.cs
│
└── Resources/
    ├── Fonts/
    ├── Images/
    └── Styles/

Le Flux de Dépendances

Le flux de dépendances suit une direction claire :

  1. Domain : ne dépend de rien. Contient les modèles, les enums, et les interfaces. C'est le cœur stable de votre application.
  2. Application : dépend uniquement du Domain. Contient la logique applicative, les services de coordination, et les DTOs.
  3. Infrastructure : dépend du Domain (implémente les interfaces) et éventuellement de l'Application. Contient les implémentations concrètes : SQLite, API HTTP, notifications, etc.
  4. Presentation : dépend du Domain et de l'Application. Contient les ViewModels, les Views, les contrôles personnalisés et les converters.

L'injection de dépendances dans MauiProgram.cs est le seul endroit où toutes les couches se rencontrent — c'est la composition root. C'est là que l'on lie les interfaces aux implémentations concrètes.

Un Service Applicatif comme Orchestrateur

Quand un ViewModel a besoin de coordonner plusieurs opérations (créer une tâche, envoyer une notification, synchroniser), on peut utiliser un service applicatif plutôt que de tout entasser dans le ViewModel :

public class TaskService
{
    private readonly ITaskRepository _taskRepository;
    private readonly INotificationService _notificationService;

    public TaskService(
        ITaskRepository taskRepository,
        INotificationService notificationService)
    {
        _taskRepository = taskRepository;
        _notificationService = notificationService;
    }

    public async Task<int> CreateTaskAsync(string title, string description,
        TaskPriority priority)
    {
        var task = new TaskItem
        {
            Title = title,
            Description = description,
            Priority = priority,
            CreatedAt = DateTime.UtcNow
        };

        var id = await _taskRepository.AddAsync(task);

        if (priority == TaskPriority.High)
        {
            await _notificationService.ScheduleReminderAsync(
                task.Title, TimeSpan.FromHours(1));
        }

        return id;
    }

    public async Task<TaskSummaryDto> GetSummaryAsync()
    {
        var all = await _taskRepository.GetAllAsync();
        return new TaskSummaryDto
        {
            TotalCount = all.Count,
            CompletedCount = all.Count(t => t.IsCompleted),
            PendingCount = all.Count(t => !t.IsCompleted),
            OverdueCount = all.Count(t => !t.IsCompleted
                && t.DueDate.HasValue
                && t.DueDate.Value < DateTime.UtcNow)
        };
    }
}

Le ViewModel devient alors beaucoup plus léger :

public partial class TaskListViewModel : ObservableObject
{
    private readonly TaskService _taskService;
    private readonly INavigationService _navigationService;

    [ObservableProperty]
    private TaskSummaryDto? _summary;

    public TaskListViewModel(TaskService taskService,
        INavigationService navigationService)
    {
        _taskService = taskService;
        _navigationService = navigationService;
    }

    [RelayCommand]
    private async Task LoadSummaryAsync()
    {
        Summary = await _taskService.GetSummaryAsync();
    }
}

Pour les Projets Plus Importants

Si votre application grandit significativement, vous pouvez évoluer vers une solution multi-projets :

MonApp.sln
├── MonApp.Domain/           (Class Library)
├── MonApp.Application/      (Class Library → référence Domain)
├── MonApp.Infrastructure/   (Class Library → référence Domain, Application)
└── MonApp.Maui/             (MAUI App → référence tout)

Cette approche impose les dépendances au niveau du compilateur : impossible pour MonApp.Domain de référencer MonApp.Infrastructure puisqu'il n'y a pas de référence de projet. C'est la meilleure garantie architecturale, mais elle a un coût en complexité de maintenance. Pour la plupart des applications MAUI, la structure en dossiers avec un peu de discipline suffit largement.

Tests Unitaires avec l'Architecture Clean

Pourquoi l'Architecture Rend les Tests Possibles

Sans architecture propre, tester un ViewModel qui crée directement une connexion SQLite et appelle Shell.Current est quasiment impossible. Avec notre architecture, chaque dépendance est derrière une interface, et donc facilement remplaçable par un mock.

On utilise xUnit comme framework de test et NSubstitute pour les mocks :

dotnet add package xunit
dotnet add package NSubstitute
dotnet add package FluentAssertions

Tester un ViewModel

Voici un test complet du TaskListViewModel. Regardez comme c'est lisible :

using FluentAssertions;
using NSubstitute;
using Xunit;

public class TaskListViewModelTests
{
    private readonly ITaskRepository _mockRepository;
    private readonly INavigationService _mockNavigation;
    private readonly TaskListViewModel _viewModel;

    public TaskListViewModelTests()
    {
        _mockRepository = Substitute.For<ITaskRepository>();
        _mockNavigation = Substitute.For<INavigationService>();
        _viewModel = new TaskListViewModel(_mockRepository, _mockNavigation);
    }

    [Fact]
    public async Task LoadTasksCommand_ShouldPopulateTasks()
    {
        // Arrange
        var fakeTasks = new List<TaskItem>
        {
            new() { Id = 1, Title = "Tâche 1", IsCompleted = false },
            new() { Id = 2, Title = "Tâche 2", IsCompleted = true },
            new() { Id = 3, Title = "Tâche 3", IsCompleted = false }
        };
        _mockRepository.GetAllAsync()
            .Returns(fakeTasks.AsReadOnly());

        // Act
        await _viewModel.LoadTasksCommand.ExecuteAsync(null);

        // Assert
        _viewModel.Tasks.Should().HaveCount(3);
        _viewModel.Tasks[0].Title.Should().Be("Tâche 1");
        _viewModel.IsLoading.Should().BeFalse();
    }

    [Fact]
    public async Task LoadTasksCommand_ShouldSetIsLoadingDuringExecution()
    {
        // Arrange
        var tcs = new TaskCompletionSource<IReadOnlyList<TaskItem>>();
        _mockRepository.GetAllAsync().Returns(tcs.Task);

        // Act
        var loadTask = _viewModel.LoadTasksCommand.ExecuteAsync(null);

        // Assert - pendant le chargement
        _viewModel.IsLoading.Should().BeTrue();

        // Compléter la tâche
        tcs.SetResult(new List<TaskItem>().AsReadOnly());
        await loadTask;

        // Assert - après le chargement
        _viewModel.IsLoading.Should().BeFalse();
    }

    [Fact]
    public async Task DeleteTaskCommand_ShouldRemoveTaskFromCollection()
    {
        // Arrange
        var task = new TaskItem { Id = 1, Title = "À supprimer" };
        _viewModel.Tasks.Add(task);
        _mockRepository.DeleteAsync(1).Returns(Task.CompletedTask);

        // Act
        await _viewModel.DeleteTaskCommand.ExecuteAsync(task);

        // Assert
        await _mockRepository.Received(1).DeleteAsync(1);
    }

    [Fact]
    public async Task NavigateToDetailCommand_ShouldNavigateWithCorrectRoute()
    {
        // Arrange
        var task = new TaskItem { Id = 42, Title = "Ma tâche" };

        // Act
        await _viewModel.NavigateToDetailCommand.ExecuteAsync(task);

        // Assert
        await _mockNavigation.Received(1)
            .GoToAsync($"taskDetail?id=42");
    }
}

Tester un Service Applicatif

public class TaskServiceTests
{
    private readonly ITaskRepository _mockRepo;
    private readonly INotificationService _mockNotification;
    private readonly TaskService _service;

    public TaskServiceTests()
    {
        _mockRepo = Substitute.For<ITaskRepository>();
        _mockNotification = Substitute.For<INotificationService>();
        _service = new TaskService(_mockRepo, _mockNotification);
    }

    [Fact]
    public async Task CreateTask_WithHighPriority_ShouldScheduleReminder()
    {
        // Arrange
        _mockRepo.AddAsync(Arg.Any<TaskItem>())
            .Returns(1);

        // Act
        var id = await _service.CreateTaskAsync(
            "Urgent", "Détails", TaskPriority.High);

        // Assert
        id.Should().Be(1);
        await _mockNotification.Received(1)
            .ScheduleReminderAsync("Urgent", TimeSpan.FromHours(1));
    }

    [Fact]
    public async Task CreateTask_WithNormalPriority_ShouldNotScheduleReminder()
    {
        // Arrange
        _mockRepo.AddAsync(Arg.Any<TaskItem>())
            .Returns(1);

        // Act
        await _service.CreateTaskAsync(
            "Normal", "Détails", TaskPriority.Normal);

        // Assert
        await _mockNotification.DidNotReceive()
            .ScheduleReminderAsync(Arg.Any<string>(), Arg.Any<TimeSpan>());
    }

    [Fact]
    public async Task GetSummary_ShouldReturnCorrectCounts()
    {
        // Arrange
        var tasks = new List<TaskItem>
        {
            new() { IsCompleted = true },
            new() { IsCompleted = false },
            new() { IsCompleted = false,
                     DueDate = DateTime.UtcNow.AddDays(-1) },
        };
        _mockRepo.GetAllAsync().Returns(tasks.AsReadOnly());

        // Act
        var summary = await _service.GetSummaryAsync();

        // Assert
        summary.TotalCount.Should().Be(3);
        summary.CompletedCount.Should().Be(1);
        summary.PendingCount.Should().Be(2);
        summary.OverdueCount.Should().Be(1);
    }
}

Remarquez comme les tests sont lisibles et focalisés. Chaque test vérifie un seul comportement. On n'a besoin ni d'un appareil physique, ni d'un émulateur, ni d'une base de données. C'est vraiment la force d'une architecture bien découplée.

Abstraire les Dialogues pour la Testabilité

Un point qu'on oublie souvent : les appels à Shell.Current.DisplayAlert dans les ViewModels rendent les tests impossibles à exécuter hors d'un contexte MAUI. La solution ? Abstraire les dialogues derrière une interface :

public interface IDialogService
{
    Task<bool> ConfirmAsync(string title, string message,
        string accept, string cancel);
    Task AlertAsync(string title, string message, string cancel);
}

public class MauiDialogService : IDialogService
{
    public async Task<bool> ConfirmAsync(string title, string message,
        string accept, string cancel)
    {
        return await Shell.Current.DisplayAlert(title, message, accept, cancel);
    }

    public async Task AlertAsync(string title, string message, string cancel)
    {
        await Shell.Current.DisplayAlert(title, message, cancel);
    }
}

Enregistrez-le en Singleton dans MauiProgram.cs, et vos ViewModels deviennent entièrement testables.

Anti-Patterns à Éviter

Après plusieurs années à travailler sur des projets .NET MAUI, voici les erreurs les plus fréquentes que je rencontre. Apprenez de ces erreurs plutôt que de les reproduire — ça vous épargnera pas mal de maux de tête.

1. Le God ViewModel

Un ViewModel qui fait tout : chargement des données, validation, navigation, formatage d'affichage, gestion des dialogues, appels réseau directs. On finit avec des fichiers de 800 lignes impossibles à tester et à maintenir. J'en ai vu un de 2000 lignes une fois. Ce n'était pas beau à voir.

Solution : un ViewModel ne devrait avoir qu'une seule responsabilité — orchestrer les interactions entre la View et les services. Déléguez la logique métier aux services et repositories.

2. Le Code-Behind Surchargé

// NE FAITES PAS ÇA
public partial class TaskListPage : ContentPage
{
    private readonly SqliteConnection _db;

    public TaskListPage()
    {
        InitializeComponent();
        _db = new SqliteConnection("...");
    }

    private async void OnAddClicked(object sender, EventArgs e)
    {
        var title = titleEntry.Text;
        if (string.IsNullOrEmpty(title))
        {
            await DisplayAlert("Erreur", "Le titre est requis", "OK");
            return;
        }
        await _db.InsertAsync(new TaskItem { Title = title });
        await LoadTasks();
    }

    private async Task LoadTasks()
    {
        var tasks = await _db.Table<TaskItem>().ToListAsync();
        tasksList.ItemsSource = tasks;
    }
}

Ce code mélange tout : accès aux données, validation, logique UI. Impossible à tester, impossible à réutiliser. Le code-behind ne devrait contenir que de la logique purement visuelle — animations, focus, gestes — qui ne peut pas être gérée par le data binding.

3. Le Couplage Fort entre les Couches

// NE FAITES PAS ÇA
public class TaskListViewModel
{
    // Dépendance directe sur l'implémentation concrète
    private readonly SqliteTaskRepository _repo = new();

    // Dépendance directe sur le framework MAUI
    public async Task Delete(TaskItem task)
    {
        await Shell.Current.DisplayAlert("Confirmer", "Sûr ?", "Oui", "Non");
        await _repo.DeleteAsync(task.Id);
        await Shell.Current.GoToAsync("..");
    }
}

Solution : dépendez toujours des abstractions (interfaces), jamais des implémentations concrètes. Utilisez l'injection de constructeur systématiquement.

4. Les Propriétés sans Notification

// NE FAITES PAS ÇA
public class TaskViewModel
{
    // La View ne sera jamais notifiée des changements
    public string Title { get; set; }
    public bool IsLoading { get; set; }
}

Utilisez [ObservableProperty] du CommunityToolkit ou implémentez INotifyPropertyChanged manuellement. Sans notification, le data binding ne fonctionne que dans un seul sens (initialisation), ce qui crée des bugs subtils et vraiment frustrants à déboguer.

5. Les Appels Asynchrones Fire-and-Forget

// DANGEREUX
public TaskListViewModel()
{
    // Si ça plante, l'exception est avalée silencieusement
    LoadTasks();
}

private async void LoadTasks()
{
    var tasks = await _repo.GetAllAsync();
    Tasks = new ObservableCollection<TaskItem>(tasks);
}

Solution : utilisez [RelayCommand] avec des méthodes async Task et déclenchez le chargement depuis la View via l'événement Appearing :

// Dans la Page
protected override void OnAppearing()
{
    base.OnAppearing();
    if (BindingContext is TaskListViewModel vm)
    {
        vm.LoadTasksCommand.Execute(null);
    }
}

6. Ignorer les Durées de Vie du Conteneur DI

Enregistrer un ViewModel en Singleton qui dépend d'un service Transient est un bug classique (et sournois). Le ViewModel gardera toujours la même instance du service, même si celui-ci a été conçu pour être recréé à chaque utilisation. Respectez la règle : un Singleton ne doit jamais dépendre d'un Transient.

Bonus : Gestion de l'État Global avec Messenger

Le CommunityToolkit.Mvvm inclut un système de messagerie (WeakReferenceMessenger) qui permet à des ViewModels de communiquer sans se connaître directement. C'est particulièrement utile quand une action dans un écran doit déclencher une mise à jour dans un autre :

// Définir un message
public class TaskCreatedMessage : ValueChangedMessage<TaskItem>
{
    public TaskCreatedMessage(TaskItem task) : base(task) { }
}

// Envoyer depuis TaskDetailViewModel
[RelayCommand]
private async Task SaveAsync()
{
    var id = await _taskRepository.AddAsync(Task);
    Task.Id = id;
    WeakReferenceMessenger.Default.Send(new TaskCreatedMessage(Task));
    await _navigationService.GoBackAsync();
}

// Recevoir dans TaskListViewModel
public TaskListViewModel(ITaskRepository taskRepository)
{
    _taskRepository = taskRepository;

    WeakReferenceMessenger.Default
        .Register<TaskCreatedMessage>(this, (recipient, message) =>
    {
        var vm = (TaskListViewModel)recipient;
        vm.Tasks.Insert(0, message.Value);
    });
}

Le WeakReferenceMessenger utilise des références faibles, ce qui évite les fuites mémoire si vous oubliez de vous désenregistrer. Cela dit, ça reste une bonne pratique de nettoyer explicitement.

Conclusion et Recommandations

Construire une application .NET MAUI bien architecturée, ce n'est pas un exercice académique. C'est un investissement pragmatique qui paie dès que votre application dépasse quelques écrans. Récapitulons les points essentiels :

  • MVVM est votre fondation. Il sépare les responsabilités et rend votre code testable. Avec le CommunityToolkit.Mvvm 8.x, le coût d'implémentation est quasiment nul grâce aux générateurs de source.
  • L'injection de dépendances est intégrée à .NET MAUI et ne demande aucune configuration externe. Utilisez-la systématiquement. Pas de new pour les services et les ViewModels.
  • Le pattern Repository abstrait votre accès aux données. Aujourd'hui c'est SQLite, demain une API REST — vos ViewModels n'en sauront rien.
  • La navigation Shell offre un système de routing puissant. Combinez-la avec IQueryAttributable et un service de navigation abstrait pour garder vos ViewModels propres et testables.
  • La Clean Architecture en couches (Domain, Application, Infrastructure, Presentation) organise votre code selon le principe de dépendance. Commencez par des dossiers, évoluez vers des projets séparés si nécessaire.
  • Les tests unitaires deviennent naturels quand votre architecture est découplée. Testez vos ViewModels et vos services sans émulateur ni appareil physique.

Mon conseil le plus important : soyez pragmatique. N'appliquez pas la Clean Architecture à une application de deux écrans. Commencez avec MVVM et l'injection de dépendances, puis structurez en couches quand la complexité le justifie. L'architecture doit servir votre projet, pas l'inverse.

Et surtout, ne cherchez pas la perfection dès le début. Une architecture qui évolue avec votre application sera toujours meilleure qu'une architecture "parfaite" qui paralyse votre développement. Commencez simple, itérez, et refactorisez quand le besoin s'en fait sentir. Le code maintenable n'est pas celui qui est le plus élégant — c'est celui que votre équipe comprend et peut faire évoluer avec confiance.

À propos de l'auteur Editorial Team

Our team of expert writers and editors.