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
- User taps Add →
AddCommand
(UI) - VM builds
AddExpenseCommand
(UI) → callsAddExpenseHandler
(Application) - Handler creates
Expense
withMoney
(Domain) IExpenseRepository.AddAsync
persists viaEfExpenseRepository
+ExpensesDbContext
(Infrastructure)- VM reloads
GetRecentAsync
and updatesItems
(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 fakeIExpenseRepository
(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 aIToastService
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 aBackgroundService
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
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.
In Application. Keep them close to use cases. UI maps VM <–> DTOs; Infrastructure maps entities <–> tables/wire.
They can. They shouldn’t. It couples UI to storage and kills testability.
Raise in Domain; handle in Application (e.g., publish to a queue) or Infrastructure (e.g., telemetry). Don’t inject loggers into entities.
UI. Presentational only. Parsing/formatting that affects business rules belongs in Application/Domain.
Infrastructure. Wrap platform APIs behind interfaces (e.g., ICamera
, IStorageAccess
) and inject them into handlers.
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.