Clean Architecture for .NET MAUI (No Hand‑Waving)

Clean Architecture for .NET MAUI: Practical Guide with IOptions, Feature Folders, and UI

Step‑by‑step Clean Architecture for .NET MAUI: layers, DI with IOptions, feature folders, MVVM, SQLite, and a truly dumb UI.

.NET Development MAUI·By amarozka · September 10, 2025

Clean Architecture for .NET MAUI: Practical Guide with IOptions, Feature Folders, and UI

Are you sure your MAUI app is “clean”… or is it secretly a spaghetti bowl in MVVM clothing? In this post we’ll build a real, working Clean Architecture for .NET MAUI – no fluff, no vague arrows. Just practical layers, DI with IOptions<T>, feature folders, and a UI that’s dumb but delightful.

Why you should care (in 20 seconds)

When your app grows, the Page code-behind becomes a junk drawer: navigation, validation, HTTP calls, DB queries, feature flags – everything lives together. Clean Architecture puts domain and use cases at the center, keeps infrastructure replaceable, and makes UI thin (read: testable, swappable, lovable).

You’ll get:

  • A repeatable folder layout that scales.
  • IOptions<T> to centralize environment/config.
  • Feature folders so changes live in one place.
  • A UI: bindings, commands, no business logic.

Demo feature we’ll implement along the way: a tiny Expenses module (list + add) on SQLite.

The final picture (Clean Architecture rings)

Inner layers know nothing about outer layers.

[ UI (MAUI) ]  -> depends on ->  [ Application ]  -> depends on ->  [ Domain ]
        |                                 ^                              ^
        v                                 |                              |
[ Infrastructure (SQLite, HTTP, etc.) ] ———— implements ———————— interfaces
  • Domain: Entities, value objects, domain events. Pure C#.
  • Application: Use cases (commands/queries), ports (interfaces) the infrastructure must implement.
  • Infrastructure: EF Core/SQLite, HTTP clients, file storage. Depends on Application contracts.
  • UI (MAUI): Pages + ViewModels. Only coordinates user intent → calls application.

Rule of thumb: if you can run Domain + Application in a console app with fake adapters, you’re clean.

Solution & folder layout (feature-first where it matters)

I like a single repo with projects per layer and feature folders inside UI:

src/
  Expenses.Domain/
  Expenses.Application/
  Expenses.Infrastructure/
  Expenses.UI/                # .NET MAUI project
    Features/
      Expenses/
        List/
          RecentExpensesPage.xaml
          RecentExpensesPage.xaml.cs
          RecentExpensesVm.cs
        Add/
          AddExpenseForm.xaml
          AddExpenseVm.cs
    App.xaml
    AppShell.xaml

Why feature folders in UI? Because the UI changes the most. Grouping View + ViewModel + resources together reduces hop time.

Domain layer (entities, value objects, events)

Keep it boring. Boring is good here.

Value Object: Money

namespace Expenses.Domain.Primitives;

public sealed record Money(decimal Amount, string Currency)
{
    public static Money From(decimal amount, string currency)
        => new(amount, currency.ToUpperInvariant());

    public Money Add(Money other)
    {
        if (other.Currency != Currency)
            throw new InvalidOperationException("Currency mismatch");
        return this with { Amount = Amount + other.Amount };
    }
}

Entity + Domain Event

namespace Expenses.Domain.Expenses;

public interface IDomainEvent { DateTime OccurredOnUtc { get; } }

public sealed record ExpenseAdded(Guid ExpenseId, decimal Amount) : IDomainEvent
{
    public DateTime OccurredOnUtc { get; } = DateTime.UtcNow;
}

public sealed class Expense
{
    public Guid Id { get; private set; } = Guid.NewGuid();
    public string Title { get; private set; }
    public Money Money { get; private set; }
    public DateOnly Date { get; private set; }
    public string Category { get; private set; }

    private readonly List<IDomainEvent> _events = new();
    public IReadOnlyCollection<IDomainEvent> Events => _events.AsReadOnly();

    public Expense(string title, Money money, DateOnly date, string category)
    {
        Title = string.IsNullOrWhiteSpace(title)
            ? throw new ArgumentException("Title required", nameof(title))
            : title.Trim();
        Money = money ?? throw new ArgumentNullException(nameof(money));
        Date = date;
        Category = string.IsNullOrWhiteSpace(category) ? "General" : category.Trim();

        _events.Add(new ExpenseAdded(Id, Money.Amount));
    }
}

Why events? They decouple side effects (telemetry, notifications) from core mutations. You can handle them later in Application/Infrastructure without polluting Expense.

Application layer (use cases, ports)

Think: “What can the app do?” not “How do we draw the page?”

Ports (interfaces)

namespace Expenses.Application.Abstractions;

using Expenses.Domain.Expenses;

public interface IExpenseRepository
{
    Task AddAsync(Expense expense, CancellationToken ct);
    Task<IReadOnlyList<Expense>> GetRecentAsync(int take, CancellationToken ct);
}

Command (use case) + handler

namespace Expenses.Application.Expenses.Add;

using Expenses.Application.Abstractions;
using Expenses.Domain.Expenses;
using Expenses.Domain.Primitives;

public sealed record AddExpenseCommand(
    string Title, decimal Amount, string Currency, DateOnly Date, string Category);

public sealed class AddExpenseHandler
{
    private readonly IExpenseRepository _repo;
    public AddExpenseHandler(IExpenseRepository repo) => _repo = repo;

    public async Task Handle(AddExpenseCommand cmd, CancellationToken ct)
    {
        if (cmd.Amount <= 0) throw new ArgumentOutOfRangeException(nameof(cmd.Amount));

        var money = Money.From(cmd.Amount, cmd.Currency);
        var expense = new Expense(cmd.Title, money, cmd.Date, cmd.Category);

        await _repo.AddAsync(expense, ct);
    }
}

Note: No EF, no HTTP, no UI here – just decisions. Swap handler wiring or repo without touching this code.

Infrastructure layer (EF Core + SQLite)

Infrastructure depends on Application to implement ports.

Options for storage (with IOptions<T>)

namespace Expenses.Infrastructure.Configuration;

public sealed class StorageOptions
{
    public string DatabaseName { get; set; } = "expenses.db";
    public bool EnsureCreated { get; set; } = true;
}

EF Core Context

namespace Expenses.Infrastructure.Persistence;

using Expenses.Domain.Expenses;
using Expenses.Domain.Primitives;
using Microsoft.EntityFrameworkCore;

public sealed class ExpensesDbContext : DbContext
{
    public DbSet<Expense> Expenses => Set<Expense>();
    public ExpensesDbContext(DbContextOptions<ExpensesDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder b)
    {
        b.Entity<Expense>(e =>
        {
            e.HasKey(x => x.Id);
            e.OwnsOne(x => x.Money, m =>
            {
                m.Property(p => p.Amount).HasColumnName("Amount").HasPrecision(18, 2);
                m.Property(p => p.Currency).HasColumnName("Currency").HasMaxLength(3);
            });
            e.Property(x => x.Title).HasMaxLength(128);
            e.Property(x => x.Category).HasMaxLength(64);
        });
    }
}

Repository implementation

namespace Expenses.Infrastructure.Persistence;

using Expenses.Application.Abstractions;
using Expenses.Domain.Expenses;
using Microsoft.EntityFrameworkCore;

public sealed class EfExpenseRepository : IExpenseRepository
{
    private readonly ExpensesDbContext _db;
    public EfExpenseRepository(ExpensesDbContext db) => _db = db;

    public async Task AddAsync(Expense expense, CancellationToken ct)
    {
        _db.Expenses.Add(expense);
        await _db.SaveChangesAsync(ct);
    }

    public async Task<IReadOnlyList<Expense>> GetRecentAsync(int take, CancellationToken ct)
    {
        return await _db.Expenses.AsNoTracking()
            .OrderByDescending(e => e.Date)
            .Take(take)
            .ToListAsync(ct);
    }
}

Database initialization (honoring IOptions<StorageOptions>)

namespace Expenses.Infrastructure.Persistence;

using Expenses.Infrastructure.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;

public sealed class DbInitializer
{
    private readonly ExpensesDbContext _db;
    private readonly IOptions<StorageOptions> _options;

    public DbInitializer(ExpensesDbContext db, IOptions<StorageOptions> options)
    {
        _db = db; _options = options;
    }

    public async Task InitializeAsync()
    {
        if (_options.Value.EnsureCreated)
        {
            await _db.Database.EnsureCreatedAsync();
        }
    }
}

UI layer (MAUI)

UI = bindings + commands. No DB, no HTTP, no business rules.

ViewModel for the list page

namespace Expenses.UI.Features.Expenses.List;

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using Expenses.Application.Abstractions;
using Expenses.Application.Expenses.Add;
using Expenses.Domain.Expenses;

public sealed class RecentExpensesVm : INotifyPropertyChanged
{
    private readonly AddExpenseHandler _addExpense;
    private readonly IExpenseRepository _repo;

    public ObservableCollection<ExpenseItem> Items { get; } = new();

    private string _title = string.Empty;
    public string Title { get => _title; set { _title = value; OnPropertyChanged(); } }

    private decimal _amount;
    public decimal Amount { get => _amount; set { _amount = value; OnPropertyChanged(); } }

    private string _currency = "USD";
    public string Currency { get => _currency; set { _currency = value; OnPropertyChanged(); } }

    private string _category = "General";
    public string Category { get => _category; set { _category = value; OnPropertyChanged(); } }

    private DateTime _date = DateTime.Today;
    public DateTime Date { get => _date; set { _date = value; OnPropertyChanged(); } }

    public ICommand AddCommand { get; }

    public RecentExpensesVm(AddExpenseHandler addExpense, IExpenseRepository repo)
    {
        _addExpense = addExpense;
        _repo = repo;
        AddCommand = new Command(async () => await AddAsync());
    }

    public async Task LoadAsync()
    {
        Items.Clear();
        var list = await _repo.GetRecentAsync(20, CancellationToken.None);
        foreach (var e in list)
            Items.Add(new ExpenseItem(e.Id, e.Title, e.Money.Amount, e.Money.Currency,
                e.Date.ToDateTime(TimeOnly.MinValue)));
    }

    private async Task AddAsync()
    {
        var cmd = new AddExpenseCommand(Title, Amount, Currency, DateOnly.FromDateTime(Date), Category);
        await _addExpense.Handle(cmd, CancellationToken.None);
        await LoadAsync();

        Title = string.Empty; Amount = 0; // reset form
    }

    public event PropertyChangedEventHandler? PropertyChanged;
    private void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

public readonly record struct ExpenseItem(Guid Id, string Title, decimal Amount, string Currency, DateTime Date);

XAML – List + Add form (single page)

<!-- Expenses.UI/Features/Expenses/List/RecentExpensesPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Expenses.UI.Features.Expenses.List.RecentExpensesPage"
             Title="Expenses">
    <ScrollView>
        <VerticalStackLayout Padding="16" Spacing="12">
            <Label Text="Add Expense" FontSize="18" FontAttributes="Bold"/>

            <Grid ColumnDefinitions="*,Auto" RowSpacing="8">
                <Entry Placeholder="Title" Text="{Binding Title}" Grid.ColumnSpan="2"/>
                <Entry Placeholder="Amount" Keyboard="Numeric" Text="{Binding Amount}" />
                <Entry Placeholder="Currency" Text="{Binding Currency}" Grid.Column="1" WidthRequest="80"/>
                <Entry Placeholder="Category" Text="{Binding Category}" Grid.ColumnSpan="2"/>
                <DatePicker Date="{Binding Date}" Grid.ColumnSpan="2"/>
            </Grid>

            <Button Text="Add" Command="{Binding AddCommand}"/>

            <Label Text="Recent" FontSize="18" FontAttributes="Bold"/>
            <CollectionView ItemsSource="{Binding Items}">
                <CollectionView.ItemTemplate>
                    <DataTemplate>
                        <Grid ColumnDefinitions="*,Auto" Padding="8">
                            <VerticalStackLayout>
                                <Label Text="{Binding Title}" FontAttributes="Bold"/>
                                <Label Text="{Binding Date, StringFormat='{}{0:yyyy-MM-dd}'}" Opacity="0.7"/>
                            </VerticalStackLayout>
                            <Label Grid.Column="1" Text="{Binding Amount}" />
                        </Grid>
                    </DataTemplate>
                </CollectionView.ItemTemplate>
            </CollectionView>
        </VerticalStackLayout>
    </ScrollView>
</ContentPage>

Code-behind (no logic besides wiring)

namespace Expenses.UI.Features.Expenses.List;

public partial class RecentExpensesPage : ContentPage
{
    private readonly RecentExpensesVm _vm;
    public RecentExpensesPage(RecentExpensesVm vm)
    {
        InitializeComponent();
        BindingContext = _vm = vm;
    }

    protected override async void OnAppearing()
    {
        base.OnAppearing();
        await _vm.LoadAsync();
    }
}

That’s what UI looks like: XAML for visuals, VM for state/commands, zero business logic in code-behind.

Wiring everything with DI + IOptions<T> in MauiProgram

using Expenses.Application.Abstractions;
using Expenses.Application.Expenses.Add;
using Expenses.Infrastructure.Configuration;
using Expenses.Infrastructure.Persistence;
using Expenses.UI.Features.Expenses.List;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;

namespace Expenses.UI;

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

        // 1) Configuration (can be json, env, secrets; demo uses in-memory)
        builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["Storage:DatabaseName"] = "expenses.db",
            ["Storage:EnsureCreated"] = "true"
        });

        // 2) Options pattern
        builder.Services.AddOptions<StorageOptions>()
            .Bind(builder.Configuration.GetSection("Storage"))
            .Validate(o => !string.IsNullOrWhiteSpace(o.DatabaseName), "DatabaseName is required");

        // 3) EF Core + SQLite with path from options
        builder.Services.AddDbContext<ExpensesDbContext>((sp, opt) =>
        {
            var storage = sp.GetRequiredService<IOptions<StorageOptions>>().Value;
            var path = Path.Combine(FileSystem.AppDataDirectory, storage.DatabaseName);
            opt.UseSqlite($"Data Source={path}");
        });

        // 4) Repositories & use cases
        builder.Services.AddScoped<IExpenseRepository, EfExpenseRepository>();
        builder.Services.AddScoped<AddExpenseHandler>();

        // 5) UI VMs + Pages
        builder.Services.AddTransient<RecentExpensesVm>();
        builder.Services.AddTransient<RecentExpensesPage>();

        // 6) DB init helper
        builder.Services.AddSingleton<DbInitializer>();

        var app = builder.Build();

        // Ensure DB exists early (no async void here; fire-and-forget controlled)
        _ = Task.Run(async () =>
        {
            using var scope = app.Services.CreateScope();
            var init = scope.ServiceProvider.GetRequiredService<DbInitializer>();
            await init.InitializeAsync();
        });

        return app;
    }
}

Injecting IServiceProvider into App (optional)

You can also inject and call initialization from App constructor if you prefer lifecycle control:

public partial class App : Application
{
    public App(RecentExpensesPage startPage)
    {
        InitializeComponent();
        MainPage = new AppShell { CurrentItem = new NavigationPage(startPage) };
    }
}

Why this is clean (and stays clean at scale)

  • UI: no repositories, no EF, no HTTP, no rules beyond formatting & commands.
  • Application is the API of your app: ViewModels don’t know if data comes from SQLite today or REST tomorrow.
  • Infrastructure is replaceable: keep EF Core/Dapper/HTTP here, behind interfaces.
  • Options rule: switching DB name or toggling migrations doesn’t touch code that adds an expense.
  • Feature folders in UI: when you change Expenses, you touch only Features/Expenses/* (and sometimes Application/Infrastructure). No scavenger hunt.

End‑to‑end flow: adding an expense

  1. User taps AddAddCommand (UI)
  2. VM builds AddExpenseCommand (UI) → calls AddExpenseHandler (Application)
  3. Handler creates Expense with Money (Domain)
  4. IExpenseRepository.AddAsync persists via EfExpenseRepository + ExpensesDbContext (Infrastructure)
  5. VM reloads GetRecentAsync and updates Items (UI)

At no point does the UI know how it’s stored. That’s the point.

Testing strategy (tiny but mighty)

  • Domain tests: Expense constructor guards, Money.Add currency mismatch.
  • Application tests: AddExpenseHandler with a fake IExpenseRepository (in‑memory list).
  • UI tests: XAML compiled bindings, RecentExpensesVm.AddAsync behavior using a fake handler.

Example: application test with fake repo

public sealed class FakeRepo : IExpenseRepository
{
    public List<Expense> Saved { get; } = new();
    public Task AddAsync(Expense e, CancellationToken ct) { Saved.Add(e); return Task.CompletedTask; }
    public Task<IReadOnlyList<Expense>> GetRecentAsync(int take, CancellationToken ct)
        => Task.FromResult((IReadOnlyList<Expense>)Saved.TakeLast(take).ToList());
}

[Fact]
public async Task AddExpense_SavesEntity()
{
    var repo = new FakeRepo();
    var sut = new AddExpenseHandler(repo);

    await sut.Handle(new("Coffee", 3.5m, "USD", DateOnly.FromDateTime(DateTime.Today), "Food"), default);

    Assert.Single(repo.Saved);
    Assert.Equal("Coffee", repo.Saved[0].Title);
}

Practical tweaks & pitfalls

  • Validation: push business validation to the Domain/Application. UI should only guard UX (e.g., numeric keyboards). Consider FluentValidation in Application if rules grow.
  • Navigation: keep it in UI. A handler returns a result (e.g., new ID); VM decides navigation.
  • Async void: avoid in VMs. Use async Task commands and catch/report errors (e.g., to a IToastService in Infrastructure).
  • Migrations: for simple mobile storage, EnsureCreated is OK. For real apps, add EF migrations and run them in an initializer service.
  • Background sync: model it as an Application use case (e.g., SyncExpensesHandler) and call from a platform service (Infrastructure) or a BackgroundService analogue.
  • Theming: styles go to UI layer only. Don’t leak domain colors/fonts into Application.

What to copy into your project (checklist)

  • Four projects: Domain, Application, Infrastructure, UI (MAUI)
  • IOptions<T> for storage/config
  • Application ports + handlers
  • Infrastructure adapters (EF Core, HTTP, etc.) implementing ports
  • Feature folders in UI with Page + ViewModel per feature
  • UI: no business logic in code-behind

FAQ: Clean Architecture in MAUI in real life

Do I need MediatR?

Nice to have, not required. I showed a plain handler to keep concepts clear. Add MediatR when your use-case list grows and you want pipelines/behaviors.

Where do DTOs live?

In Application. Keep them close to use cases. UI maps VM <–> DTOs; Infrastructure maps entities <–> tables/wire.

Can ViewModels talk to DbContext?

They can. They shouldn’t. It couples UI to storage and kills testability.

How do I handle domain events?

Raise in Domain; handle in Application (e.g., publish to a queue) or Infrastructure (e.g., telemetry). Don’t inject loggers into entities.

Where do I put converters/formatters?

UI. Presentational only. Parsing/formatting that affects business rules belongs in Application/Domain.

What about platform-specific code (Android/iOS permissions)?

Infrastructure. Wrap platform APIs behind interfaces (e.g., ICamera, IStorageAccess) and inject them into handlers.

How big should a feature folder be?

Small and cohesive: a page, its VM, and related resource files. If it grows, split List, Details, Edit subfolders.

Conclusion: Clean code that survives version 3.0

If you keep only one thing, keep this: Domain & Application first, everything else second. With IOptions<T> for config, feature folders for speed, and a UI that’s delightfully dumb, your MAUI app will stay change-friendly when the product manager inevitably says, “One more feature.”

What part of this setup do you want me to package as a ready‑to‑run sample next: the EF Core bit, the handlers with MediatR, or the MVVM layer? Drop a comment and I’ll prioritize the next post.

Leave a Reply

Your email address will not be published. Required fields are marked *