MVVM w .NET MAUI — praktyczny przewodnik z CommunityToolkit, DI i Shell Navigation

Praktyczny przewodnik po architekturze MVVM w .NET MAUI. CommunityToolkit.Mvvm 8.4 z partial properties, wstrzykiwanie zależności, nawigacja Shell i testy jednostkowe — wszystko, czego potrzebujesz do budowania profesjonalnych aplikacji mobilnych.

Dlaczego MVVM jest fundamentem profesjonalnych aplikacji .NET MAUI

Wzorzec Model-View-ViewModel (MVVM) to nie chwilowa moda — to sprawdzony fundament, na którym opiera się zdecydowana większość profesjonalnych aplikacji mobilnych tworzonych w .NET MAUI. Jeśli pracujesz nad aplikacją, która ma szansę przeżyć dłużej niż kilka tygodni, MVVM po prostu nie jest opcjonalny.

Szczerze? W 2026 roku ekosystem narzędzi wspierających MVVM w .NET MAUI osiągnął poziom dojrzałości, o którym deweloperzy Xamarin mogliby tylko pomarzyć. CommunityToolkit.Mvvm w wersji 8.4+ z obsługą partial properties, wbudowane wstrzykiwanie zależności wzorowane na ASP.NET Core, Shell Navigation z typowanym routingiem — to solidny arsenał. W tym przewodniku pokażę Ci, jak połączyć to wszystko w spójną architekturę, która faktycznie działa w produkcji.

Czym jest MVVM i dlaczego akurat ten wzorzec?

MVVM rozdziela aplikację na trzy warstwy o ściśle zdefiniowanych odpowiedzialnościach:

  • Model — reprezentuje dane i logikę biznesową. To encje, serwisy, repozytoria — wszystko, co nie ma pojęcia o interfejsie użytkownika.
  • View (Widok) — warstwa prezentacji zdefiniowana w XAML (lub C#). Odpowiada wyłącznie za wyświetlanie danych i przechwytywanie interakcji użytkownika.
  • ViewModel — pośrednik między Modelem a Widokiem. Udostępnia dane i komendy, reaguje na akcje użytkownika, zarządza stanem widoku. Nie zna konkretnego widoku — komunikuje się z nim wyłącznie przez data binding.

Kluczowa zasada brzmi: View zna ViewModel, ViewModel zna Model, ale nigdy odwrotnie. Dzięki temu możesz testować logikę aplikacji bez uruchamiania interfejsu graficznego, wymieniać widoki bez dotykania logiki, a nawet współdzielić ViewModele między różnymi platformami. To naprawdę robi różnicę, gdy projekt zaczyna rosnąć.

Konfiguracja projektu — niezbędne pakiety NuGet

Zanim napiszemy pierwszą linię kodu, musimy zainstalować odpowiednie pakiety. Oto minimum, którego potrzebujesz w projekcie .NET MAUI 10:

dotnet add package CommunityToolkit.Mvvm --version 8.4.0
dotnet add package CommunityToolkit.Maui --version 10.0.0

Pakiet CommunityToolkit.Mvvm dostarcza generatory kodu źródłowego (source generators), bazowe klasy ViewModeli i implementacje komend. CommunityToolkit.Maui dodaje konwertery, zachowania i kontrolki specyficzne dla MAUI. Oba są utrzymywane przez Microsoft w ramach .NET Foundation, więc możesz na nich polegać.

Następnie zarejestruj CommunityToolkit.Maui w pliku MauiProgram.cs:

using CommunityToolkit.Maui;

namespace MojaAplikacja;

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

        // Tutaj będziemy rejestrować serwisy i ViewModele
        ConfigureServices(builder.Services);

        return builder.Build();
    }

    private static void ConfigureServices(IServiceCollection services)
    {
        // Rejestracja zostanie omówiona w dalszej części artykułu
    }
}

ObservableObject — fundament każdego ViewModelu

Każdy ViewModel w architekturze MVVM musi implementować interfejs INotifyPropertyChanged, żeby system data bindingu .NET MAUI wiedział, kiedy odświeżyć widok. Ręczna implementacja tego interfejsu to żmudna, powtarzalna praca — i wierzcie mi, po trzecim ViewModelu zaczyna naprawdę irytować. CommunityToolkit.Mvvm rozwiązuje ten problem klasą bazową ObservableObject.

Najpierw podejście bez toolkitu:

// BEZ CommunityToolkit — dużo boilerplate'u
public class ProduktViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    private string _nazwa = string.Empty;
    public string Nazwa
    {
        get => _nazwa;
        set
        {
            if (_nazwa != value)
            {
                _nazwa = value;
                PropertyChanged?.Invoke(this,
                    new PropertyChangedEventArgs(nameof(Nazwa)));
                PropertyChanged?.Invoke(this,
                    new PropertyChangedEventArgs(nameof(PelnyOpis)));
            }
        }
    }

    private decimal _cena;
    public decimal Cena
    {
        get => _cena;
        set
        {
            if (_cena != value)
            {
                _cena = value;
                PropertyChanged?.Invoke(this,
                    new PropertyChangedEventArgs(nameof(Cena)));
                PropertyChanged?.Invoke(this,
                    new PropertyChangedEventArgs(nameof(PelnyOpis)));
            }
        }
    }

    public string PelnyOpis => $"{Nazwa} — {Cena:C}";
}

A teraz ten sam ViewModel z CommunityToolkit.Mvvm:

using CommunityToolkit.Mvvm.ComponentModel;

namespace MojaAplikacja.ViewModels;

public partial class ProduktViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(PelnyOpis))]
    private string _nazwa = string.Empty;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(PelnyOpis))]
    private decimal _cena;

    public string PelnyOpis => $"{Nazwa} — {Cena:C}";
}

Różnica jest kolosalna. Generator kodu źródłowego automatycznie tworzy publiczne właściwości Nazwa i Cena z pełną obsługą powiadamiania o zmianach. Atrybut [NotifyPropertyChangedFor] dodatkowo informuje, że zmiana tych pól powinna odświeżyć właściwość obliczaną PelnyOpis. Mniej kodu, mniej błędów — trudno się nie cieszyć.

Partial Properties — nowość z CommunityToolkit.Mvvm 8.4

Ok, to jest chyba moja ulubiona funkcja w ostatnich aktualizacjach toolkitu. Wersja 8.4 wprowadziła obsługę partial properties — zamiast deklarować prywatne pole z atrybutem, możesz teraz zdefiniować właściwość bezpośrednio. Jest bardziej czytelnie i lepiej integruje się z językiem C#.

using CommunityToolkit.Mvvm.ComponentModel;

namespace MojaAplikacja.ViewModels;

public partial class UzytkownikViewModel : ObservableObject
{
    // Nowa składnia z partial properties (CommunityToolkit.Mvvm 8.4+)
    [ObservableProperty]
    public partial string Imie { get; set; } = string.Empty;

    [ObservableProperty]
    public partial string Nazwisko { get; set; } = string.Empty;

    [ObservableProperty]
    public partial string Email { get; set; } = string.Empty;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(PelneImie))]
    public partial bool CzyZweryfikowany { get; set; }

    public string PelneImie => $"{Imie} {Nazwisko}";
}

Co zyskujesz w porównaniu z podejściem opartym na polach?

  • Lepsze wsparcie IDE — IntelliSense i refaktoryzacja działają poprawnie, bo widzą właściwość, nie pole.
  • Modyfikatory dostępuprivate set, protected set, internal set i inne działają naturalnie.
  • Spójność z resztą kodu — partial properties to standardowa funkcja C#, nie obejście frameworka.
  • Wsparcie dla modyfikatorówrequired, override, sealed i new działają bez problemów.

Komendy — RelayCommand i AsyncRelayCommand

Komendy to mechanizm, za pomocą którego widok wywołuje akcje w ViewModelu bez bezpośredniego odwoływania się do metod. W MVVM nigdy nie obsługujesz zdarzeń kliknięcia w code-behind — zamiast tego bindujesz komendy. To fundamentalna różnica w podejściu.

CommunityToolkit.Mvvm oferuje atrybut [RelayCommand], który automatycznie generuje komendę na podstawie metody:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace MojaAplikacja.ViewModels;

public partial class KoszykViewModel : ObservableObject
{
    private readonly IKoszykService _koszykService;
    private readonly INavigationService _nawigacja;

    [ObservableProperty]
    private ObservableCollection<ProduktViewModel> _produkty = new();

    [ObservableProperty]
    private decimal _suma;

    [ObservableProperty]
    private bool _czyLadowanie;

    public KoszykViewModel(IKoszykService koszykService,
                           INavigationService nawigacja)
    {
        _koszykService = koszykService;
        _nawigacja = nawigacja;
    }

    [RelayCommand]
    private async Task WczytajProduktyAsync()
    {
        CzyLadowanie = true;
        try
        {
            var produkty = await _koszykService.PobierzProduktyAsync();
            Produkty = new ObservableCollection<ProduktViewModel>(produkty);
            Suma = Produkty.Sum(p => p.Cena);
        }
        finally
        {
            CzyLadowanie = false;
        }
    }

    [RelayCommand]
    private async Task UsunProduktAsync(ProduktViewModel produkt)
    {
        await _koszykService.UsunAsync(produkt.Id);
        Produkty.Remove(produkt);
        Suma = Produkty.Sum(p => p.Cena);
    }

    [RelayCommand(CanExecute = nameof(MoznaZlozyc))]
    private async Task ZlozZamowienieAsync()
    {
        await _koszykService.ZlozZamowienieAsync(Produkty);
        await _nawigacja.PrzejdzDoAsync("potwierdzenie");
    }

    private bool MoznaZlozyc() => Produkty.Count > 0;
}

Generator kodu tworzy właściwości komend: WczytajProduktyCommand, UsunProduktCommand i ZlozZamowienieCommand. Kilka rzeczy, na które warto zwrócić uwagę:

  • AsyncRelayCommand domyślnie blokuje jednoczesne wykonanie — chroni przed wielokrotnym kliknięciem (co zdarza się częściej, niż byśmy chcieli).
  • Parametr CanExecute wskazuje metodę, która określa, czy komenda jest dostępna. Powiązany przycisk automatycznie się dezaktywuje, gdy warunek nie jest spełniony.
  • Sufiks Async jest automatycznie usuwany z nazwy komendy — metoda WczytajProduktyAsync generuje komendę WczytajProduktyCommand.

Wstrzykiwanie zależności (Dependency Injection) w .NET MAUI

.NET MAUI ma wbudowany kontener IoC (Inversion of Control), który działa identycznie jak w ASP.NET Core. Jeśli pracowałeś wcześniej z ASP.NET Core, poczujesz się jak w domu. Rejestracja serwisów odbywa się w MauiProgram.cs za pomocą metod AddSingleton, AddTransient i AddScoped.

Czasy życia serwisów — kiedy co wybrać

Poprawny wybór czasu życia serwisu to jedna z tych decyzji, które na początku wydają się nieistotne, a potem potrafią napsuć sporo krwi:

  • Singleton — jedna instancja na całą aplikację. Używaj dla serwisów HTTP, cache'u, konfiguracji, loggerów. Uwaga na bezpieczeństwo wątkowe!
  • Transient — nowa instancja przy każdym żądaniu. Idealny dla ViewModeli i lekkich serwisów bez stanu.
  • Scoped — w MAUI mniej przydatne niż w ASP.NET Core, ale mogą się przydać w scenariuszach multi-window.

Oto kompletna konfiguracja DI dla przykładowej aplikacji:

private static void ConfigureServices(IServiceCollection services)
{
    // --- Serwisy (warstwa danych i biznesowa) ---
    services.AddSingleton<IHttpClientFactory>(sp =>
    {
        var factory = new DefaultHttpClientFactory();
        return factory;
    });
    services.AddSingleton<IApiClient, ApiClient>();
    services.AddSingleton<IKoszykService, KoszykService>();
    services.AddSingleton<IUzytkownikService, UzytkownikService>();
    services.AddSingleton<INavigationService, ShellNavigationService>();

    // --- ViewModele (Transient — nowa instancja dla każdego widoku) ---
    services.AddTransient<GlownaViewModel>();
    services.AddTransient<KoszykViewModel>();
    services.AddTransient<ProfilViewModel>();
    services.AddTransient<LogowanieViewModel>();
    services.AddTransient<SzczegolyProduktuViewModel>();

    // --- Widoki (Transient) ---
    services.AddTransient<GlownaPage>();
    services.AddTransient<KoszykPage>();
    services.AddTransient<ProfilPage>();
    services.AddTransient<LogowaniePage>();
    services.AddTransient<SzczegolyProduktuPage>();
}

Rejestrując zarówno ViewModele, jak i widoki w kontenerze DI, umożliwiamy automatyczne wstrzykiwanie zależności do konstruktorów. Framework sam rozwiąże drzewo zależności — nie musisz ręcznie tworzyć instancji.

Wiązanie ViewModelu z widokiem

Najprostsze i zalecane podejście to wstrzyknięcie ViewModelu do konstruktora strony:

namespace MojaAplikacja.Views;

public partial class KoszykPage : ContentPage
{
    public KoszykPage(KoszykViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}

Dwie linijki i gotowe. Gdy Shell tworzy instancję KoszykPage, kontener automatycznie rozwiązuje KoszykViewModel wraz ze wszystkimi jego zależnościami (IKoszykService, INavigationService itd.).

Nawigacja Shell — typowany routing i przekazywanie parametrów

Shell to preferowany system nawigacji w .NET MAUI i (moim zdaniem) jeden z lepiej zaprojektowanych elementów frameworka. Oferuje deklaratywną strukturę aplikacji, nawigację opartą na URI, flyout menu, tab bar i sporo więcej. Kluczowa zaleta: nawigacja za pomocą ciągów URI doskonale współgra z MVVM — ViewModel nie musi znać konkretnych typów widoków.

Definiowanie struktury aplikacji w AppShell

<?xml version="1.0" encoding="utf-8" ?>
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:views="clr-namespace:MojaAplikacja.Views"
       x:Class="MojaAplikacja.AppShell"
       FlyoutBehavior="Disabled">

    <TabBar>
        <ShellContent Title="Główna"
                      Icon="home.png"
                      ContentTemplate="{DataTemplate views:GlownaPage}" />

        <ShellContent Title="Koszyk"
                      Icon="cart.png"
                      ContentTemplate="{DataTemplate views:KoszykPage}" />

        <ShellContent Title="Profil"
                      Icon="profile.png"
                      ContentTemplate="{DataTemplate views:ProfilPage}" />
    </TabBar>

</Shell>

Rejestracja tras

Strony, które nie są częścią stałej struktury TabBar czy Flyout (np. strona szczegółów produktu), rejestrujesz jako trasy w code-behind AppShell:

namespace MojaAplikacja;

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

        // Rejestracja tras dla stron niehierarchicznych
        Routing.RegisterRoute("szczegoly-produktu",
            typeof(SzczegolyProduktuPage));
        Routing.RegisterRoute("logowanie",
            typeof(LogowaniePage));
        Routing.RegisterRoute("potwierdzenie",
            typeof(PotwierdzeniePage));
    }
}

Serwis nawigacyjny zgodny z MVVM

Bezpośrednie wywoływanie Shell.Current.GoToAsync() w ViewModelu to antywzorzec — tworzy ścisłe powiązanie z frameworkiem UI. Zamiast tego warto stworzyć prostą abstrakcję:

namespace MojaAplikacja.Services;

public interface INavigationService
{
    Task PrzejdzDoAsync(string trasa);
    Task PrzejdzDoAsync(string trasa, IDictionary<string, object> parametry);
    Task CofnijAsync();
    Task CofnijDoGlownejAsync();
}

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

    public async Task PrzejdzDoAsync(string trasa,
        IDictionary<string, object> parametry)
    {
        await Shell.Current.GoToAsync(trasa, parametry);
    }

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

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

Przekazywanie parametrów między stronami

.NET MAUI Shell obsługuje przekazywanie złożonych obiektów jako parametrów nawigacji. ViewModel docelowy implementuje interfejs IQueryAttributable:

// Nawigacja z parametrem (w ViewModelu źródłowym)
[RelayCommand]
private async Task OtworzSzczegolyAsync(ProduktViewModel produkt)
{
    var parametry = new Dictionary<string, object>
    {
        { "ProduktId", produkt.Id },
        { "NazwaProduktu", produkt.Nazwa }
    };
    await _nawigacja.PrzejdzDoAsync("szczegoly-produktu", parametry);
}

// Odbiór parametrów (w ViewModelu docelowym)
public partial class SzczegolyProduktuViewModel : ObservableObject,
    IQueryAttributable
{
    private readonly IProduktService _produktService;

    [ObservableProperty]
    public partial ProduktModel? Produkt { get; set; }

    [ObservableProperty]
    public partial bool CzyLadowanie { get; set; }

    public SzczegolyProduktuViewModel(IProduktService produktService)
    {
        _produktService = produktService;
    }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("ProduktId", out var idObj)
            && idObj is int produktId)
        {
            WczytajProduktCommand.Execute(produktId);
        }
    }

    [RelayCommand]
    private async Task WczytajProduktAsync(int produktId)
    {
        CzyLadowanie = true;
        try
        {
            Produkt = await _produktService.PobierzSzczegolyAsync(produktId);
        }
        finally
        {
            CzyLadowanie = false;
        }
    }
}

Data Binding w XAML — łączenie widoku z ViewModelem

Mając gotowy ViewModel, pora połączyć go z widokiem. Oto kompletny przykład strony koszyka, który pokazuje, jak to wygląda w praktyce:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:MojaAplikacja.ViewModels"
             x:Class="MojaAplikacja.Views.KoszykPage"
             x:DataType="vm:KoszykViewModel"
             Title="Koszyk">

    <Grid RowDefinitions="*, Auto"
          Padding="16">

        <!-- Wskaźnik ładowania -->
        <ActivityIndicator IsRunning="{Binding CzyLadowanie}"
                           IsVisible="{Binding CzyLadowanie}"
                           HorizontalOptions="Center"
                           VerticalOptions="Center" />

        <!-- Lista produktów -->
        <CollectionView ItemsSource="{Binding Produkty}"
                        IsVisible="{Binding CzyLadowanie, Converter={StaticResource InvertBoolConverter}}">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="vm:ProduktViewModel">
                    <SwipeView>
                        <SwipeView.RightItems>
                            <SwipeItems>
                                <SwipeItem Text="Usuń"
                                           BackgroundColor="Red"
                                           Command="{Binding Source={RelativeSource AncestorType={x:Type vm:KoszykViewModel}}, Path=UsunProduktCommand}"
                                           CommandParameter="{Binding .}" />
                            </SwipeItems>
                        </SwipeView.RightItems>

                        <Frame Padding="12" Margin="0,4">
                            <Grid ColumnDefinitions="*, Auto">
                                <Label Text="{Binding Nazwa}"
                                       FontSize="16"
                                       VerticalOptions="Center" />
                                <Label Text="{Binding Cena, StringFormat='{0:C}'}"
                                       Grid.Column="1"
                                       FontSize="16"
                                       FontAttributes="Bold"
                                       VerticalOptions="Center" />
                            </Grid>
                        </Frame>
                    </SwipeView>
                </DataTemplate>
            </CollectionView.ItemTemplate>

            <CollectionView.EmptyView>
                <Label Text="Twój koszyk jest pusty"
                       HorizontalOptions="Center"
                       VerticalOptions="Center"
                       FontSize="18"
                       TextColor="Gray" />
            </CollectionView.EmptyView>
        </CollectionView>

        <!-- Panel podsumowania -->
        <VerticalStackLayout Grid.Row="1" Spacing="8">
            <BoxView HeightRequest="1" Color="LightGray" />
            <HorizontalStackLayout HorizontalOptions="End" Spacing="8">
                <Label Text="Suma:" FontSize="20" />
                <Label Text="{Binding Suma, StringFormat='{0:C}'}"
                       FontSize="20"
                       FontAttributes="Bold" />
            </HorizontalStackLayout>
            <Button Text="Złóż zamówienie"
                    Command="{Binding ZlozZamowienieCommand}"
                    BackgroundColor="#2196F3"
                    TextColor="White"
                    FontSize="18"
                    HeightRequest="50" />
        </VerticalStackLayout>

    </Grid>

</ContentPage>

Zwróć uwagę na atrybut x:DataType — to compiled bindings, które zapewniają statyczne typowanie bindingów na etapie kompilacji. W .NET MAUI 10 generator kodu XAML dodatkowo kompiluje bindingi w czasie budowania, eliminując refleksję w runtime. Efekt? Szybszy start aplikacji i literówki w bindingach wyłapane jeszcze przed uruchomieniem.

Komunikacja między ViewModelami — WeakReferenceMessenger

Czasami ViewModele muszą się komunikować między sobą, nie znając się nawzajem. Klasyczny scenariusz: po zalogowaniu użytkownika wiele stron musi odświeżyć dane. CommunityToolkit.Mvvm oferuje do tego WeakReferenceMessenger — prosty, a skuteczny mechanizm.

// 1. Definicja wiadomości
public sealed class UzytkownikZalogowanyMessage
{
    public string NazwaUzytkownika { get; }
    public UzytkownikZalogowanyMessage(string nazwa) => NazwaUzytkownika = nazwa;
}

// 2. Wysłanie wiadomości (w LogowanieViewModel)
[RelayCommand]
private async Task ZalogujAsync()
{
    var wynik = await _authService.ZalogujAsync(Email, Haslo);
    if (wynik.Sukces)
    {
        WeakReferenceMessenger.Default.Send(
            new UzytkownikZalogowanyMessage(wynik.NazwaUzytkownika));
        await _nawigacja.CofnijDoGlownejAsync();
    }
}

// 3. Odbiór wiadomości (w GlownaViewModel)
public partial class GlownaViewModel : ObservableObject
{
    public GlownaViewModel(IProduktService produktService)
    {
        _produktService = produktService;

        WeakReferenceMessenger.Default
            .Register<UzytkownikZalogowanyMessage>(this, (r, m) =>
            {
                // Odśwież dane po zalogowaniu
                ((GlownaViewModel)r).WczytajRekomendacjeCommand.Execute(null);
            });
    }
}

WeakReferenceMessenger używa słabych referencji, co zapobiega wyciekom pamięci. Jeśli ViewModel zostanie usunięty z pamięci, automatycznie przestanie otrzymywać wiadomości. To znacznie bezpieczniejsze niż klasyczne zdarzenia C# (gdzie zapomnienie o unsubscribe to prosta droga do memory leaków).

Walidacja danych — ObservableValidator

CommunityToolkit.Mvvm zawiera też klasę ObservableValidator, która integruje walidację z modelem MVVM. Dziedziczy po ObservableObject i dodaje obsługę atrybutów walidacyjnych z System.ComponentModel.DataAnnotations:

using System.ComponentModel.DataAnnotations;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace MojaAplikacja.ViewModels;

public partial class RejestracjaViewModel : ObservableValidator
{
    [ObservableProperty]
    [Required(ErrorMessage = "Imię jest wymagane")]
    [MinLength(2, ErrorMessage = "Imię musi mieć co najmniej 2 znaki")]
    public partial string Imie { get; set; } = string.Empty;

    [ObservableProperty]
    [Required(ErrorMessage = "Email jest wymagany")]
    [EmailAddress(ErrorMessage = "Podaj prawidłowy adres email")]
    public partial string Email { get; set; } = string.Empty;

    [ObservableProperty]
    [Required(ErrorMessage = "Hasło jest wymagane")]
    [MinLength(8, ErrorMessage = "Hasło musi mieć co najmniej 8 znaków")]
    [RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$",
        ErrorMessage = "Hasło musi zawierać małą literę, wielką literę i cyfrę")]
    public partial string Haslo { get; set; } = string.Empty;

    [RelayCommand]
    private async Task ZarejestrujAsync()
    {
        ValidateAllProperties();

        if (HasErrors)
        {
            var pierwszyBlad = GetErrors()
                .FirstOrDefault()?.ErrorMessage;
            await Shell.Current.DisplayAlert("Błąd walidacji",
                pierwszyBlad, "OK");
            return;
        }

        // Kontynuuj rejestrację...
    }
}

Warstwa serwisów — wzorzec repozytorium

Kompletna architektura MVVM potrzebuje solidnej warstwy serwisów. Poniżej wzorzec, który sprawdza się naprawdę dobrze w aplikacjach .NET MAUI — generyczny interfejs repozytorium z implementacją HTTP API:

namespace MojaAplikacja.Services;

public interface IRepozytorium<T> where T : class
{
    Task<IEnumerable<T>> PobierzWszystkieAsync();
    Task<T?> PobierzPoIdAsync(int id);
    Task<T> DodajAsync(T encja);
    Task AktualizujAsync(T encja);
    Task UsunAsync(int id);
}

public class ApiRepozytorium<T> : IRepozytorium<T> where T : class
{
    private readonly HttpClient _httpClient;
    private readonly string _endpoint;

    public ApiRepozytorium(HttpClient httpClient, string endpoint)
    {
        _httpClient = httpClient;
        _endpoint = endpoint;
    }

    public async Task<IEnumerable<T>> PobierzWszystkieAsync()
    {
        var odpowiedz = await _httpClient.GetAsync(_endpoint);
        odpowiedz.EnsureSuccessStatusCode();
        return await odpowiedz.Content
            .ReadFromJsonAsync<IEnumerable<T>>() ?? [];
    }

    public async Task<T?> PobierzPoIdAsync(int id)
    {
        var odpowiedz = await _httpClient.GetAsync($"{_endpoint}/{id}");
        if (odpowiedz.StatusCode == System.Net.HttpStatusCode.NotFound)
            return null;
        odpowiedz.EnsureSuccessStatusCode();
        return await odpowiedz.Content.ReadFromJsonAsync<T>();
    }

    public async Task<T> DodajAsync(T encja)
    {
        var odpowiedz = await _httpClient.PostAsJsonAsync(_endpoint, encja);
        odpowiedz.EnsureSuccessStatusCode();
        return await odpowiedz.Content.ReadFromJsonAsync<T>()
            ?? throw new InvalidOperationException(
                "Serwer nie zwrócił utworzonego obiektu");
    }

    public async Task AktualizujAsync(T encja)
    {
        var odpowiedz = await _httpClient.PutAsJsonAsync(_endpoint, encja);
        odpowiedz.EnsureSuccessStatusCode();
    }

    public async Task UsunAsync(int id)
    {
        var odpowiedz = await _httpClient.DeleteAsync($"{_endpoint}/{id}");
        odpowiedz.EnsureSuccessStatusCode();
    }
}

Testy jednostkowe ViewModeli — tu MVVM się opłaca

Jedną z największych zalet MVVM jest testowalność i szczerze mówiąc, to właśnie dlatego warto wdrożyć ten wzorzec. Skoro ViewModel nie zależy od widoku ani platformy, możesz testować całą logikę za pomocą zwykłych testów jednostkowych. Oto przykład z xUnit i NSubstitute:

using NSubstitute;
using Xunit;

namespace MojaAplikacja.Tests;

public class KoszykViewModelTests
{
    private readonly IKoszykService _koszykService;
    private readonly INavigationService _nawigacja;
    private readonly KoszykViewModel _viewModel;

    public KoszykViewModelTests()
    {
        _koszykService = Substitute.For<IKoszykService>();
        _nawigacja = Substitute.For<INavigationService>();
        _viewModel = new KoszykViewModel(_koszykService, _nawigacja);
    }

    [Fact]
    public async Task WczytajProdukty_UstawiaProduktyISume()
    {
        // Arrange
        var testoweProdukty = new List<ProduktViewModel>
        {
            new() { Nazwa = "Laptop", Cena = 3999.99m },
            new() { Nazwa = "Mysz", Cena = 149.00m }
        };
        _koszykService.PobierzProduktyAsync()
            .Returns(testoweProdukty);

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

        // Assert
        Assert.Equal(2, _viewModel.Produkty.Count);
        Assert.Equal(4148.99m, _viewModel.Suma);
        Assert.False(_viewModel.CzyLadowanie);
    }

    [Fact]
    public async Task UsunProdukt_UsuwaZListyIAktualizujeSume()
    {
        // Arrange
        var produkt = new ProduktViewModel
            { Id = 1, Nazwa = "Laptop", Cena = 3999.99m };
        _viewModel.Produkty.Add(produkt);
        _viewModel.Produkty.Add(new ProduktViewModel
            { Id = 2, Nazwa = "Mysz", Cena = 149.00m });

        // Act
        await _viewModel.UsunProduktCommand.ExecuteAsync(produkt);

        // Assert
        Assert.Single(_viewModel.Produkty);
        Assert.DoesNotContain(produkt, _viewModel.Produkty);
        await _koszykService.Received(1).UsunAsync(1);
    }

    [Fact]
    public async Task ZlozZamowienie_NawigujeDoPotwierdzenia()
    {
        // Arrange
        _viewModel.Produkty.Add(new ProduktViewModel
            { Nazwa = "Laptop", Cena = 3999.99m });

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

        // Assert
        await _nawigacja.Received(1)
            .PrzejdzDoAsync("potwierdzenie");
    }
}

Zauważ, jak łatwo pisze się testy, gdy zależności są wstrzykiwane przez konstruktor. Zamiast prawdziwych serwisów HTTP podajemy mocki z NSubstitute. Testy wykonują się w milisekundach — bez emulatorów, bez sieci, bez czekania.

Co nowego w .NET MAUI 10 dla architektury MVVM

.NET MAUI 10 przynosi kilka zmian, które bezpośrednio wpływają na codzienną pracę z MVVM:

Generator kodu XAML (XAML Source Generator)

Nowy generator kompiluje pliki XAML w czasie budowania zamiast parsować je w runtime. W praktyce oznacza to:

  • Literówki w ścieżkach bindingów powodują błędy kompilacji, nie ciche awarie w runtime (nareszcie!).
  • Szybszy start aplikacji — XAML nie musi być parsowany przy uruchomieniu.
  • Możliwość inspekcji generowanego kodu, co bywa przydatne przy debugowaniu.

Nowe CollectionView i CarouselView

W .NET MAUI 10 nowe handlery CollectionView i CarouselView (wcześniej opcjonalne w .NET 9) stały się domyślne. Oferują znacznie lepszą wydajność przy dużych zbiorach danych — co jest kluczowe dla ViewModeli zarządzających listami.

Pakietowy model dystrybucji

.NET MAUI 10 dystrybuuje się jako workload .NET oraz zestaw pakietów NuGet. Z perspektywy MVVM oznacza to łatwiejsze zarządzanie zależnościami i aktualizacje poszczególnych komponentów bez ryzyka globalnych regresji.

Wycofane kontrolki

ListView, EntryCell, ImageCell, SwitchCell, TextCell i TableView zostały oznaczone jako wycofane. Jeśli korzystasz z tych kontrolek, czas migrować do CollectionView. Dobra wiadomość — z perspektywy MVVM zmiana jest kosmetyczna. Dane w ViewModelu zostają takie same, zmienia się tylko widok.

Struktura folderów projektu

Organizacja plików w projekcie ma większy wpływ na utrzymywalność kodu, niż mogłoby się wydawać. Oto struktura, którą sam stosuję w projektach produkcyjnych:

MojaAplikacja/
├── App.xaml / App.xaml.cs
├── AppShell.xaml / AppShell.xaml.cs
├── MauiProgram.cs
├── Models/
│   ├── ProduktModel.cs
│   ├── UzytkownikModel.cs
│   └── ZamowienieModel.cs
├── ViewModels/
│   ├── GlownaViewModel.cs
│   ├── KoszykViewModel.cs
│   ├── ProfilViewModel.cs
│   ├── LogowanieViewModel.cs
│   ├── RejestracjaViewModel.cs
│   └── SzczegolyProduktuViewModel.cs
├── Views/
│   ├── GlownaPage.xaml / .cs
│   ├── KoszykPage.xaml / .cs
│   ├── ProfilPage.xaml / .cs
│   ├── LogowaniePage.xaml / .cs
│   ├── RejestracjaPage.xaml / .cs
│   └── SzczegolyProduktuPage.xaml / .cs
├── Services/
│   ├── Interfaces/
│   │   ├── INavigationService.cs
│   │   ├── IKoszykService.cs
│   │   └── IRepozytorium.cs
│   ├── ShellNavigationService.cs
│   ├── KoszykService.cs
│   └── ApiRepozytorium.cs
├── Messages/
│   └── UzytkownikZalogowanyMessage.cs
├── Converters/
│   └── InvertBoolConverter.cs
└── Resources/
    ├── Fonts/
    ├── Images/
    └── Styles/

Kilka kluczowych zasad:

  • Każdy ViewModel ma swój odpowiadający View — nazwy muszą być spójne (KoszykViewModelKoszykPage).
  • Interfejsy serwisów w osobnym folderze — ułatwia mockowanie w testach.
  • Modele są niezależne od platformy i UI — mogą być współdzielone z innymi projektami.
  • Wiadomości oddzielnie — łatwiej śledzić komunikację między ViewModelami.

Najczęstsze błędy i antywzorce

Na koniec — lista błędów, które widzę regularnie w projektach .NET MAUI z MVVM. Unikaj ich, a zaoszczędzisz sobie (i swojemu zespołowi) sporo frustracji:

1. Logika biznesowa w code-behind

Jeśli w pliku .xaml.cs jest cokolwiek poza InitializeComponent() i przypisaniem BindingContext, to prawie na pewno robisz coś źle. Obsługa zdarzeń, walidacja, nawigacja — to wszystko powinno być w ViewModelu.

2. Brak interfejsów dla serwisów

Bez interfejsów nie zamockujesz zależności w testach. Każdy serwis, z którego korzysta ViewModel, powinien mieć interfejs.

3. ViewModel zna konkretny widok

ViewModel nigdy nie powinien odwoływać się do kontrolek XAML, stron ani elementów UI. Komunikacja idzie wyłącznie przez data binding, komendy i wiadomości. Koniec, kropka.

4. Nadużywanie Singletona

Rejestrowanie ViewModeli jako Singleton oznacza, że stan strony przetrwa nawigację — użytkownik wróci i zobaczy stare dane. ViewModele powinny być Transient, chyba że masz naprawdę konkretny powód.

5. Brak obsługi błędów w komendach asynchronicznych

Nieobsłużony wyjątek w AsyncRelayCommand nie przerwie aplikacji (jest łapany wewnętrznie), ale użytkownik nie zobaczy żadnej informacji. Zawsze obsługuj wyjątki i informuj o problemach.

6. Zbyt głęboka hierarchia ViewModeli

Nie twórz ViewModeli dla każdego elementu UI. ViewModel odpowiada stronie lub sekcji logicznej, nie pojedynczemu przyciskowi.

Podsumowanie

Architektura MVVM w .NET MAUI w 2026 roku to dojrzały ekosystem z solidnym wsparciem narzędziowym. CommunityToolkit.Mvvm 8.4 z partial properties eliminuje boilerplate. Wbudowane DI upraszcza zarządzanie zależnościami. Shell Navigation zapewnia nawigację zgodną z MVVM. A nowy generator XAML w .NET MAUI 10 dba o wydajność i wczesne wykrywanie błędów.

Co zapamiętać z tego przewodnika:

  • Zacznij od CommunityToolkit.Mvvm i partial properties — to standard na 2026 rok.
  • Rejestruj wszystko w DI — ViewModele, serwisy i strony.
  • Abstrahuj nawigację przez interfejs — ułatwi testowanie i ewentualną wymianę frameworka nawigacyjnego.
  • Pisz testy jednostkowe ViewModeli od początku — to główny powód, dla którego wdrażasz MVVM.
  • Używaj x:DataType we wszystkich widokach — dla wydajności i bezpieczeństwa typów.
  • Nie komplikuj. MVVM ma upraszczać, nie komplikować. Jeśli architektura zaczyna być barierą, to znak, że przesadzasz z abstrakcjami.

Masz już solidne podstawy. Kolejne kroki to zastosowanie tych wzorców w konkretnym projekcie i dodanie warstwy offline-first z SQLite. Architektura MVVM to nie jednorazowa decyzja — to ciągły proces doskonalenia, który procentuje z każdą nową funkcją i każdym poprawionym błędem.

O Autorze Editorial Team

Our team of expert writers and editors.