Perché l'Architettura Conta: MVVM e Dependency Injection in .NET MAUI
Costruire un'app mobile che funziona è relativamente facile. Costruire un'app che si mantiene, si testa e si evolve nel tempo — beh, quello è tutto un altro discorso. Ed è proprio per questo che padroneggiare il pattern MVVM insieme alla Dependency Injection in .NET MAUI non è roba da puristi dell'architettura: è una necessità pratica per qualsiasi progetto che ambisca a durare più di qualche mese.
In questa guida esploreremo come strutturare un'applicazione .NET MAUI moderna usando il pattern Model-View-ViewModel, il sistema di Dependency Injection integrato nel framework, e il potentissimo MVVM Toolkit della .NET Community. Vedremo codice reale, pattern concreti e strategie testate sul campo — dalla configurazione iniziale fino al testing dei ViewModel.
Se avete già letto la nostra guida sulle novità di .NET MAUI 10 (prestazioni, NativeAOT e diagnostica), considerate questo articolo come il complemento naturale: là ci siamo occupati del "motore", qui ci occupiamo del "telaio" — l'architettura che tiene tutto insieme.
Il Pattern MVVM Spiegato Senza Giri di Parole
Il Model-View-ViewModel è il pattern architetturale raccomandato per le applicazioni .NET MAUI, e per buoni motivi. Non è certo un pattern nuovo — esiste da quando Microsoft ha introdotto il data binding in WPF — ma rimane il più efficace per separare la logica di presentazione dall'interfaccia utente. A dirla tutta, dopo averlo usato su diversi progetti, è difficile immaginare di tornare indietro.
I Tre Componenti
Ricapitoliamo rapidamente i ruoli:
- Model: rappresenta i dati e la logica di business. Classi come
Utente,Ordine,Prodotto— niente di legato all'interfaccia grafica - View: è l'interfaccia utente, scritta in XAML (o in C# se preferite). Non contiene logica di business, solo presentazione
- ViewModel: è il "collante" tra i due. Espone dati e comandi che la View può consumare tramite data binding, senza sapere nulla della View stessa
Perché Non Mettere Tutto nel Code-Behind?
Domanda legittima, e onestamente l'abbiamo pensato tutti almeno una volta. Scrivere tutto nel code-behind della pagina (il file .xaml.cs) funziona per prototipi e app semplicissime. Ma appena il progetto cresce, i problemi si moltiplicano in fretta:
- Testabilità zero: non potete testare la logica senza avviare l'intera UI
- Riuso impossibile: la logica è legata a doppio filo con una specifica pagina
- Manutenzione da incubo: file da centinaia di righe dove tutto è mischiato
- Collaborazione difficile: designer e sviluppatori si pestano i piedi a vicenda
Con MVVM, il ViewModel è una classe C# normale, testabile in isolamento, riutilizzabile e manutenibile. Punto.
Dependency Injection in .NET MAUI: Le Basi
.NET MAUI integra nativamente Microsoft.Extensions.DependencyInjection, lo stesso sistema usato in ASP.NET Core. Tradotto: avete a disposizione un container IoC completo, senza dover installare pacchetti di terze parti. Una bella comodità, va detto.
Configurazione nel MauiProgram
Tutto parte dal file MauiProgram.cs, il punto di ingresso dell'applicazione:
using Microsoft.Extensions.Logging;
namespace MiaApp;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Registrazione dei servizi
builder.Services.AddSingleton<IApiService, ApiService>();
builder.Services.AddSingleton<IDatabaseService, DatabaseService>();
builder.Services.AddSingleton<INavigationService, NavigationService>();
// Registrazione dei ViewModel
builder.Services.AddTransient<HomeViewModel>();
builder.Services.AddTransient<DettaglioViewModel>();
builder.Services.AddTransient<ProfiloViewModel>();
// Registrazione delle pagine
builder.Services.AddTransient<HomePage>();
builder.Services.AddTransient<DettaglioPage>();
builder.Services.AddTransient<ProfiloPage>();
#if DEBUG
builder.Logging.AddDebug();
#endif
return builder.Build();
}
}
Singleton, Transient o Scoped?
La scelta del ciclo di vita è fondamentale e, credetemi, è spesso fonte di bug sottili che vi faranno impazzire:
- AddSingleton: una sola istanza per tutta la vita dell'app. Usatelo per servizi senza stato (HTTP client, configurazioni) o con stato condiviso (cache, sessione utente)
- AddTransient: una nuova istanza ogni volta che viene richiesta. Perfetto per ViewModel e pagine — ogni navigazione ottiene un'istanza fresca
- AddScoped: una sola istanza per "scope". In .NET MAUI è meno utilizzato rispetto ad ASP.NET Core, ma può tornare utile con scoping personalizzato
Un errore classico? Registrare un ViewModel come Singleton quando dovrebbe essere Transient. Se un utente naviga avanti e indietro, un ViewModel Singleton mantiene lo stato della visita precedente — e raramente è il comportamento desiderato. Ci siamo passati tutti.
Constructor Injection in Azione
Una volta registrati i servizi, l'iniezione avviene automaticamente tramite il costruttore. Ecco com'è semplice:
public class HomeViewModel
{
private readonly IApiService _apiService;
private readonly INavigationService _navigationService;
public HomeViewModel(IApiService apiService, INavigationService navigationService)
{
_apiService = apiService;
_navigationService = navigationService;
}
// La logica del ViewModel usa i servizi iniettati
public async Task CaricaDatiAsync()
{
var prodotti = await _apiService.GetProdottiAsync();
// Aggiorna la UI...
}
}
E nella pagina, il ViewModel viene iniettato allo stesso modo:
public partial class HomePage : ContentPage
{
public HomePage(HomeViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}
Nessun new, nessun accoppiamento diretto. Il container si occupa di risolvere tutto il grafo delle dipendenze per voi.
MVVM Toolkit: Source Generator per Eliminare il Boilerplate
Ok, arriviamo alla parte che preferisco. Scrivere ViewModel seguendo il pattern MVVM "classico" richiede una quantità francamente imbarazzante di codice ripetitivo: proprietà con notifica, comandi, validazione. Il pacchetto CommunityToolkit.Mvvm (versione 8.4) risolve questo problema in modo elegante grazie ai source generator.
Installazione
Aggiungete il pacchetto NuGet al progetto:
dotnet add package CommunityToolkit.Mvvm
ObservableProperty: Proprietà con Notifica Automatica
Prima del toolkit, una singola proprietà con notifica richiedeva tutto questo codice (e sì, fa un po' male vederlo):
public class ProdottoViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private string _nome = string.Empty;
public string Nome
{
get => _nome;
set
{
if (_nome != value)
{
_nome = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Nome)));
}
}
}
private decimal _prezzo;
public decimal Prezzo
{
get => _prezzo;
set
{
if (_prezzo != value)
{
_prezzo = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Prezzo)));
}
}
}
}
Con il MVVM Toolkit, lo stesso risultato si ottiene così:
using CommunityToolkit.Mvvm.ComponentModel;
public partial class ProdottoViewModel : ObservableObject
{
[ObservableProperty]
private string _nome = string.Empty;
[ObservableProperty]
private decimal _prezzo;
}
Il source generator crea automaticamente le proprietà pubbliche Nome e Prezzo con tutta la logica di notifica. Due righe invece di venti — non è fantastico? E a partire dalla versione 8.4, potete anche sfruttare le partial properties di C# 13:
public partial class ProdottoViewModel : ObservableObject
{
[ObservableProperty]
public partial string Nome { get; set; }
[ObservableProperty]
public partial decimal Prezzo { get; set; }
}
Questa sintassi è ancora più naturale e permette di specificare modificatori di accesso personalizzati per ciascun accessor.
Proprietà Dipendenti con NotifyPropertyChangedFor
Spesso una proprietà dipende da un'altra. Per esempio, il prezzo totale che dipende da quantità e prezzo unitario:
public partial class OrdineViewModel : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PrezzoTotale))]
private int _quantita;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PrezzoTotale))]
private decimal _prezzoUnitario;
public decimal PrezzoTotale => Quantita * PrezzoUnitario;
}
Ogni volta che Quantita o PrezzoUnitario cambiano, viene automaticamente notificata anche la proprietà PrezzoTotale. Zero codice manuale.
RelayCommand: Comandi Senza Fatica
I comandi sono l'altro grande pilastro dell'MVVM. L'attributo [RelayCommand] trasforma qualsiasi metodo in un comando bindabile, ed è qui che il toolkit brilla davvero:
public partial class ListaProdottiViewModel : ObservableObject
{
private readonly IApiService _apiService;
private readonly INavigationService _navigationService;
[ObservableProperty]
private ObservableCollection<Prodotto> _prodotti = new();
[ObservableProperty]
private bool _isCaricamento;
public ListaProdottiViewModel(IApiService apiService, INavigationService navigationService)
{
_apiService = apiService;
_navigationService = navigationService;
}
[RelayCommand]
private async Task CaricaProdottiAsync()
{
try
{
IsCaricamento = true;
var risultati = await _apiService.GetProdottiAsync();
Prodotti = new ObservableCollection<Prodotto>(risultati);
}
finally
{
IsCaricamento = false;
}
}
[RelayCommand]
private async Task ApriDettaglioAsync(Prodotto prodotto)
{
await _navigationService.NavigaAAsync<DettaglioViewModel>(prodotto);
}
}
Il source generator crea automaticamente le proprietà CaricaProdottiCommand e ApriDettaglioCommand che potete bindare nello XAML:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MiaApp.Views.ListaProdottiPage">
<RefreshView IsRefreshing="{Binding IsCaricamento}"
Command="{Binding CaricaProdottiCommand}">
<CollectionView ItemsSource="{Binding Prodotti}">
<CollectionView.ItemTemplate>
<DataTemplate>
<SwipeView>
<Grid Padding="10">
<Grid.GestureRecognizers>
<TapGestureRecognizer
Command="{Binding Source={RelativeSource
AncestorType={x:Type viewmodels:ListaProdottiViewModel}},
Path=ApriDettaglioCommand}"
CommandParameter="{Binding .}" />
</Grid.GestureRecognizers>
<Label Text="{Binding Nome}" FontSize="18" />
<Label Text="{Binding Prezzo, StringFormat='{0:C2}'}"
Grid.Column="1"
HorizontalOptions="End" />
</Grid>
</SwipeView>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</RefreshView>
</ContentPage>
AsyncRelayCommand e Gestione della Concorrenza
Per i comandi asincroni, il toolkit gestisce automaticamente scenari comuni come la disabilitazione del pulsante durante l'esecuzione e la cancellazione. Vediamo un esempio pratico:
[RelayCommand(IncludeCancelCommand = true)]
private async Task ScaricaFileAsync(string url, CancellationToken token)
{
IsDownloading = true;
try
{
var dati = await _downloadService.ScaricaAsync(url, token);
await _fileService.SalvaAsync(dati);
}
catch (OperationCanceledException)
{
// L'utente ha annullato il download
await Shell.Current.DisplayAlert("Annullato",
"Download annullato dall'utente.", "OK");
}
finally
{
IsDownloading = false;
}
}
Con IncludeCancelCommand = true, il generator crea anche un ScaricaFileCancelCommand che potete bindare a un pulsante "Annulla". Piccolo dettaglio, grande impatto sull'esperienza utente.
Shell Navigation con Dependency Injection
La navigazione è, onestamente, uno degli aspetti più delicati dell'architettura MVVM. In .NET MAUI, Shell offre un sistema di routing basato su URI che si integra piuttosto bene con la Dependency Injection.
Registrazione delle Rotte
Le rotte vengono registrate nell'AppShell:
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
// Registra le rotte per la navigazione
Routing.RegisterRoute(nameof(DettaglioPage), typeof(DettaglioPage));
Routing.RegisterRoute(nameof(ModificaPage), typeof(ModificaPage));
Routing.RegisterRoute(nameof(ImpostazioniPage), typeof(ImpostazioniPage));
}
}
Quando Shell naviga verso una pagina registrata, il container DI crea automaticamente l'istanza della pagina e inietta tutte le dipendenze nel costruttore — incluso il ViewModel, se registrato. Niente di cui preoccuparsi.
Passaggio di Parametri con QueryProperty
Per passare dati tra pagine, Shell supporta i query parameters. Nel ViewModel destinazione, usate l'attributo [QueryProperty] oppure implementate IQueryAttributable:
[QueryProperty(nameof(ProdottoId), "id")]
public partial class DettaglioViewModel : ObservableObject
{
private readonly IApiService _apiService;
[ObservableProperty]
private string _prodottoId = string.Empty;
[ObservableProperty]
private Prodotto? _prodotto;
[ObservableProperty]
private bool _isCaricamento;
public DettaglioViewModel(IApiService apiService)
{
_apiService = apiService;
}
partial void OnProdottoIdChanged(string value)
{
// Chiamato automaticamente quando ProdottoId viene impostato dalla navigazione
Task.Run(() => CaricaDettaglioAsync(value));
}
private async Task CaricaDettaglioAsync(string id)
{
IsCaricamento = true;
try
{
Prodotto = await _apiService.GetProdottoAsync(id);
}
finally
{
IsCaricamento = false;
}
}
}
E la navigazione dal ViewModel chiamante è altrettanto diretta:
[RelayCommand]
private async Task ApriDettaglioAsync(Prodotto prodotto)
{
await Shell.Current.GoToAsync($"{nameof(DettaglioPage)}?id={prodotto.Id}");
}
Navigazione con Oggetti Complessi
Per passare oggetti complessi (non solo stringhe), potete usare un dizionario di parametri:
[RelayCommand]
private async Task ApriModificaAsync(Prodotto prodotto)
{
var parametri = new Dictionary<string, object>
{
{ "prodotto", prodotto }
};
await Shell.Current.GoToAsync(nameof(ModificaPage), parametri);
}
E nel ViewModel di destinazione, implementate IQueryAttributable:
public partial class ModificaViewModel : ObservableObject, IQueryAttributable
{
[ObservableProperty]
private Prodotto? _prodotto;
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("prodotto", out var obj) && obj is Prodotto p)
{
Prodotto = p;
}
}
}
Creare un Servizio di Navigazione Testabile
Usare Shell.Current.GoToAsync direttamente nel ViewModel funziona, ma introduce un accoppiamento con il framework che rende il testing decisamente più complicato. La soluzione? Creare un servizio di navigazione astratto.
L'Interfaccia
public interface INavigationService
{
Task NavigaAAsync(string route);
Task NavigaAAsync(string route, IDictionary<string, object> parametri);
Task TornaIndietroAsync();
Task TornaAllaRadiceAsync();
}
L'Implementazione
public class ShellNavigationService : INavigationService
{
public async Task NavigaAAsync(string route)
{
await Shell.Current.GoToAsync(route);
}
public async Task NavigaAAsync(string route, IDictionary<string, object> parametri)
{
await Shell.Current.GoToAsync(route, parametri);
}
public async Task TornaIndietroAsync()
{
await Shell.Current.GoToAsync("..");
}
public async Task TornaAllaRadiceAsync()
{
await Shell.Current.GoToAsync("//");
}
}
Registrazione
builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
Ora i ViewModel dipendono solo dall'interfaccia INavigationService, che potete facilmente mockare nei test. Nessun riferimento diretto a Shell — e i vostri test vi ringrazieranno.
WeakReferenceMessenger: Comunicazione Disaccoppiata
A volte i ViewModel devono comunicare tra loro senza conoscersi direttamente. È un problema classico, e il MVVM Toolkit lo risolve con un sistema di messaggistica basato su riferimenti deboli.
Definire un Messaggio
using CommunityToolkit.Mvvm.Messaging.Messages;
// Messaggio per notificare che un prodotto è stato aggiornato
public class ProdottoAggiornatoMessage : ValueChangedMessage<Prodotto>
{
public ProdottoAggiornatoMessage(Prodotto prodotto) : base(prodotto) { }
}
// Messaggio per notificare il logout dell'utente
public class LogoutMessage { }
Inviare un Messaggio
[RelayCommand]
private async Task SalvaModificheAsync()
{
await _apiService.AggiornaProdottoAsync(Prodotto!);
// Notifica tutti gli interessati che il prodotto è cambiato
WeakReferenceMessenger.Default.Send(new ProdottoAggiornatoMessage(Prodotto!));
await _navigationService.TornaIndietroAsync();
}
Ricevere un Messaggio
public partial class ListaProdottiViewModel : ObservableObject,
IRecipient<ProdottoAggiornatoMessage>
{
public ListaProdottiViewModel(IApiService apiService)
{
_apiService = apiService;
// Registra questo ViewModel per ricevere messaggi
WeakReferenceMessenger.Default.Register(this);
}
public void Receive(ProdottoAggiornatoMessage message)
{
var prodottoAggiornato = message.Value;
var esistente = Prodotti.FirstOrDefault(p => p.Id == prodottoAggiornato.Id);
if (esistente != null)
{
var indice = Prodotti.IndexOf(esistente);
Prodotti[indice] = prodottoAggiornato;
}
}
}
I riferimenti deboli garantiscono che il sistema di messaggistica non impedisca al garbage collector di liberare i ViewModel quando non sono più in uso. In parole povere: niente memory leak.
Validazione dei Dati con ObservableValidator
Il MVVM Toolkit include anche un sistema di validazione integrato basato su Data Annotations. Se avete mai lavorato con ASP.NET, vi sentirete subito a casa. Ecco come usarlo in un form di registrazione:
using System.ComponentModel.DataAnnotations;
using CommunityToolkit.Mvvm.ComponentModel;
public partial class RegistrazioneViewModel : ObservableValidator
{
[ObservableProperty]
[NotifyDataErrorInfo]
[Required(ErrorMessage = "Il nome è obbligatorio")]
[MinLength(2, ErrorMessage = "Il nome deve avere almeno 2 caratteri")]
private string _nome = string.Empty;
[ObservableProperty]
[NotifyDataErrorInfo]
[Required(ErrorMessage = "L'email è obbligatoria")]
[EmailAddress(ErrorMessage = "Inserisci un indirizzo email valido")]
private string _email = string.Empty;
[ObservableProperty]
[NotifyDataErrorInfo]
[Required(ErrorMessage = "La password è obbligatoria")]
[MinLength(8, ErrorMessage = "La password deve avere almeno 8 caratteri")]
private string _password = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RegistratiCommand))]
private bool _hasAccettatoTermini;
private bool PuoRegistrarsi() => HasAccettatoTermini && !HasErrors;
[RelayCommand(CanExecute = nameof(PuoRegistrarsi))]
private async Task RegistratiAsync()
{
ValidateAllProperties();
if (HasErrors)
return;
// Procedi con la registrazione
await _authService.RegistraAsync(Nome, Email, Password);
}
}
Nello XAML, potete mostrare gli errori di validazione collegandovi alla proprietà Errors:
<VerticalStackLayout Spacing="10" Padding="20">
<Entry Placeholder="Nome"
Text="{Binding Nome}" />
<Entry Placeholder="Email"
Text="{Binding Email}"
Keyboard="Email" />
<Entry Placeholder="Password"
Text="{Binding Password}"
IsPassword="True" />
<HorizontalStackLayout Spacing="8">
<CheckBox IsChecked="{Binding HasAccettatoTermini}" />
<Label Text="Accetto i termini e condizioni"
VerticalOptions="Center" />
</HorizontalStackLayout>
<Button Text="Registrati"
Command="{Binding RegistratiCommand}" />
</VerticalStackLayout>
Unit Testing dei ViewModel: La Parte che Fa la Differenza
Ecco dove l'architettura MVVM con DI mostra il suo vero valore. Grazie alla separazione tra logica e UI, potete testare i ViewModel come qualsiasi classe C# — senza avviare l'app, senza emulatori, senza simulatori. Solo codice puro.
Setup del Progetto di Test
Create un progetto xUnit e aggiungete i pacchetti necessari:
dotnet new xunit -n MiaApp.Tests
dotnet add MiaApp.Tests package Moq
dotnet add MiaApp.Tests package FluentAssertions
Mock dei Servizi
Usando Moq, create implementazioni mock delle interfacce dei servizi. Ecco un esempio completo di test per il nostro ViewModel della lista prodotti:
using Moq;
using FluentAssertions;
public class ListaProdottiViewModelTests
{
private readonly Mock<IApiService> _mockApiService;
private readonly Mock<INavigationService> _mockNavigationService;
private readonly ListaProdottiViewModel _viewModel;
public ListaProdottiViewModelTests()
{
_mockApiService = new Mock<IApiService>();
_mockNavigationService = new Mock<INavigationService>();
_viewModel = new ListaProdottiViewModel(
_mockApiService.Object,
_mockNavigationService.Object);
}
[Fact]
public async Task CaricaProdotti_QuandoChiamato_CaricaDalServizio()
{
// Arrange
var prodottiAttesi = new List<Prodotto>
{
new() { Id = "1", Nome = "Prodotto A", Prezzo = 29.99m },
new() { Id = "2", Nome = "Prodotto B", Prezzo = 49.99m }
};
_mockApiService
.Setup(s => s.GetProdottiAsync())
.ReturnsAsync(prodottiAttesi);
// Act
await _viewModel.CaricaProdottiCommand.ExecuteAsync(null);
// Assert
_viewModel.Prodotti.Should().HaveCount(2);
_viewModel.Prodotti[0].Nome.Should().Be("Prodotto A");
_viewModel.IsCaricamento.Should().BeFalse();
}
[Fact]
public async Task CaricaProdotti_QuandoFallisce_GestisceErrore()
{
// Arrange
_mockApiService
.Setup(s => s.GetProdottiAsync())
.ThrowsAsync(new HttpRequestException("Errore di rete"));
// Act
var act = () => _viewModel.CaricaProdottiCommand.ExecuteAsync(null);
// Assert
await act.Should().NotThrowAsync();
_viewModel.IsCaricamento.Should().BeFalse();
}
[Fact]
public async Task ApriDettaglio_QuandoChiamato_NavigaConParametri()
{
// Arrange
var prodotto = new Prodotto { Id = "1", Nome = "Test" };
// Act
await _viewModel.ApriDettaglioCommand.ExecuteAsync(prodotto);
// Assert
_mockNavigationService.Verify(
n => n.NavigaAAsync(It.IsAny<string>(), It.IsAny<IDictionary<string, object>>()),
Times.Once);
}
}
Test della Validazione
public class RegistrazioneViewModelTests
{
private readonly RegistrazioneViewModel _viewModel;
public RegistrazioneViewModelTests()
{
_viewModel = new RegistrazioneViewModel(
Mock.Of<IAuthService>());
}
[Theory]
[InlineData("", true)] // Nome vuoto = errore
[InlineData("A", true)] // Nome troppo corto = errore
[InlineData("Mario", false)] // Nome valido = nessun errore
public void Nome_Validazione_FunzionaCorrettamente(string nome, bool deveAvereErrori)
{
// Act
_viewModel.Nome = nome;
_viewModel.ValidateAllProperties();
// Assert
_viewModel.GetErrors(nameof(_viewModel.Nome))
.Cast<ValidationResult>()
.Any()
.Should().Be(deveAvereErrori);
}
[Fact]
public void RegistratiCommand_SenzaTerminiAccettati_NonPuoEseguire()
{
// Arrange
_viewModel.Nome = "Mario Rossi";
_viewModel.Email = "[email protected]";
_viewModel.Password = "password123";
_viewModel.HasAccettatoTermini = false;
// Assert
_viewModel.RegistratiCommand.CanExecute(null).Should().BeFalse();
}
}
Pattern Avanzati: ViewModel Base Riutilizzabile
Se lavorate su progetti con più di una manciata di pagine, vi accorgerete presto che certa logica si ripete ovunque: stato di caricamento, gestione errori, titolo della pagina. Per evitare di duplicare tutto questo, create un ViewModel base:
public abstract partial class BaseViewModel : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NonInCaricamento))]
private bool _isCaricamento;
[ObservableProperty]
private string _titolo = string.Empty;
[ObservableProperty]
private string? _messaggioErrore;
public bool NonInCaricamento => !IsCaricamento;
protected async Task EseguiConCaricamentoAsync(Func<Task> operazione)
{
if (IsCaricamento)
return;
try
{
IsCaricamento = true;
MessaggioErrore = null;
await operazione();
}
catch (HttpRequestException ex)
{
MessaggioErrore = $"Errore di rete: {ex.Message}";
}
catch (Exception ex)
{
MessaggioErrore = $"Errore imprevisto: {ex.Message}";
}
finally
{
IsCaricamento = false;
}
}
}
E nei ViewModel concreti, il codice diventa molto più snello:
public partial class CatalogoViewModel : BaseViewModel
{
private readonly IApiService _apiService;
[ObservableProperty]
private ObservableCollection<Prodotto> _prodotti = new();
public CatalogoViewModel(IApiService apiService)
{
_apiService = apiService;
Titolo = "Catalogo Prodotti";
}
[RelayCommand]
private async Task CaricaCatalogoAsync()
{
await EseguiConCaricamentoAsync(async () =>
{
var risultati = await _apiService.GetProdottiAsync();
Prodotti = new ObservableCollection<Prodotto>(risultati);
});
}
}
Organizzazione del Progetto: Una Struttura che Scala
Con tutti questi pezzi in gioco, l'organizzazione del progetto diventa fondamentale. Ecco una struttura che funziona bene nella pratica (e che ho visto reggere anche su progetti di dimensioni importanti):
MiaApp/
├── MauiProgram.cs
├── App.xaml / App.xaml.cs
├── AppShell.xaml / AppShell.xaml.cs
├── Models/
│ ├── Prodotto.cs
│ ├── Utente.cs
│ └── Ordine.cs
├── ViewModels/
│ ├── BaseViewModel.cs
│ ├── HomeViewModel.cs
│ ├── ListaProdottiViewModel.cs
│ ├── DettaglioViewModel.cs
│ └── ProfiloViewModel.cs
├── Views/
│ ├── HomePage.xaml / HomePage.xaml.cs
│ ├── ListaProdottiPage.xaml / ListaProdottiPage.xaml.cs
│ ├── DettaglioPage.xaml / DettaglioPage.xaml.cs
│ └── ProfiloPage.xaml / ProfiloPage.xaml.cs
├── Services/
│ ├── Interfaces/
│ │ ├── IApiService.cs
│ │ ├── INavigationService.cs
│ │ └── IDatabaseService.cs
│ ├── ApiService.cs
│ ├── ShellNavigationService.cs
│ └── DatabaseService.cs
├── Messages/
│ ├── ProdottoAggiornatoMessage.cs
│ └── LogoutMessage.cs
├── Converters/
│ └── BoolToColorConverter.cs
└── Resources/
├── Fonts/
├── Images/
└── Styles/
Ogni cartella ha una responsabilità chiara. I ViewModel non conoscono le View. I servizi sono dietro interfacce. I messaggi permettono la comunicazione senza accoppiamento. Questa struttura regge bene anche quando il progetto cresce a decine di pagine.
Consigli Pratici e Insidie Comuni
Dopo aver impostato l'architettura, ecco alcune lezioni apprese (a volte a caro prezzo) che vi risparmieranno parecchi grattacapi:
1. Non Iniettate Tutto
Non ogni classe deve passare dal container DI. I Model, i DTO e le classi di utilità statiche non hanno bisogno di injection. Iniettate solo ciò che ha dipendenze esterne o che dovete mockare nei test. Tutto il resto è overengineering.
2. Attenzione ai Cicli di Vita
Un errore subdolo che prima o poi capita a tutti: un servizio Singleton che dipende da un servizio Transient. Il Singleton mantiene un riferimento all'istanza Transient per tutta la vita dell'app, vanificandone completamente il ciclo di vita. E il container .NET non vi avverte di questo (a meno che non abilitiate la validazione degli scope in sviluppo).
// Abilitate la validazione degli scope in Debug
#if DEBUG
builder.Services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true,
ValidateOnBuild = true
});
#endif
3. Usate IAsyncRelayCommand per i Binding Asincroni
Se un comando asincrono è bindato a un Button, il toolkit disabilita automaticamente il pulsante durante l'esecuzione. Ma attenzione: funziona solo se il tipo esposto è IAsyncRelayCommand, non il generico ICommand.
4. Deregistrate i Messaggi
Se usate WeakReferenceMessenger, i riferimenti deboli gestiscono la pulizia automaticamente. Ma se optate per StrongReferenceMessenger (più performante), dovete deregistrare manualmente per evitare memory leak:
// Nel metodo di cleanup del ViewModel
WeakReferenceMessenger.Default.UnregisterAll(this);
5. Preferite i Compiled Bindings
Con l'architettura MVVM ben strutturata, potete sfruttare i compiled bindings per prestazioni migliori e — cosa ancora più utile — errori a tempo di compilazione anziché a runtime:
<ContentPage xmlns:viewmodels="clr-namespace:MiaApp.ViewModels"
x:DataType="viewmodels:ListaProdottiViewModel">
<!-- I binding vengono verificati a tempo di compilazione -->
<Label Text="{Binding Titolo}" />
<CollectionView ItemsSource="{Binding Prodotti}" />
</ContentPage>
Conclusioni: Un'Architettura per il Lungo Termine
L'architettura MVVM con Dependency Injection in .NET MAUI non è un esercizio accademico — è l'approccio che vi permette di mantenere, testare e far evolvere le vostre app nel tempo senza impazzire. E con il MVVM Toolkit e i suoi source generator, il boilerplate che una volta rendeva MVVM tedioso è praticamente scomparso.
Ricapitoliamo i punti chiave:
- Dependency Injection integrata nel framework vi dà un container IoC completo senza dipendenze esterne
- CommunityToolkit.Mvvm elimina il codice ripetitivo con attributi come
[ObservableProperty]e[RelayCommand] - Shell Navigation si integra naturalmente con DI, iniettando le dipendenze durante la navigazione
- WeakReferenceMessenger permette comunicazione tra ViewModel senza accoppiamento
- ObservableValidator gestisce la validazione dei form con Data Annotations standard
- Unit Testing diventa semplice grazie all'inversione delle dipendenze e ai mock
Se state iniziando un nuovo progetto .NET MAUI, investite tempo nell'architettura fin dal primo giorno. Il vostro "io futuro" vi ringrazierà quando dovrete aggiungere la ventesima feature o correggere quel bug in produzione alle 23 di sera.
E se avete già un progetto con codice nel code-behind, non disperate: la migrazione a MVVM può avvenire gradualmente, pagina per pagina, senza dover riscrivere tutto in una volta. Cominciate con una pagina nuova, sperimentate il pattern, e poi espandetelo al resto dell'app quando vi sentite pronti.