Arsitektur Aplikasi .NET MAUI: Panduan MVVM, Dependency Injection, dan Shell Navigation

Panduan praktis merancang arsitektur aplikasi .NET MAUI profesional menggunakan pola MVVM dengan CommunityToolkit.Mvvm, Dependency Injection bawaan, dan Shell Navigation. Lengkap dengan contoh kode siap pakai.

Pendahuluan: Kenapa Arsitektur Itu Penting di .NET MAUI?

Kalau kamu pernah membangun aplikasi mobile dengan .NET MAUI dan langsung terjun ke coding tanpa memikirkan arsitektur — percayalah, kamu nggak sendirian. Banyak developer (termasuk saya dulu) yang memulai proyek dengan semangat tinggi, lalu beberapa minggu kemudian mendapati kode yang sudah kusut, sulit di-debug, dan rasanya mustahil untuk diuji secara otomatis.

Masalahnya sederhana: tanpa pola arsitektur yang tepat, proyek yang awalnya sederhana bakal berubah jadi spaghetti code. Dan di dunia mobile development, itu artinya bug di mana-mana.

.NET MAUI, sebagai penerus Xamarin.Forms, sebenarnya sudah dirancang dengan mempertimbangkan praktik pengembangan modern. Framework ini punya dukungan bawaan untuk tiga pilar arsitektur utama: Model-View-ViewModel (MVVM) untuk pemisahan logika presentasi, Dependency Injection (DI) untuk pengelolaan dependensi yang bersih, dan Shell Navigation untuk navigasi berbasis URI yang terstruktur. Ketiganya bekerja secara sinergis — dan jujur saja, begitu kamu paham cara menggunakannya, development jadi jauh lebih menyenangkan.

Dalam panduan ini, kita akan bahas setiap pilar arsitektur tersebut secara mendalam. Mulai dari konsep dasar sampai implementasi praktis dengan contoh kode yang bisa langsung kamu pakai. Kita akan menggunakan CommunityToolkit.Mvvm untuk menyederhanakan MVVM, memanfaatkan DI container bawaan .NET MAUI, dan mengonfigurasi Shell Navigation yang terintegrasi dengan ViewModel. Nah, mari kita mulai.

Pola MVVM di .NET MAUI

Memahami Model-View-ViewModel

Pola MVVM memisahkan aplikasi menjadi tiga komponen utama:

  • Model: Merepresentasikan data dan logika bisnis. Model berisi entitas data, validasi, dan aturan bisnis. Komponen ini sama sekali tidak tahu tentang UI.
  • View: Ini adalah antarmuka pengguna — biasanya file XAML di .NET MAUI. View harus sesederhana mungkin dan bebas dari logika bisnis.
  • ViewModel: Jembatan antara Model dan View. ViewModel mengekspos data yang siap ditampilkan, menangani command dari interaksi pengguna, dan mengelola state aplikasi.

Keuntungan utamanya? Testability. ViewModel bisa diuji secara independen tanpa UI sama sekali. Plus, developer UI dan developer logika bisa kerja paralel karena komunikasi hanya lewat data binding.

Berikut contoh Model sederhana yang akan kita pakai sepanjang panduan:

// Models/Product.cs
namespace MauiStoreApp.Models;

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public string ImageUrl { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public int StockQuantity { get; set; }
    public DateTime CreatedAt { get; set; }
}

Implementasi MVVM dengan CommunityToolkit.Mvvm

Nah, ini bagian yang seru. Secara tradisional, implementasi MVVM di .NET memerlukan banyak boilerplate — mengimplementasikan INotifyPropertyChanged, membuat properti dengan notifikasi perubahan, membungkus metode dalam ICommand. Membosankan dan repetitif banget.

CommunityToolkit.Mvvm (atau MVVM Toolkit) menyelesaikan masalah ini dengan source generator yang otomatis menghasilkan kode boilerplate saat kompilasi. Jadi kamu cukup fokus ke logika bisnis.

Pertama, instal paket NuGet-nya:

dotnet add package CommunityToolkit.Mvvm

CommunityToolkit.Mvvm menyediakan kelas dasar ObservableObject yang sudah mengimplementasikan INotifyPropertyChanged dan INotifyPropertyChanging. Dengan source generator, jumlah kode yang perlu ditulis manual berkurang drastis.

Menggunakan [ObservableProperty] dan [RelayCommand]

Atribut [ObservableProperty] otomatis menghasilkan properti publik lengkap dengan notifikasi perubahan dari field privat. Sementara [RelayCommand] membungkus metode jadi objek IRelayCommand yang bisa di-bind ke UI. Ini contoh ViewModel lengkapnya:

// ViewModels/ProductListViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MauiStoreApp.Models;
using MauiStoreApp.Services;
using System.Collections.ObjectModel;

namespace MauiStoreApp.ViewModels;

public partial class ProductListViewModel : ObservableObject
{
    private readonly IProductService _productService;
    private readonly INavigationService _navigationService;

    public ProductListViewModel(
        IProductService productService,
        INavigationService navigationService)
    {
        _productService = productService;
        _navigationService = navigationService;
    }

    // Source generator akan membuat properti publik "Products"
    // lengkap dengan INotifyPropertyChanged
    [ObservableProperty]
    private ObservableCollection<Product> _products = new();

    // Source generator membuat properti "IsLoading"
    [ObservableProperty]
    private bool _isLoading;

    // Source generator membuat properti "SearchQuery"
    // dan memanggil OnSearchQueryChanged saat nilainya berubah
    [ObservableProperty]
    private string _searchQuery = string.Empty;

    [ObservableProperty]
    private string _errorMessage = string.Empty;

    [ObservableProperty]
    private bool _hasError;

    // Metode parsial yang dipanggil otomatis saat SearchQuery berubah
    partial void OnSearchQueryChanged(string value)
    {
        // Trigger pencarian ulang saat query berubah
        FilterProductsCommand.Execute(null);
    }

    // Source generator membuat IRelayCommand "LoadProductsCommand"
    // dengan dukungan async dan CanExecute otomatis
    [RelayCommand]
    private async Task LoadProductsAsync()
    {
        if (IsLoading) return;

        try
        {
            IsLoading = true;
            HasError = false;
            ErrorMessage = string.Empty;

            var products = await _productService.GetProductsAsync();

            Products.Clear();
            foreach (var product in products)
            {
                Products.Add(product);
            }
        }
        catch (Exception ex)
        {
            HasError = true;
            ErrorMessage = $"Gagal memuat produk: {ex.Message}";
        }
        finally
        {
            IsLoading = false;
        }
    }

    [RelayCommand]
    private async Task FilterProductsAsync()
    {
        if (string.IsNullOrWhiteSpace(SearchQuery))
        {
            await LoadProductsAsync();
            return;
        }

        try
        {
            IsLoading = true;
            var filtered = await _productService
                .SearchProductsAsync(SearchQuery);

            Products.Clear();
            foreach (var product in filtered)
            {
                Products.Add(product);
            }
        }
        catch (Exception ex)
        {
            HasError = true;
            ErrorMessage = $"Gagal mencari produk: {ex.Message}";
        }
        finally
        {
            IsLoading = false;
        }
    }

    [RelayCommand]
    private async Task NavigateToDetailAsync(Product product)
    {
        if (product is null) return;

        await _navigationService.NavigateToAsync(
            "ProductDetail",
            new Dictionary<string, object>
            {
                { "ProductId", product.Id }
            });
    }

    [RelayCommand]
    private async Task RefreshAsync()
    {
        await LoadProductsAsync();
    }
}

Ada beberapa hal penting yang perlu diperhatikan di sini. Pertama, kelas harus partial karena source generator menghasilkan bagian lainnya. Kedua, field privat pakai konvensi underscore prefix (seperti _products), dan source generator bikin properti publik dengan nama PascalCase (Products). Ketiga, metode async dengan [RelayCommand] otomatis menghasilkan command yang mengelola state eksekusi.

Penasaran apa yang dihasilkan source generator di balik layar? Kurang lebih seperti ini (untuk satu properti):

// Kode yang dihasilkan secara otomatis oleh source generator
// (TIDAK perlu ditulis secara manual)
public ObservableCollection<Product> Products
{
    get => _products;
    set
    {
        if (!EqualityComparer<ObservableCollection<Product>>
            .Default.Equals(_products, value))
        {
            OnPropertyChanging(nameof(Products));
            _products = value;
            OnPropertyChanged(nameof(Products));
        }
    }
}

Praktik Terbaik Data Binding

Data binding menghubungkan View dengan ViewModel. Berikut View yang mengkonsumsi ViewModel di atas — perhatikan bagaimana compiled bindings dan beberapa teknik penting diterapkan:

<!-- Views/ProductListPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:viewmodels="clr-namespace:MauiStoreApp.ViewModels"
             xmlns:models="clr-namespace:MauiStoreApp.Models"
             x:Class="MauiStoreApp.Views.ProductListPage"
             x:DataType="viewmodels:ProductListViewModel"
             Title="Katalog Produk">

    <Grid RowDefinitions="Auto,*">
        <!-- Search Bar -->
        <SearchBar Grid.Row="0"
                   Text="{Binding SearchQuery}"
                   Placeholder="Cari produk..."
                   SearchCommand="{Binding FilterProductsCommand}" />

        <!-- Error Message -->
        <Label Grid.Row="1"
               Text="{Binding ErrorMessage}"
               TextColor="Red"
               IsVisible="{Binding HasError}"
               HorizontalOptions="Center"
               VerticalOptions="Center" />

        <!-- Loading Indicator -->
        <ActivityIndicator Grid.Row="1"
                           IsRunning="{Binding IsLoading}"
                           IsVisible="{Binding IsLoading}"
                           HorizontalOptions="Center"
                           VerticalOptions="Center" />

        <!-- Product List -->
        <RefreshView Grid.Row="1"
                     Command="{Binding RefreshCommand}"
                     IsRefreshing="{Binding IsLoading}">
            <CollectionView ItemsSource="{Binding Products}"
                            SelectionMode="None">
                <CollectionView.ItemTemplate>
                    <DataTemplate x:DataType="models:Product">
                        <Grid Padding="16,8" ColumnDefinitions="80,*"
                              ColumnSpacing="12">
                            <Grid.GestureRecognizers>
                                <TapGestureRecognizer
                                    Command="{Binding Source={RelativeSource
                                        AncestorType={x:Type viewmodels:ProductListViewModel}},
                                        Path=NavigateToDetailCommand}"
                                    CommandParameter="{Binding .}" />
                            </Grid.GestureRecognizers>

                            <Image Source="{Binding ImageUrl}"
                                   Aspect="AspectFill"
                                   WidthRequest="80"
                                   HeightRequest="80" />

                            <VerticalStackLayout Grid.Column="1"
                                                 Spacing="4"
                                                 VerticalOptions="Center">
                                <Label Text="{Binding Name}"
                                       FontSize="16"
                                       FontAttributes="Bold"
                                       LineBreakMode="TailTruncation" />
                                <Label Text="{Binding Description}"
                                       FontSize="13"
                                       TextColor="Gray"
                                       MaxLines="2"
                                       LineBreakMode="TailTruncation" />
                                <Label Text="{Binding Price,
                                       StringFormat='Rp {0:N0}'}"
                                       FontSize="15"
                                       TextColor="Green"
                                       FontAttributes="Bold" />
                            </VerticalStackLayout>
                        </Grid>
                    </DataTemplate>
                </CollectionView.ItemTemplate>

                <CollectionView.EmptyView>
                    <VerticalStackLayout HorizontalOptions="Center"
                                         VerticalOptions="Center"
                                         Spacing="8">
                        <Label Text="Tidak ada produk ditemukan"
                               FontSize="18"
                               HorizontalOptions="Center" />
                        <Button Text="Muat Ulang"
                                Command="{Binding Source={RelativeSource
                                    AncestorType={x:Type viewmodels:ProductListViewModel}},
                                    Path=LoadProductsCommand}" />
                    </VerticalStackLayout>
                </CollectionView.EmptyView>
            </CollectionView>
        </RefreshView>
    </Grid>

Beberapa praktik penting yang diterapkan di contoh ini:

  • Compiled Bindings dengan x:DataType: Setiap elemen yang punya binding menggunakan x:DataType. Ini bukan cuma soal performa (sekitar 8x lebih cepat untuk OneWay dan 20x untuk OneTime binding), tapi juga validasi saat kompilasi yang menangkap kesalahan sebelum runtime.
  • RelativeSource untuk binding lintas konteks: Di dalam DataTemplate, konteks data berubah jadi item individual. Untuk mengakses command dari ViewModel induk, kita pakai RelativeSource AncestorType.
  • EmptyView: Selalu sediakan tampilan untuk state kosong. Pengguna perlu tahu bahwa data memang nggak ada, bukan sedang loading.
  • StringFormat: Gunakan StringFormat langsung di binding daripada bikin properti terpisah di ViewModel untuk format tampilan sederhana.

Dan ini code-behind yang minimalis — semua logika sudah ada di ViewModel:

// Views/ProductListPage.xaml.cs
namespace MauiStoreApp.Views;

public partial class ProductListPage : ContentPage
{
    public ProductListPage(ProductListViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }

    protected override void OnAppearing()
    {
        base.OnAppearing();

        // Memuat data saat halaman pertama kali muncul
        if (BindingContext is ProductListViewModel vm)
        {
            vm.LoadProductsCommand.Execute(null);
        }
    }
}

Dependency Injection di .NET MAUI

DI Container Bawaan

.NET MAUI hadir dengan DI container bawaan yang sama dengan ASP.NET Core, yaitu Microsoft.Extensions.DependencyInjection. Ini perubahan besar dari era Xamarin.Forms yang nggak punya DI bawaan — dulu kita harus pakai library pihak ketiga atau bahkan manual service locator pattern (yang sejujurnya agak menyebalkan).

DI container dikonfigurasi di MauiProgram.cs, entry point untuk konfigurasi aplikasi .NET MAUI. Semua registrasi service, view, dan ViewModel dilakukan di sini:

// MauiProgram.cs
using Microsoft.Extensions.Logging;
using MauiStoreApp.Services;
using MauiStoreApp.ViewModels;
using MauiStoreApp.Views;

namespace MauiStoreApp;

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");
            });

        // === Registrasi Services ===
        builder.Services.AddSingleton<IProductService, ProductService>();
        builder.Services.AddSingleton<IAuthService, AuthService>();
        builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
        builder.Services.AddSingleton<IConnectivityService, ConnectivityService>();

        // HttpClient dengan konfigurasi
        builder.Services.AddHttpClient("StoreApi", client =>
        {
            client.BaseAddress = new Uri("https://api.mystore.com/v1/");
            client.DefaultRequestHeaders.Add("Accept", "application/json");
            client.Timeout = TimeSpan.FromSeconds(30);
        });

        // === Registrasi ViewModels ===
        builder.Services.AddTransient<ProductListViewModel>();
        builder.Services.AddTransient<ProductDetailViewModel>();
        builder.Services.AddTransient<CartViewModel>();
        builder.Services.AddSingleton<SettingsViewModel>();

        // === Registrasi Views (Pages) ===
        builder.Services.AddTransient<ProductListPage>();
        builder.Services.AddTransient<ProductDetailPage>();
        builder.Services.AddTransient<CartPage>();
        builder.Services.AddSingleton<SettingsPage>();

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

        return builder.Build();
    }
}

Service Lifetimes: Singleton, Transient, dan Scoped

Ini bagian yang sering bikin developer bingung, tapi sebenarnya nggak terlalu rumit kok. Memahami lifetime service itu kunci untuk menghindari bug yang super sulit dilacak — terutama memory leak dan state yang nggak konsisten.

.NET MAUI mendukung tiga jenis lifetime:

  1. Singleton (AddSingleton): Cuma satu instance selama aplikasi hidup. Instance yang sama di-share ke semua konsumen. Cocok untuk service yang menyimpan state global, konfigurasi, atau cache — misalnya IAuthService yang menyimpan token autentikasi.
  2. Transient (AddTransient): Instance baru dibuat setiap kali diminta dari container. Pakai ini untuk ViewModel dan Page yang butuh fresh state setiap dibuka.
  3. Scoped (AddScoped): Instance dibuat sekali per scope. Di .NET MAUI, konsep scope memang nggak sejelas di ASP.NET Core (yang punya request scope), tapi kamu bisa bikin scope manual untuk skenario tertentu.

Berikut panduan praktis pemilihan lifetime:

// Panduan Registrasi Lifetime

// SINGLETON - state global, cache, konfigurasi
builder.Services.AddSingleton<IAuthService, AuthService>();
builder.Services.AddSingleton<IPreferencesService, PreferencesService>();
builder.Services.AddSingleton<IDatabaseService, SqliteDatabaseService>();

// TRANSIENT - ViewModel dan Page (fresh state setiap navigasi)
builder.Services.AddTransient<ProductDetailViewModel>();
builder.Services.AddTransient<ProductDetailPage>();
builder.Services.AddTransient<CheckoutViewModel>();
builder.Services.AddTransient<CheckoutPage>();

// SINGLETON untuk ViewModel tab utama (tetap hidup)
builder.Services.AddSingleton<HomeViewModel>();
builder.Services.AddSingleton<HomePage>();

// PENTING: Jangan inject Transient ke dalam Singleton!
// Ini akan membuat instance Transient hidup selama Singleton.

Peringatan penting: Jangan pernah inject service Transient ke dalam Singleton. Ini namanya captive dependency — instance Transient akan ikut hidup selama Singleton (alias selama aplikasi berjalan), yang menghilangkan tujuan Transient dan bisa menyebabkan memory leak. Saya pernah menghabiskan seharian debug masalah ini. Nggak seru.

Mendaftarkan Views, ViewModels, dan Services

Untuk proyek yang semakin besar, MauiProgram.cs bisa jadi sangat panjang. Solusinya? Pakai extension methods untuk memisahkan registrasi berdasarkan jenis komponen:

// Extensions/ServiceCollectionExtensions.cs
using MauiStoreApp.Services;
using MauiStoreApp.ViewModels;
using MauiStoreApp.Views;

namespace MauiStoreApp.Extensions;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddAppServices(
        this IServiceCollection services)
    {
        // Core services
        services.AddSingleton<IProductService, ProductService>();
        services.AddSingleton<IAuthService, AuthService>();
        services.AddSingleton<INavigationService, ShellNavigationService>();
        services.AddSingleton<IDatabaseService, SqliteDatabaseService>();

        // HTTP clients
        services.AddHttpClient<IApiClient, ApiClient>(client =>
        {
            client.BaseAddress = new Uri("https://api.mystore.com/v1/");
        });

        return services;
    }

    public static IServiceCollection AddAppViewModels(
        this IServiceCollection services)
    {
        // Tab ViewModels (Singleton - tetap hidup)
        services.AddSingleton<HomeViewModel>();
        services.AddSingleton<CategoriesViewModel>();
        services.AddSingleton<SettingsViewModel>();

        // Detail ViewModels (Transient - fresh setiap navigasi)
        services.AddTransient<ProductListViewModel>();
        services.AddTransient<ProductDetailViewModel>();
        services.AddTransient<CartViewModel>();
        services.AddTransient<CheckoutViewModel>();
        services.AddTransient<OrderHistoryViewModel>();

        return services;
    }

    public static IServiceCollection AddAppViews(
        this IServiceCollection services)
    {
        // Tab Pages (Singleton)
        services.AddSingleton<HomePage>();
        services.AddSingleton<CategoriesPage>();
        services.AddSingleton<SettingsPage>();

        // Detail Pages (Transient)
        services.AddTransient<ProductListPage>();
        services.AddTransient<ProductDetailPage>();
        services.AddTransient<CartPage>();
        services.AddTransient<CheckoutPage>();
        services.AddTransient<OrderHistoryPage>();

        return services;
    }
}

Dengan pendekatan ini, MauiProgram.cs jadi jauh lebih bersih dan gampang dibaca:

// MauiProgram.cs yang bersih dengan extension methods
public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();

    builder
        .UseMauiApp<App>()
        .ConfigureFonts(fonts =>
        {
            fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
        });

    builder.Services.AddAppServices();
    builder.Services.AddAppViewModels();
    builder.Services.AddAppViews();

    return builder.Build();
}

Pola Constructor Injection

Constructor injection adalah cara utama (dan yang paling direkomendasikan) untuk menerima dependensi di .NET MAUI. DI container otomatis menyediakan semua dependensi yang diperlukan saat membuat instance kelas. Berikut contoh implementasi service yang memanfaatkan constructor injection sepenuhnya:

// Services/ProductService.cs
namespace MauiStoreApp.Services;

public interface IProductService
{
    Task<IEnumerable<Product>> GetProductsAsync();
    Task<Product?> GetProductByIdAsync(int id);
    Task<IEnumerable<Product>> SearchProductsAsync(string query);
    Task<IEnumerable<Product>> GetProductsByCategoryAsync(string category);
}

public class ProductService : IProductService
{
    private readonly IApiClient _apiClient;
    private readonly IDatabaseService _databaseService;
    private readonly IConnectivityService _connectivity;
    private readonly ILogger<ProductService> _logger;

    // DI container menyediakan semua parameter ini secara otomatis
    public ProductService(
        IApiClient apiClient,
        IDatabaseService databaseService,
        IConnectivityService connectivity,
        ILogger<ProductService> logger)
    {
        _apiClient = apiClient;
        _databaseService = databaseService;
        _connectivity = connectivity;
        _logger = logger;
    }

    public async Task<IEnumerable<Product>> GetProductsAsync()
    {
        try
        {
            // Cek koneksi - jika offline, ambil dari cache lokal
            if (!_connectivity.IsConnected)
            {
                _logger.LogInformation(
                    "Offline mode: memuat produk dari cache lokal");
                return await _databaseService
                    .GetCachedProductsAsync();
            }

            var products = await _apiClient
                .GetAsync<List<Product>>("products");

            // Simpan ke cache lokal untuk akses offline
            if (products?.Any() == true)
            {
                await _databaseService
                    .CacheProductsAsync(products);
            }

            return products ?? Enumerable.Empty<Product>();
        }
        catch (HttpRequestException ex)
        {
            _logger.LogWarning(ex,
                "Gagal mengambil produk dari API, fallback ke cache");
            return await _databaseService
                .GetCachedProductsAsync();
        }
    }

    public async Task<Product?> GetProductByIdAsync(int id)
    {
        try
        {
            return await _apiClient
                .GetAsync<Product>($"products/{id}");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "Gagal mengambil produk dengan ID {ProductId}", id);
            return null;
        }
    }

    public async Task<IEnumerable<Product>> SearchProductsAsync(
        string query)
    {
        return await _apiClient
            .GetAsync<List<Product>>(
                $"products/search?q={Uri.EscapeDataString(query)}")
            ?? Enumerable.Empty<Product>();
    }

    public async Task<IEnumerable<Product>> GetProductsByCategoryAsync(
        string category)
    {
        return await _apiClient
            .GetAsync<List<Product>>(
                $"products?category={Uri.EscapeDataString(category)}")
            ?? Enumerable.Empty<Product>();
    }
}

Satu hal yang penting: selalu gunakan interface saat mendaftarkan service, bukan implementasi konkret. Ini memudahkan penggantian implementasi — entah untuk testing dengan mock atau implementasi berbeda per platform.

Shell Navigation

Mengonfigurasi Shell Routing

.NET MAUI Shell menyediakan sistem navigasi terpusat yang mendukung navigasi berbasis URI, flyout menu, tab bar, dan pencarian terintegrasi. Dibanding mengelola navigation stack secara manual, Shell jauh lebih terstruktur dan konsisten di seluruh platform.

Langkah pertama adalah mendefinisikan struktur Shell di AppShell.xaml:

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

    <!-- Tab Bar untuk navigasi utama -->
    <TabBar>
        <ShellContent Title="Beranda"
                      Icon="icon_home.png"
                      ContentTemplate="{DataTemplate views:HomePage}"
                      Route="home" />

        <ShellContent Title="Kategori"
                      Icon="icon_categories.png"
                      ContentTemplate="{DataTemplate views:CategoriesPage}"
                      Route="categories" />

        <ShellContent Title="Keranjang"
                      Icon="icon_cart.png"
                      ContentTemplate="{DataTemplate views:CartPage}"
                      Route="cart" />

        <ShellContent Title="Pengaturan"
                      Icon="icon_settings.png"
                      ContentTemplate="{DataTemplate views:SettingsPage}"
                      Route="settings" />
    </TabBar>

Lalu daftarkan rute untuk halaman-halaman yang diakses lewat navigasi (bukan tab langsung) di code-behind:

// AppShell.xaml.cs
namespace MauiStoreApp;

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

        // Mendaftarkan rute untuk halaman detail
        // yang tidak ada di tab bar
        Routing.RegisterRoute("ProductList",
            typeof(Views.ProductListPage));
        Routing.RegisterRoute("ProductDetail",
            typeof(Views.ProductDetailPage));
        Routing.RegisterRoute("Checkout",
            typeof(Views.CheckoutPage));
        Routing.RegisterRoute("OrderHistory",
            typeof(Views.OrderHistoryPage));
        Routing.RegisterRoute("OrderDetail",
            typeof(Views.OrderDetailPage));
        Routing.RegisterRoute("Login",
            typeof(Views.LoginPage));
        Routing.RegisterRoute("Register",
            typeof(Views.RegisterPage));
    }
}

Navigasi Berbasis URI

Shell Navigation menggunakan sistem URI yang familiar. Kalau kamu pernah kerja dengan web routing, konsepnya mirip — dan itu yang bikin navigasinya deklaratif serta gampang dipahami:

// Navigasi dasar - push ke halaman baru
await Shell.Current.GoToAsync("ProductDetail");

// Navigasi absolut - kembali ke root dan navigasi
await Shell.Current.GoToAsync("//home");

// Navigasi mundur
await Shell.Current.GoToAsync("..");

// Navigasi mundur dua level
await Shell.Current.GoToAsync("../..");

// Navigasi bertingkat
await Shell.Current.GoToAsync("ProductList/ProductDetail");

Tapi, memanggil Shell.Current.GoToAsync langsung dari ViewModel itu bukan praktik terbaik. Kenapa? Karena ini bikin ViewModel bergantung langsung pada Shell, yang menyulitkan pengujian. Solusinya, kita bungkus dalam navigation service:

// Services/INavigationService.cs
namespace MauiStoreApp.Services;

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

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

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

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

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

Mengirim Data Antar Halaman dengan Query Parameters

.NET MAUI Shell mendukung dua cara mengirim data antar halaman: query string dan parameter dictionary. Untuk menerima parameter, ViewModel bisa implementasikan IQueryAttributable atau pakai atribut [QueryProperty].

Ini contoh ViewModel detail produk yang menerima parameter navigasi:

// ViewModels/ProductDetailViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MauiStoreApp.Models;
using MauiStoreApp.Services;

namespace MauiStoreApp.ViewModels;

// Menggunakan IQueryAttributable untuk fleksibilitas maksimal
public partial class ProductDetailViewModel
    : ObservableObject, IQueryAttributable
{
    private readonly IProductService _productService;
    private readonly ICartService _cartService;
    private readonly INavigationService _navigationService;

    public ProductDetailViewModel(
        IProductService productService,
        ICartService cartService,
        INavigationService navigationService)
    {
        _productService = productService;
        _cartService = cartService;
        _navigationService = navigationService;
    }

    [ObservableProperty]
    private Product? _product;

    [ObservableProperty]
    private bool _isLoading;

    [ObservableProperty]
    private int _quantity = 1;

    [ObservableProperty]
    private bool _isInCart;

    [ObservableProperty]
    private string _errorMessage = string.Empty;

    // Dipanggil otomatis oleh Shell saat navigasi
    public void ApplyQueryAttributes(
        IDictionary<string, object> query)
    {
        if (query.TryGetValue("ProductId", out var productIdObj)
            && productIdObj is int productId)
        {
            // Muat detail produk secara async
            MainThread.BeginInvokeOnMainThread(async () =>
            {
                await LoadProductAsync(productId);
            });
        }
    }

    private async Task LoadProductAsync(int productId)
    {
        try
        {
            IsLoading = true;
            Product = await _productService
                .GetProductByIdAsync(productId);
            IsInCart = await _cartService
                .IsInCartAsync(productId);
        }
        catch (Exception ex)
        {
            ErrorMessage = $"Gagal memuat detail produk: {ex.Message}";
        }
        finally
        {
            IsLoading = false;
        }
    }

    [RelayCommand]
    private async Task AddToCartAsync()
    {
        if (Product is null) return;

        try
        {
            await _cartService.AddToCartAsync(Product.Id, Quantity);
            IsInCart = true;

            // Tampilkan notifikasi sukses
            await Shell.Current.DisplayAlert(
                "Berhasil",
                $"{Product.Name} telah ditambahkan ke keranjang",
                "OK");
        }
        catch (Exception ex)
        {
            await Shell.Current.DisplayAlert(
                "Error",
                $"Gagal menambahkan ke keranjang: {ex.Message}",
                "OK");
        }
    }

    [RelayCommand]
    private async Task GoToCartAsync()
    {
        await _navigationService.NavigateToAsync("//cart");
    }

    [RelayCommand]
    private void IncrementQuantity()
    {
        if (Product is not null
            && Quantity < Product.StockQuantity)
        {
            Quantity++;
        }
    }

    [RelayCommand]
    private void DecrementQuantity()
    {
        if (Quantity > 1)
        {
            Quantity--;
        }
    }
}

Untuk mengirim data saat navigasi:

// Mengirim data dengan dictionary (mendukung tipe kompleks)
await Shell.Current.GoToAsync("ProductDetail",
    new Dictionary<string, object>
    {
        { "ProductId", selectedProduct.Id }
    });

// Atau dengan query string (hanya string)
await Shell.Current.GoToAsync(
    $"ProductDetail?ProductId={selectedProduct.Id}");

Pendekatan dictionary lebih disukai karena mendukung tipe data kompleks — objek, collection, bahkan tipe primitif tanpa perlu konversi string. Honestly, query string sebaiknya cuma dipakai untuk kasus yang benar-benar sederhana.

Navigasi dengan ViewModel yang Di-resolve melalui DI

Salah satu hal yang bikin .NET MAUI terasa "modern" adalah integrasi antara Shell Navigation dan DI container. Saat Shell menavigasi ke halaman, DI container otomatis membuat instance halaman beserta semua dependensinya — termasuk ViewModel yang di-inject lewat constructor.

Alurnya begini: Shell terima permintaan navigasi, minta DI container bikin instance Page, DI container lihat constructor Page butuh ViewModel, maka DI container juga bikin instance ViewModel beserta semua dependensinya secara rekursif. Semua terjadi otomatis tanpa kode manual. Cantik, kan?

// Views/ProductDetailPage.xaml.cs
namespace MauiStoreApp.Views;

public partial class ProductDetailPage : ContentPage
{
    // DI container secara otomatis menyediakan ViewModel
    public ProductDetailPage(ProductDetailViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}
<!-- Views/ProductDetailPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:viewmodels="clr-namespace:MauiStoreApp.ViewModels"
             x:Class="MauiStoreApp.Views.ProductDetailPage"
             x:DataType="viewmodels:ProductDetailViewModel"
             Title="Detail Produk">

    <ScrollView>
        <VerticalStackLayout Spacing="16" Padding="16">

            <!-- Loading State -->
            <ActivityIndicator IsRunning="{Binding IsLoading}"
                               IsVisible="{Binding IsLoading}"
                               HorizontalOptions="Center" />

            <!-- Product Image -->
            <Image Source="{Binding Product.ImageUrl}"
                   Aspect="AspectFit"
                   HeightRequest="300"
                   IsVisible="{Binding IsLoading,
                       Converter={StaticResource InvertedBoolConverter}}" />

            <!-- Product Info -->
            <Label Text="{Binding Product.Name}"
                   FontSize="24"
                   FontAttributes="Bold" />

            <Label Text="{Binding Product.Description}"
                   FontSize="15"
                   TextColor="Gray" />

            <Label Text="{Binding Product.Price,
                       StringFormat='Rp {0:N0}'}"
                   FontSize="22"
                   TextColor="Green"
                   FontAttributes="Bold" />

            <Label Text="{Binding Product.StockQuantity,
                       StringFormat='Stok tersedia: {0}'}"
                   FontSize="14"
                   TextColor="Gray" />

            <!-- Quantity Selector -->
            <HorizontalStackLayout Spacing="12"
                                   HorizontalOptions="Center">
                <Button Text="-"
                        Command="{Binding DecrementQuantityCommand}"
                        WidthRequest="48"
                        HeightRequest="48" />
                <Label Text="{Binding Quantity}"
                       FontSize="20"
                       VerticalOptions="Center"
                       HorizontalOptions="Center"
                       WidthRequest="48"
                       HorizontalTextAlignment="Center" />
                <Button Text="+"
                        Command="{Binding IncrementQuantityCommand}"
                        WidthRequest="48"
                        HeightRequest="48" />
            </HorizontalStackLayout>

            <!-- Add to Cart / Go to Cart -->
            <Button Text="Tambah ke Keranjang"
                    Command="{Binding AddToCartCommand}"
                    IsVisible="{Binding IsInCart,
                        Converter={StaticResource InvertedBoolConverter}}"
                    BackgroundColor="#4CAF50"
                    TextColor="White"
                    FontSize="18"
                    HeightRequest="52" />

            <Button Text="Lihat Keranjang"
                    Command="{Binding GoToCartCommand}"
                    IsVisible="{Binding IsInCart}"
                    BackgroundColor="#2196F3"
                    TextColor="White"
                    FontSize="18"
                    HeightRequest="52" />

        </VerticalStackLayout>
    </ScrollView>

Menyatukan Semuanya: Arsitektur Aplikasi Lengkap

Oke, sekarang mari kita lihat bagaimana semua komponen — MVVM, DI, dan Shell Navigation — bekerja bersama dalam satu arsitektur yang utuh. Berikut struktur folder yang saya rekomendasikan:

MauiStoreApp/
├── App.xaml
├── App.xaml.cs
├── AppShell.xaml
├── AppShell.xaml.cs
├── MauiProgram.cs
├── Models/
│   ├── Product.cs
│   ├── CartItem.cs
│   ├── Order.cs
│   └── User.cs
├── ViewModels/
│   ├── BaseViewModel.cs
│   ├── HomeViewModel.cs
│   ├── ProductListViewModel.cs
│   ├── ProductDetailViewModel.cs
│   ├── CartViewModel.cs
│   ├── CheckoutViewModel.cs
│   └── SettingsViewModel.cs
├── Views/
│   ├── HomePage.xaml / .xaml.cs
│   ├── ProductListPage.xaml / .xaml.cs
│   ├── ProductDetailPage.xaml / .xaml.cs
│   ├── CartPage.xaml / .xaml.cs
│   ├── CheckoutPage.xaml / .xaml.cs
│   └── SettingsPage.xaml / .xaml.cs
├── Services/
│   ├── IProductService.cs
│   ├── ProductService.cs
│   ├── INavigationService.cs
│   ├── ShellNavigationService.cs
│   ├── IAuthService.cs
│   ├── AuthService.cs
│   ├── IApiClient.cs
│   ├── ApiClient.cs
│   └── IDatabaseService.cs
├── Converters/
│   └── InvertedBoolConverter.cs
├── Extensions/
│   └── ServiceCollectionExtensions.cs
└── Resources/
    ├── Fonts/
    ├── Images/
    └── Styles/

Pertama, mari buat BaseViewModel sebagai fondasi untuk semua ViewModel lainnya:

// ViewModels/BaseViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;

namespace MauiStoreApp.ViewModels;

public partial class BaseViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsNotBusy))]
    private bool _isBusy;

    [ObservableProperty]
    private string _title = string.Empty;

    [ObservableProperty]
    private bool _hasError;

    [ObservableProperty]
    private string _errorMessage = string.Empty;

    public bool IsNotBusy => !IsBusy;

    protected async Task ExecuteBusyAction(
        Func<Task> action,
        string? errorContext = null)
    {
        if (IsBusy) return;

        try
        {
            IsBusy = true;
            HasError = false;
            ErrorMessage = string.Empty;
            await action();
        }
        catch (Exception ex)
        {
            HasError = true;
            ErrorMessage = errorContext is not null
                ? $"{errorContext}: {ex.Message}"
                : ex.Message;
        }
        finally
        {
            IsBusy = false;
        }
    }
}

Nah, ini bagian yang menarik. Berikut CartViewModel yang menunjukkan interaksi penuh antara MVVM, DI, dan navigasi — perhatikan bagaimana semuanya terhubung dengan rapi:

// ViewModels/CartViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MauiStoreApp.Models;
using MauiStoreApp.Services;
using System.Collections.ObjectModel;
using System.Collections.Specialized;

namespace MauiStoreApp.ViewModels;

public partial class CartViewModel : BaseViewModel
{
    private readonly ICartService _cartService;
    private readonly INavigationService _navigationService;
    private readonly IAuthService _authService;

    public CartViewModel(
        ICartService cartService,
        INavigationService navigationService,
        IAuthService authService)
    {
        _cartService = cartService;
        _navigationService = navigationService;
        _authService = authService;
        Title = "Keranjang Belanja";

        // Update total saat item berubah
        CartItems.CollectionChanged += OnCartItemsChanged;
    }

    [ObservableProperty]
    private ObservableCollection<CartItem> _cartItems = new();

    [ObservableProperty]
    private decimal _totalPrice;

    [ObservableProperty]
    private int _totalItems;

    [ObservableProperty]
    private bool _isEmpty = true;

    private void OnCartItemsChanged(
        object? sender, NotifyCollectionChangedEventArgs e)
    {
        RecalculateTotals();
    }

    private void RecalculateTotals()
    {
        TotalPrice = CartItems.Sum(
            item => item.Price * item.Quantity);
        TotalItems = CartItems.Sum(item => item.Quantity);
        IsEmpty = !CartItems.Any();
    }

    [RelayCommand]
    private async Task LoadCartAsync()
    {
        await ExecuteBusyAction(async () =>
        {
            var items = await _cartService.GetCartItemsAsync();

            CartItems.Clear();
            foreach (var item in items)
            {
                CartItems.Add(item);
            }
        }, "Gagal memuat keranjang");
    }

    [RelayCommand]
    private async Task RemoveItemAsync(CartItem item)
    {
        if (item is null) return;

        bool confirm = await Shell.Current.DisplayAlert(
            "Konfirmasi",
            $"Hapus {item.ProductName} dari keranjang?",
            "Hapus", "Batal");

        if (!confirm) return;

        await _cartService.RemoveFromCartAsync(item.ProductId);
        CartItems.Remove(item);
    }

    [RelayCommand]
    private async Task UpdateQuantityAsync(CartItem item)
    {
        if (item is null) return;

        await _cartService.UpdateQuantityAsync(
            item.ProductId, item.Quantity);
        RecalculateTotals();
    }

    [RelayCommand]
    private async Task ProceedToCheckoutAsync()
    {
        if (IsEmpty)
        {
            await Shell.Current.DisplayAlert(
                "Keranjang Kosong",
                "Tambahkan produk ke keranjang terlebih dahulu",
                "OK");
            return;
        }

        // Cek apakah pengguna sudah login
        if (!_authService.IsAuthenticated)
        {
            await _navigationService.NavigateToAsync("Login",
                new Dictionary<string, object>
                {
                    { "ReturnRoute", "Checkout" }
                });
            return;
        }

        await _navigationService.NavigateToAsync("Checkout");
    }

    [RelayCommand]
    private async Task ContinueShoppingAsync()
    {
        await _navigationService.GoBackToRootAsync();
    }
}

Dan terakhir, file App.xaml.cs yang simpel — cuma mengonfigurasi Shell sebagai halaman utama:

// App.xaml.cs
namespace MauiStoreApp;

public partial class App : Application
{
    public App()
    {
        InitializeComponent();
    }

    protected override Window CreateWindow(
        IActivationState? activationState)
    {
        return new Window(new AppShell());
    }
}

Praktik Terbaik dan Kesalahan Umum

Praktik Terbaik

Setelah cukup banyak trial-and-error (dan beberapa sesi debugging tengah malam), berikut hal-hal yang menurut saya paling penting:

  1. Selalu gunakan Compiled Bindings: Tambahkan x:DataType di setiap halaman dan DataTemplate. Ini bukan cuma soal performa — validasi saat kompilasi akan menangkap kesalahan binding sebelum runtime. Percayalah, ini menyelamatkan banyak waktu.
  2. Jangan taruh logika bisnis di code-behind: Code-behind cuma untuk hal yang benar-benar butuh referensi langsung ke elemen UI (animasi kompleks, misalnya). Sisanya harus di ViewModel.
  3. Gunakan interface untuk service: Selalu daftarkan service pakai interface. Ini memudahkan pengujian unit dan penggantian implementasi.
  4. Hindari async void: Kecuali untuk event handler, selalu pakai async Task. [RelayCommand] otomatis menangani ini.
  5. Gunakan MainThread.BeginInvokeOnMainThread dengan bijak: Update UI harus di main thread. ObservableProperty sudah menangani ini untuk properti sederhana, tapi operasi collection kadang butuh marshalling manual.
  6. Implementasikan IDisposable untuk cleanup: ViewModel yang subscribe ke event atau punya resource yang perlu dibersihkan harus implementasikan IDisposable.

Kesalahan Umum yang Harus Dihindari

Ini beberapa jebakan yang sering saya lihat (dan kadang masih terjebak juga):

  • Captive Dependency: Jangan inject Transient ke dalam Singleton. Lifetime Page dan ViewModel harus sesuai — keduanya Singleton atau keduanya Transient.
  • Memanggil async dari constructor: Constructor nggak boleh async. Gunakan metode terpisah seperti InitializeAsync yang dipanggil dari OnAppearing.
  • Memory leak pada event subscription: Kalau ViewModel subscribe ke event dari service Singleton, pastikan unsubscribe saat halaman di-dispose. Ini sumber memory leak yang klasik.
  • Tidak menangani state offline: Selalu sediakan fallback untuk operasi jaringan. Pengguna mobile sering di kondisi jaringan yang nggak stabil — ini realita yang harus kita terima.
  • Binding ke properti tanpa notifikasi: Pastikan semua properti yang di-bind ke UI pakai [ObservableProperty]. Binding ke properti biasa nggak akan memperbarui UI saat nilainya berubah.

Berikut contoh pola yang salah dan perbaikannya:

// SALAH: Async di constructor
public class BadViewModel : ObservableObject
{
    public BadViewModel(IProductService productService)
    {
        // Jangan lakukan ini! Fire-and-forget async
        // di constructor sangat berbahaya
        _ = LoadDataAsync(productService);
    }
}

// BENAR: Gunakan metode inisialisasi terpisah
public partial class GoodViewModel : ObservableObject
{
    private readonly IProductService _productService;

    public GoodViewModel(IProductService productService)
    {
        _productService = productService;
    }

    [RelayCommand]
    private async Task InitializeAsync()
    {
        // Dipanggil dari OnAppearing di code-behind
        await LoadDataAsync();
    }

    private async Task LoadDataAsync()
    {
        // Implementasi loading data yang aman
    }
}
// SALAH: Captive Dependency
// Page Singleton meng-inject ViewModel Transient
builder.Services.AddSingleton<HomePage>();       // Singleton
builder.Services.AddTransient<HomeViewModel>();   // Transient
// HomeViewModel hanya dibuat SEKALI karena HomePage adalah Singleton!

// BENAR: Lifetime yang konsisten
builder.Services.AddSingleton<HomePage>();
builder.Services.AddSingleton<HomeViewModel>();
// Atau keduanya Transient:
builder.Services.AddTransient<ProductDetailPage>();
builder.Services.AddTransient<ProductDetailViewModel>();

Pertimbangan Performa

Arsitektur yang bagus tanpa performa yang oke? Sayang sekali. Berikut beberapa pertimbangan performa yang berkaitan langsung dengan pola arsitektur yang sudah kita bahas.

Compiled Bindings dan NativeAOT

Compiled bindings bukan cuma soal kecepatan runtime. Mereka juga krusial untuk kompatibilitas dengan NativeAOT (Native Ahead-of-Time compilation). NativeAOT, yang didukung .NET MAUI untuk iOS dan Mac Catalyst, menghilangkan kebutuhan JIT compilation — artinya startup lebih cepat dan memori lebih hemat.

Tapi ada catch-nya: NativeAOT punya keterbatasan pada reflection. Binding tradisional yang bergantung pada reflection bisa gagal saat NativeAOT diaktifkan. Compiled bindings menyelesaikan masalah ini karena resolusi binding terjadi saat kompilasi.

<!-- Aktifkan NativeAOT di file .csproj -->
<PropertyGroup Condition="$(TargetFramework.Contains('ios'))
    OR $(TargetFramework.Contains('maccatalyst'))">
    <PublishAot>true</PublishAot>
    <MtouchInterpreter>-all</MtouchInterpreter>
</PropertyGroup>

Lazy Loading dan DI

Untuk aplikasi besar, mendaftarkan semua halaman dan ViewModel sekaligus di DI container bisa memperlambat startup. Gunakan Lazy<T> atau registrasi factory untuk menunda pembuatan objek berat:

// Registrasi dengan factory untuk lazy initialization
builder.Services.AddTransient<HeavyReportViewModel>(sp =>
{
    // Hanya dibuat saat benar-benar dibutuhkan
    var dataService = sp.GetRequiredService<IDataService>();
    var logger = sp.GetRequiredService<ILogger<HeavyReportViewModel>>();
    return new HeavyReportViewModel(dataService, logger);
});

// Menggunakan Lazy<T> untuk service yang berat
builder.Services.AddSingleton<Lazy<IAnalyticsService>>(sp =>
    new Lazy<IAnalyticsService>(() =>
        sp.GetRequiredService<IAnalyticsService>()));

Optimasi Collection di ViewModel

Saat bekerja dengan koleksi besar, jangan clear dan isi ulang ObservableCollection berulang-ulang. Setiap perubahan memicu notifikasi UI, dan itu bisa bikin aplikasi terasa lambat. Lebih baik ganti seluruh koleksi sekaligus:

// Kurang optimal: Banyak notifikasi UI
Products.Clear();
foreach (var product in newProducts)
{
    Products.Add(product); // Setiap Add memicu CollectionChanged
}

// Lebih optimal: Satu notifikasi
Products = new ObservableCollection<Product>(newProducts);

Menghindari Over-notification

CommunityToolkit.Mvvm punya atribut [NotifyPropertyChangedFor] untuk menghubungkan notifikasi antar properti. Gunakan dengan bijak supaya nggak terjadi cascade notification berlebihan:

// Properti yang saling terkait
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TotalPrice))]
[NotifyPropertyChangedFor(nameof(CanCheckout))]
private int _quantity;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TotalPrice))]
[NotifyPropertyChangedFor(nameof(CanCheckout))]
private decimal _unitPrice;

public decimal TotalPrice => Quantity * UnitPrice;
public bool CanCheckout => Quantity > 0 && TotalPrice > 0;

Oh, satu tips bonus: saat menggunakan [RelayCommand] pada metode async, command otomatis punya properti IsRunning yang bisa di-bind langsung. Jadi kamu nggak perlu bikin properti IsLoading terpisah untuk setiap command:

<!-- Binding langsung ke IsRunning dari command -->
<ActivityIndicator
    IsRunning="{Binding LoadProductsCommand.IsRunning}"
    IsVisible="{Binding LoadProductsCommand.IsRunning}" />

Pengujian Unit dengan Arsitektur MVVM dan DI

Salah satu alasan terbesar kenapa kita repot-repot setup arsitektur seperti ini: pengujian. Karena semua dependensi di-inject lewat constructor, mengganti implementasi dengan mock jadi sangat mudah. Ini yang bikin development jangka panjang jadi jauh lebih sustainable.

// Tests/ViewModels/ProductListViewModelTests.cs
using Moq;
using MauiStoreApp.Models;
using MauiStoreApp.Services;
using MauiStoreApp.ViewModels;

namespace MauiStoreApp.Tests.ViewModels;

public class ProductListViewModelTests
{
    private readonly Mock<IProductService> _mockProductService;
    private readonly Mock<INavigationService> _mockNavigationService;
    private readonly ProductListViewModel _viewModel;

    public ProductListViewModelTests()
    {
        _mockProductService = new Mock<IProductService>();
        _mockNavigationService = new Mock<INavigationService>();

        _viewModel = new ProductListViewModel(
            _mockProductService.Object,
            _mockNavigationService.Object);
    }

    [Fact]
    public async Task LoadProducts_ReturnsProducts_UpdatesCollection()
    {
        // Arrange
        var expectedProducts = new List<Product>
        {
            new() { Id = 1, Name = "Laptop", Price = 15000000 },
            new() { Id = 2, Name = "Mouse", Price = 250000 }
        };

        _mockProductService
            .Setup(s => s.GetProductsAsync())
            .ReturnsAsync(expectedProducts);

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

        // Assert
        Assert.Equal(2, _viewModel.Products.Count);
        Assert.Equal("Laptop", _viewModel.Products[0].Name);
        Assert.False(_viewModel.IsLoading);
        Assert.False(_viewModel.HasError);
    }

    [Fact]
    public async Task LoadProducts_OnError_SetsErrorState()
    {
        // Arrange
        _mockProductService
            .Setup(s => s.GetProductsAsync())
            .ThrowsAsync(new HttpRequestException("Network error"));

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

        // Assert
        Assert.True(_viewModel.HasError);
        Assert.Contains("Gagal memuat produk",
            _viewModel.ErrorMessage);
    }

    [Fact]
    public async Task NavigateToDetail_WithProduct_NavigatesCorrectly()
    {
        // Arrange
        var product = new Product { Id = 42, Name = "Keyboard" };

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

        // Assert
        _mockNavigationService.Verify(
            s => s.NavigateToAsync(
                "ProductDetail",
                It.Is<IDictionary<string, object>>(
                    d => (int)d["ProductId"] == 42)),
            Times.Once);
    }
}

Lihat betapa mudahnya menguji ViewModel secara terisolasi — tanpa UI, database, atau koneksi jaringan. Inilah manfaat utama dari pemisahan yang bersih lewat MVVM dan DI.

Kesimpulan

Arsitektur aplikasi .NET MAUI yang solid dibangun di atas tiga pilar: MVVM untuk pemisahan tanggung jawab, Dependency Injection untuk pengelolaan dependensi, dan Shell Navigation untuk navigasi berbasis URI.

Dengan CommunityToolkit.Mvvm, hampir seluruh kode boilerplate hilang. [ObservableProperty] dan [RelayCommand] bikin kita fokus ke logika bisnis. Compiled bindings dengan x:DataType memberikan performa optimal plus keamanan saat kompilasi.

DI container bawaan .NET MAUI menyediakan infrastruktur yang matang. Memahami lifetime (Singleton, Transient, Scoped) dan menghindari captive dependency adalah kunci stabilitas. Interface dan constructor injection bikin kode mudah diuji dan fleksibel.

Shell Navigation menyatukan semuanya — routing berbasis URI terintegrasi dengan DI container, halaman dan ViewModel otomatis di-resolve, dan data bisa dikirim antar halaman lewat IQueryAttributable.

Jujur saja, investasi waktu di awal proyek untuk merancang arsitektur yang baik itu terasa berat. Tapi pengembaliannya? Luar biasa — dalam hal produktivitas, kualitas kode, dan kemudahan maintenance jangka panjang. Ditambah kompatibilitas dengan fitur modern seperti NativeAOT, arsitektur ini benar-benar siap untuk masa depan mobile development.

Tentang Penulis Editorial Team

Our team of expert writers and editors.