Mastering Navigation and Dependency Injection in .NET MAUI: Shell Routing, MVVM, and Service Lifetimes

A practical guide to .NET MAUI Shell navigation and dependency injection. Learn how to structure routes, pass data between pages, build testable navigation services, and avoid common pitfalls with service lifetimes in mobile apps.

Navigation and dependency injection — these two things basically make or break every serious .NET MAUI app. Get them right, and everything feels snappy, your code stays testable, and adding new features is actually enjoyable. Get them wrong, and you're stuck fighting memory leaks, tangled page hierarchies, and view models that somehow know way too much about each other.

I've spent a lot of time working through these patterns, and honestly, the difference between a well-structured MAUI app and a messy one almost always comes down to how navigation and DI are set up.

This guide covers both topics in depth — from Shell routing fundamentals to advanced navigation patterns, from basic service registration to the subtle pitfalls of service lifetimes in a mobile context. Along the way, you'll build real, production-grade patterns you can drop into your projects today. Whether you're starting a fresh .NET MAUI app or migrating from Xamarin.Forms, the patterns here apply directly to .NET MAUI 9 and the upcoming .NET 10 release.

Understanding .NET MAUI Shell Navigation

Shell is the recommended navigation container for most .NET MAUI applications. It gives you a URI-based routing system, a built-in flyout menu, tab bars, and a search handler — all wired together in a declarative structure. If you've worked with ASP.NET Core routing, the mental model transfers surprisingly well.

The Shell Visual Hierarchy

Before diving into routing, it helps to understand how Shell organizes content. The hierarchy runs Shell → ShellItem (FlyoutItem/TabBar) → ShellSection (Tab) → ShellContent → ContentPage. Each level maps to a specific navigation pattern:

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

    <!-- Flyout items appear in the hamburger menu -->
    <FlyoutItem Title="Dashboard" Icon="dashboard.png">
        <ShellContent ContentTemplate="{DataTemplate views:DashboardPage}" />
    </FlyoutItem>

    <FlyoutItem Title="Projects" Icon="projects.png">
        <!-- Tabs within a flyout item -->
        <Tab Title="Active">
            <ShellContent ContentTemplate="{DataTemplate views:ActiveProjectsPage}" />
        </Tab>
        <Tab Title="Archived">
            <ShellContent ContentTemplate="{DataTemplate views:ArchivedProjectsPage}" />
        </Tab>
    </FlyoutItem>

    <FlyoutItem Title="Settings" Icon="settings.png">
        <ShellContent ContentTemplate="{DataTemplate views:SettingsPage}" />
    </FlyoutItem>
</Shell>

Notice the use of ContentTemplate instead of setting Content directly. This is a performance optimization — the page isn't instantiated until the user actually navigates to it, which reduces startup time and memory usage. It's a small thing, but it adds up fast in apps with many pages.

Route Registration: Global vs. Implicit

Shell creates implicit routes for every ShellContent in your XAML. These routes follow the visual hierarchy path. But for detail pages — pages that aren't part of the top-level navigation structure — you register explicit global routes:

// AppShell.xaml.cs
public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();

        // Register detail pages as global routes
        Routing.RegisterRoute("project/details", typeof(ProjectDetailsPage));
        Routing.RegisterRoute("project/edit", typeof(ProjectEditPage));
        Routing.RegisterRoute("project/task", typeof(TaskDetailsPage));
        Routing.RegisterRoute("profile/edit", typeof(EditProfilePage));
    }
}

Global routes are resolved relative to your current position in the navigation stack. When you call Shell.Current.GoToAsync("project/details"), Shell pushes the page onto the current stack. When you use an absolute route like Shell.Current.GoToAsync("//Dashboard"), Shell navigates to that position in the visual hierarchy, potentially popping or switching the entire stack.

Passing Data with Query Parameters

Shell supports passing data between pages through query parameters appended to the URI. You've got two approaches here: string-based parameters for simple values, and object-based parameters for complex data.

String-Based Query Parameters

// Navigate with string query parameters
await Shell.Current.GoToAsync($"project/details?projectId={project.Id}&mode=readonly");

// Receive in the target page using IQueryAttributable (recommended)
public partial class ProjectDetailsPage : ContentPage, IQueryAttributable
{
    private readonly ProjectDetailsViewModel _viewModel;

    public ProjectDetailsPage(ProjectDetailsViewModel viewModel)
    {
        InitializeComponent();
        _viewModel = viewModel;
        BindingContext = _viewModel;
    }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("projectId", out var idObj)
            && int.TryParse(idObj?.ToString(), out var projectId))
        {
            _viewModel.LoadProject(projectId);
        }

        if (query.TryGetValue("mode", out var modeObj))
        {
            _viewModel.IsReadOnly = modeObj?.ToString() == "readonly";
        }
    }
}

The IQueryAttributable interface is the recommended approach because it's compatible with trimming and NativeAOT compilation. The older [QueryProperty] attribute relies on reflection, which conflicts with .NET 10's push toward ahead-of-time compilation. So if you're still using [QueryProperty], now's a good time to switch.

Object-Based Parameters for Complex Data

For passing complex objects, use ShellNavigationQueryParameters — a dictionary designed for single-use navigation data:

// Pass a complex object during navigation
var parameters = new ShellNavigationQueryParameters
{
    { "project", selectedProject },
    { "permissions", userPermissions }
};

await Shell.Current.GoToAsync("project/edit", parameters);

// Receive in the target ViewModel (also implementing IQueryAttributable)
public class ProjectEditViewModel : ObservableObject, IQueryAttributable
{
    private Project _project;
    private UserPermissions _permissions;

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("project", out var projectObj)
            && projectObj is Project project)
        {
            _project = project;
            Title = project.Name;
            // Populate form fields...
        }

        if (query.TryGetValue("permissions", out var permObj)
            && permObj is UserPermissions perms)
        {
            _permissions = perms;
            CanEditTitle = perms.HasPermission("project.edit.title");
        }
    }
}

ShellNavigationQueryParameters are cleared after navigation completes. This prevents stale data from leaking during subsequent navigations — a bug that plagued a lot of earlier Xamarin.Forms apps.

Dependency Injection Fundamentals in .NET MAUI

.NET MAUI builds on the same Microsoft.Extensions.DependencyInjection container used in ASP.NET Core. This is a huge advantage — the patterns, the API surface, and even the NuGet packages are identical. But here's the thing: mobile applications have unique lifecycle characteristics that change how you should think about service lifetimes.

Registering Services in MauiProgram

All service registration happens in MauiProgram.CreateMauiApp():

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

        // --- Services ---
        builder.Services.AddSingleton<IConnectivityService, ConnectivityService>();
        builder.Services.AddSingleton<ISettingsService, SettingsService>();
        builder.Services.AddSingleton<IDatabaseService, SqliteDatabaseService>();

        // HTTP clients
        builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
        {
            client.BaseAddress = new Uri("https://api.myapp.com/v2/");
            client.DefaultRequestHeaders.Add("Accept", "application/json");
        });

        // --- ViewModels ---
        builder.Services.AddTransient<DashboardViewModel>();
        builder.Services.AddTransient<ProjectDetailsViewModel>();
        builder.Services.AddTransient<ProjectEditViewModel>();
        builder.Services.AddTransient<SettingsViewModel>();

        // --- Pages ---
        builder.Services.AddTransient<DashboardPage>();
        builder.Services.AddTransient<ProjectDetailsPage>();
        builder.Services.AddTransient<ProjectEditPage>();
        builder.Services.AddTransient<SettingsPage>();

        return builder.Build();
    }
}

This is where many developers hit their first decision point: should pages and view models be transient, singleton, or scoped? The answer depends on the page's role, but the short version is — transient is almost always what you want.

Service Lifetimes: Singleton, Transient, and Scoped

Understanding lifetimes is critical in a mobile context because they behave differently than in a web application:

  • Singleton — One instance for the entire application lifetime. Created on first resolution and reused for every subsequent request. Use this for services that maintain shared state (database connections, settings, connectivity monitoring) and services that are expensive to create.
  • Transient — A new instance every time the service is requested. Each page navigation, each constructor injection, each GetService<T>() call creates a fresh object. Use this for pages and view models to avoid stale state between navigations.
  • Scoped — In ASP.NET Core, a scope is tied to an HTTP request. In .NET MAUI, there's no built-in per-request scope, so scoped services behave like singletons by default unless you create scopes manually. This catches a lot of developers migrating from web backgrounds off guard.

Here's a practical illustration of why this matters:

// WRONG: Singleton page retains stale data between navigations
builder.Services.AddSingleton<ProjectDetailsPage>();
builder.Services.AddSingleton<ProjectDetailsViewModel>();

// User navigates to Project A → sees Project A data
// User goes back, navigates to Project B → still sees Project A data!

// CORRECT: Transient page and ViewModel get fresh instances
builder.Services.AddTransient<ProjectDetailsPage>();
builder.Services.AddTransient<ProjectDetailsViewModel>();

// Each navigation creates a new page and ViewModel
// Data is always fresh

The Scoped Lifetime Trap

One of the most common mistakes when migrating from ASP.NET Core (or even Xamarin.Forms) is misusing scoped services. In a web context, Entity Framework's DbContext is typically registered as scoped — one instance per HTTP request. In .NET MAUI, registering DbContext as scoped means it effectively becomes a singleton, which can lead to threading issues when multiple view models try to use the same context concurrently.

I've seen this cause some really weird bugs that are hard to track down.

// PROBLEMATIC in MAUI: Scoped acts like Singleton
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite("Data Source=myapp.db"));
// DbContext is registered as Scoped by default!

// BETTER: Use a factory pattern
builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(sp =>
{
    var options = new DbContextOptionsBuilder<AppDbContext>()
        .UseSqlite($"Data Source={Path.Combine(FileSystem.AppDataDirectory, "myapp.db")}")
        .Options;
    return new PooledDbContextFactory<AppDbContext>(options);
});

// Usage in a ViewModel
public class ProjectListViewModel : ObservableObject
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public ProjectListViewModel(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public async Task LoadProjectsAsync()
    {
        // Each operation gets its own short-lived context
        await using var context = await _contextFactory.CreateDbContextAsync();
        var projects = await context.Projects
            .OrderByDescending(p => p.UpdatedAt)
            .ToListAsync();
        Projects = new ObservableCollection<Project>(projects);
    }
}

Connecting Navigation and DI: The Full Pattern

The real power of .NET MAUI's architecture shows up when Shell navigation and dependency injection work together. When you register a page type with both Routing.RegisterRoute and the DI container, Shell automatically resolves the page from the container during navigation — injecting all its dependencies.

This is where things start to feel really elegant.

Constructor Injection in Pages

public partial class ProjectDetailsPage : ContentPage, IQueryAttributable
{
    private readonly ProjectDetailsViewModel _viewModel;

    // Shell resolves this from the DI container automatically
    public ProjectDetailsPage(ProjectDetailsViewModel viewModel)
    {
        InitializeComponent();
        _viewModel = viewModel;
        BindingContext = _viewModel;
    }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        // Delegate parameter handling to the ViewModel
        if (_viewModel is IQueryAttributable attributable)
        {
            attributable.ApplyQueryAttributes(query);
        }
    }
}

public class ProjectDetailsViewModel : ObservableObject, IQueryAttributable
{
    private readonly IApiClient _apiClient;
    private readonly IConnectivityService _connectivity;

    public ProjectDetailsViewModel(
        IApiClient apiClient,
        IConnectivityService connectivity)
    {
        _apiClient = apiClient;
        _connectivity = connectivity;
    }

    public async void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("projectId", out var idObj)
            && int.TryParse(idObj?.ToString(), out var id))
        {
            await LoadProjectAsync(id);
        }
    }

    private async Task LoadProjectAsync(int projectId)
    {
        IsLoading = true;
        try
        {
            if (_connectivity.IsConnected)
            {
                Project = await _apiClient.GetProjectAsync(projectId);
            }
            else
            {
                ErrorMessage = "No internet connection. Showing cached data.";
                // Fall back to local cache...
            }
        }
        catch (Exception ex)
        {
            ErrorMessage = $"Failed to load project: {ex.Message}";
        }
        finally
        {
            IsLoading = false;
        }
    }

    [ObservableProperty]
    private Project _project;

    [ObservableProperty]
    private bool _isLoading;

    [ObservableProperty]
    private string _errorMessage;
}

This pattern creates a clean separation: the page handles only UI concerns, while the view model handles data loading and business logic. Every dependency is injected, making the view model fully unit-testable.

Building a Navigation Service for Decoupled ViewModels

Direct calls to Shell.Current.GoToAsync() inside view models create a tight coupling to the .NET MAUI framework. Your tests would need a running Shell instance, and that's just not practical. The solution? A navigation service abstraction:

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

// Implement using Shell
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)
    {
        var shellParams = new ShellNavigationQueryParameters(parameters);
        await Shell.Current.GoToAsync(route, shellParams);
    }

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

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

// Register in MauiProgram
builder.Services.AddSingleton<INavigationService, ShellNavigationService>();

// Use in ViewModels
public class ProjectListViewModel : ObservableObject
{
    private readonly INavigationService _navigation;
    private readonly IApiClient _apiClient;

    public ProjectListViewModel(
        INavigationService navigation,
        IApiClient apiClient)
    {
        _navigation = navigation;
        _apiClient = apiClient;
    }

    [RelayCommand]
    private async Task ViewProjectAsync(Project project)
    {
        var parameters = new Dictionary<string, object>
        {
            { "projectId", project.Id.ToString() }
        };
        await _navigation.NavigateToAsync("project/details", parameters);
    }

    [RelayCommand]
    private async Task CreateProjectAsync()
    {
        await _navigation.NavigateToAsync("project/edit");
    }
}

Now your view model depends on INavigationService instead of Shell. In unit tests, you just mock the interface and verify that the correct routes and parameters were passed. Simple.

Replacing MessagingCenter with WeakReferenceMessenger

With .NET 10, MessagingCenter has been made internal — it's no longer publicly accessible. The recommended replacement is WeakReferenceMessenger from the CommunityToolkit.Mvvm package. And this isn't just a cosmetic rename: WeakReferenceMessenger is over 100x more efficient and uses weak references to prevent memory leaks automatically.

Defining Messages

// Define strongly-typed messages
public class ProjectUpdatedMessage : ValueChangedMessage<Project>
{
    public ProjectUpdatedMessage(Project project) : base(project) { }
}

public class UserLoggedOutMessage
{
    public static readonly UserLoggedOutMessage Instance = new();
}

public class NavigationRequestMessage : ValueChangedMessage<string>
{
    public NavigationRequestMessage(string route) : base(route) { }
}

Sending and Receiving Messages

// Sending a message (e.g., from ProjectEditViewModel after saving)
public class ProjectEditViewModel : ObservableObject
{
    [RelayCommand]
    private async Task SaveProjectAsync()
    {
        var savedProject = await _apiClient.UpdateProjectAsync(Project);

        // Notify other ViewModels that a project was updated
        WeakReferenceMessenger.Default.Send(new ProjectUpdatedMessage(savedProject));

        await _navigation.GoBackAsync();
    }
}

// Receiving a message (e.g., in ProjectListViewModel)
public class ProjectListViewModel : ObservableObject
{
    public ProjectListViewModel(
        INavigationService navigation,
        IApiClient apiClient)
    {
        _navigation = navigation;
        _apiClient = apiClient;

        // Register for project update messages
        WeakReferenceMessenger.Default.Register<ProjectUpdatedMessage>(
            this,
            (recipient, message) =>
            {
                var vm = (ProjectListViewModel)recipient;
                vm.OnProjectUpdated(message.Value);
            });
    }

    private void OnProjectUpdated(Project updatedProject)
    {
        var existing = Projects.FirstOrDefault(p => p.Id == updatedProject.Id);
        if (existing is not null)
        {
            var index = Projects.IndexOf(existing);
            Projects[index] = updatedProject;
        }
    }
}

Avoiding the Duplicate Message Trap

Here's a pitfall that bites a lot of people: duplicate message delivery when using transient pages. Each time you navigate to a transient page, a new instance registers for messages — but the weak reference to the previous instance might not be collected yet. The result? Your handler fires multiple times.

The fix is explicit cleanup:

public partial class ProjectListPage : ContentPage
{
    private readonly ProjectListViewModel _viewModel;

    public ProjectListPage(ProjectListViewModel viewModel)
    {
        InitializeComponent();
        _viewModel = viewModel;
        BindingContext = _viewModel;
    }

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

        // Unregister all messages when the page disappears
        WeakReferenceMessenger.Default.UnregisterAll(_viewModel);
    }

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

        // Re-register when the page appears
        _viewModel.RegisterMessages();
    }
}

Platform-Specific Service Injection

.NET MAUI provides several approaches for injecting platform-specific implementations. The cleanest pattern (in my opinion) uses partial classes with platform-specific implementations:

Approach 1: Interface with Platform Implementations

// Shared interface
public interface IBiometricService
{
    Task<bool> IsAvailableAsync();
    Task<bool> AuthenticateAsync(string reason);
}

// Platforms/Android/Services/BiometricService.cs
public class BiometricService : IBiometricService
{
    public async Task<bool> IsAvailableAsync()
    {
        var context = Platform.CurrentActivity;
        var biometricManager = BiometricManager.From(context);
        var result = biometricManager.CanAuthenticate(
            BiometricManager.Authenticators.BiometricStrong);
        return result == BiometricManager.BiometricSuccess;
    }

    public async Task<bool> AuthenticateAsync(string reason)
    {
        // Android-specific biometric implementation
        var tcs = new TaskCompletionSource<bool>();
        // ... BiometricPrompt setup ...
        return await tcs.Task;
    }
}

// Platforms/iOS/Services/BiometricService.cs
public class BiometricService : IBiometricService
{
    public Task<bool> IsAvailableAsync()
    {
        var context = new LAContext();
        return Task.FromResult(
            context.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthenticationWithBiometrics,
            out _));
    }

    public async Task<bool> AuthenticateAsync(string reason)
    {
        var context = new LAContext();
        var (success, _) = await context.EvaluatePolicyAsync(
            LAPolicy.DeviceOwnerAuthenticationWithBiometrics,
            reason);
        return success;
    }
}

// Register with conditional compilation in MauiProgram
builder.Services.AddSingleton<IBiometricService, BiometricService>();

Because each platform has its own BiometricService class file under the Platforms folder, the correct implementation gets compiled for each target automatically. The shared code only ever references the IBiometricService interface.

Approach 2: Conditional Registration

For cases where only some platforms support a feature:

// MauiProgram.cs
#if ANDROID || IOS
    builder.Services.AddSingleton<IBiometricService, BiometricService>();
#else
    builder.Services.AddSingleton<IBiometricService, NullBiometricService>();
#endif

Advanced Pattern: ViewModel-First Navigation

Some teams prefer a ViewModel-first navigation approach where you navigate to a ViewModel type and the framework resolves the corresponding page automatically. This inverts the typical pattern and can be cleaner for complex applications:

// Enhanced navigation service with ViewModel-first support
public interface INavigationService
{
    Task NavigateToAsync<TViewModel>() where TViewModel : ObservableObject;
    Task NavigateToAsync<TViewModel>(IDictionary<string, object> parameters)
        where TViewModel : ObservableObject;
    Task GoBackAsync();
}

public class ShellNavigationService : INavigationService
{
    // Map ViewModel types to route names
    private static readonly Dictionary<Type, string> _routes = new()
    {
        [typeof(ProjectDetailsViewModel)] = "project/details",
        [typeof(ProjectEditViewModel)] = "project/edit",
        [typeof(TaskDetailsViewModel)] = "project/task",
        [typeof(EditProfileViewModel)] = "profile/edit",
    };

    public async Task NavigateToAsync<TViewModel>()
        where TViewModel : ObservableObject
    {
        if (_routes.TryGetValue(typeof(TViewModel), out var route))
        {
            await Shell.Current.GoToAsync(route);
        }
        else
        {
            throw new InvalidOperationException(
                $"No route registered for {typeof(TViewModel).Name}");
        }
    }

    public async Task NavigateToAsync<TViewModel>(
        IDictionary<string, object> parameters)
        where TViewModel : ObservableObject
    {
        if (_routes.TryGetValue(typeof(TViewModel), out var route))
        {
            var shellParams = new ShellNavigationQueryParameters(parameters);
            await Shell.Current.GoToAsync(route, shellParams);
        }
        else
        {
            throw new InvalidOperationException(
                $"No route registered for {typeof(TViewModel).Name}");
        }
    }

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

// Usage becomes more expressive
await _navigation.NavigateToAsync<ProjectDetailsViewModel>(
    new Dictionary<string, object> { { "projectId", "42" } });

The big win here is compile-time safety on navigation targets. A typo in a route string fails silently at runtime, but a typo in a generic type argument? That's a compile error. You'll catch it immediately.

Testing Your Navigation and DI Setup

One of the biggest benefits of the patterns above is testability. With dependencies abstracted behind interfaces and navigation decoupled from Shell, writing thorough unit tests becomes straightforward:

public class ProjectListViewModelTests
{
    private readonly Mock<INavigationService> _mockNavigation;
    private readonly Mock<IApiClient> _mockApiClient;
    private readonly ProjectListViewModel _viewModel;

    public ProjectListViewModelTests()
    {
        _mockNavigation = new Mock<INavigationService>();
        _mockApiClient = new Mock<IApiClient>();
        _viewModel = new ProjectListViewModel(
            _mockNavigation.Object,
            _mockApiClient.Object);
    }

    [Fact]
    public async Task ViewProject_NavigatesToDetailsWithCorrectId()
    {
        // Arrange
        var project = new Project { Id = 42, Name = "Test Project" };

        // Act
        await _viewModel.ViewProjectCommand.ExecuteAsync(project);

        // Assert
        _mockNavigation.Verify(n => n.NavigateToAsync(
            "project/details",
            It.Is<IDictionary<string, object>>(
                d => d["projectId"].ToString() == "42")),
            Times.Once);
    }

    [Fact]
    public async Task LoadProjects_SetsProjectsCollection()
    {
        // Arrange
        var projects = new List<Project>
        {
            new() { Id = 1, Name = "Alpha" },
            new() { Id = 2, Name = "Beta" },
        };
        _mockApiClient
            .Setup(a => a.GetProjectsAsync())
            .ReturnsAsync(projects);

        // Act
        await _viewModel.LoadProjectsAsync();

        // Assert
        Assert.Equal(2, _viewModel.Projects.Count);
        Assert.Equal("Alpha", _viewModel.Projects[0].Name);
    }

    [Fact]
    public void OnProjectUpdated_UpdatesExistingProjectInList()
    {
        // Arrange
        var original = new Project { Id = 1, Name = "Original" };
        _viewModel.Projects = new ObservableCollection<Project> { original };

        var updated = new Project { Id = 1, Name = "Updated" };

        // Act — simulate receiving a message
        WeakReferenceMessenger.Default.Send(new ProjectUpdatedMessage(updated));

        // Assert
        Assert.Equal("Updated", _viewModel.Projects[0].Name);
    }
}

Real-World Architecture: Putting It All Together

So, let's bring everything together into a cohesive architecture you can actually use as a template for production applications.

Project Structure

MyApp/
├── MauiProgram.cs          // DI registration
├── AppShell.xaml/.cs       // Shell structure + route registration
├── App.xaml/.cs            // Application lifecycle
├── Models/
│   ├── Project.cs
│   └── UserProfile.cs
├── ViewModels/
│   ├── BaseViewModel.cs    // Shared ViewModel logic
│   ├── DashboardViewModel.cs
│   ├── ProjectListViewModel.cs
│   └── ProjectDetailsViewModel.cs
├── Views/
│   ├── DashboardPage.xaml/.cs
│   ├── ProjectListPage.xaml/.cs
│   └── ProjectDetailsPage.xaml/.cs
├── Services/
│   ├── INavigationService.cs
│   ├── ShellNavigationService.cs
│   ├── IApiClient.cs
│   ├── ApiClient.cs
│   ├── IConnectivityService.cs
│   └── ConnectivityService.cs
├── Messages/
│   ├── ProjectUpdatedMessage.cs
│   └── UserLoggedOutMessage.cs
└── Platforms/
    ├── Android/Services/
    └── iOS/Services/

Base ViewModel with Common Functionality

public abstract class BaseViewModel : ObservableObject
{
    [ObservableProperty]
    private bool _isBusy;

    [ObservableProperty]
    private string _title;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(HasError))]
    private string _errorMessage;

    public bool HasError => !string.IsNullOrEmpty(ErrorMessage);

    protected async Task ExecuteBusyActionAsync(
        Func<Task> action,
        string errorContext = "An error occurred")
    {
        if (IsBusy) return;

        IsBusy = true;
        ErrorMessage = null;

        try
        {
            await action();
        }
        catch (HttpRequestException ex)
        {
            ErrorMessage = $"{errorContext}: Network error — {ex.Message}";
        }
        catch (TaskCanceledException)
        {
            ErrorMessage = $"{errorContext}: Request timed out.";
        }
        catch (Exception ex)
        {
            ErrorMessage = $"{errorContext}: {ex.Message}";
        }
        finally
        {
            IsBusy = false;
        }
    }
}

Complete Registration in MauiProgram

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

        // Core services — Singletons
        builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
        builder.Services.AddSingleton<IConnectivityService, ConnectivityService>();
        builder.Services.AddSingleton<ISettingsService, SettingsService>();

        // HTTP client with resilience
        builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
        {
            client.BaseAddress = new Uri("https://api.myapp.com/v2/");
            client.Timeout = TimeSpan.FromSeconds(30);
        })
        .AddStandardResilienceHandler();

        // ViewModels — Transient
        builder.Services.AddTransient<DashboardViewModel>();
        builder.Services.AddTransient<ProjectListViewModel>();
        builder.Services.AddTransient<ProjectDetailsViewModel>();
        builder.Services.AddTransient<ProjectEditViewModel>();

        // Pages — Transient
        builder.Services.AddTransient<DashboardPage>();
        builder.Services.AddTransient<ProjectListPage>();
        builder.Services.AddTransient<ProjectDetailsPage>();
        builder.Services.AddTransient<ProjectEditPage>();

        // Platform-specific services
#if ANDROID || IOS
        builder.Services.AddSingleton<IBiometricService, BiometricService>();
#endif

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

        return builder.Build();
    }
}

Common Pitfalls and How to Avoid Them

After building (and debugging) plenty of .NET MAUI applications, certain anti-patterns keep showing up. Here are the ones that trip people up the most:

1. Singleton Pages with Transient ViewModels (or Vice Versa)

If a page is singleton but its ViewModel is transient, the page captures the first ViewModel and never gets a new one. The reverse — transient page with singleton ViewModel — means every new page instance shares the same state. Keep page and ViewModel lifetimes aligned. Almost always, transient is the right call.

2. Navigating on the Wrong Thread

Shell navigation must happen on the main thread. If you trigger navigation from a background task, you'll get cryptic exceptions. Wrap it like this:

await MainThread.InvokeOnMainThreadAsync(async () =>
{
    await Shell.Current.GoToAsync("project/details?projectId=42");
});

3. Circular Dependencies

As your service graph grows, you might accidentally create circular dependencies (Service A depends on Service B, which depends on Service A). The DI container will throw at runtime, and the error message isn't always obvious. Use Lazy<T> or refactor to break the cycle:

// Break circular dependency with Lazy<T>
public class ServiceA
{
    private readonly Lazy<ServiceB> _serviceB;

    public ServiceA(Lazy<ServiceB> serviceB)
    {
        _serviceB = serviceB;
    }

    public void DoWork()
    {
        // ServiceB is resolved only when accessed
        _serviceB.Value.Process();
    }
}

4. Not Disposing Heavy Resources

Transient services that hold unmanaged resources (file handles, network connections) need explicit disposal. Implement IDisposable and dispose in the page's OnDisappearing:

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

    if (BindingContext is IDisposable disposable)
    {
        disposable.Dispose();
    }
}

5. Service Locator Anti-Pattern

Avoid resolving services manually through Application.Current.Handler.MauiContext.Services.GetService<T>(). It hides dependencies, makes testing harder, and bypasses the DI container's lifetime management. Always prefer constructor injection — it's more explicit and way easier to reason about.

Key Takeaways

Building a well-architected .NET MAUI application comes down to a few core principles:

  • Use Shell for navigation and register detail pages as global routes. Prefer IQueryAttributable for receiving parameters — it's NativeAOT compatible and trim-safe.
  • Register pages and ViewModels as transient unless you have a specific reason to keep them alive. Singletons are for services with shared state.
  • Abstract navigation behind an interface so ViewModels remain testable and decoupled from the framework.
  • Replace MessagingCenter with WeakReferenceMessenger — it's required for .NET 10 and significantly more efficient.
  • Watch out for the scoped lifetime trap — it doesn't behave the same way in MAUI as it does in ASP.NET Core.
  • Use platform-specific folders and conditional compilation to inject platform services cleanly.
  • Test your ViewModels by mocking injected services. The patterns in this article make that straightforward.

These patterns form the backbone of maintainable .NET MAUI applications. They scale from small utility apps to complex enterprise solutions, and they integrate naturally with the broader .NET ecosystem. Start with the basics — register your services, set up Shell routes, inject your dependencies — and layer on more advanced patterns like ViewModel-first navigation and messaging as your application grows.

About the Author Editorial Team

Our team of expert writers and editors.