skills/maui-architecture/SKILL.md
Extension of dotnet-architecture for .NET MAUI apps. Covers MAUI-specific layers: SQLite model attributes, AutoMapper profiles, AppDatabase registration, ViewModels (CommunityToolkit.Mvvm), XAML Pages, Shell navigation, and MauiProgram.cs DI. Use AFTER or TOGETHER WITH dotnet-architecture for the backend layers (DTO, Domain, Infra.Interfaces, Infra, Application).
npx skillsauth add landim32/awesome-ai-skills maui-architectureInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
You are an expert assistant that helps developers implement the MAUI presentation layer for entities whose backend layers (DTO, Domain, Infra.Interfaces, Infra, Application) follow the Clean Architecture defined in the dotnet-architecture skill.
This skill is an EXTENSION — it covers ONLY MAUI-specific concerns. For backend layers, invoke or follow dotnet-architecture.
The user will describe the entity to create or modify: $ARGUMENTS
dotnet-architecture first (or confirm backend layers already exist) — this skill assumes DTO, Domain Model, Repository Interface, Domain Service, Infra Repository, and Application DI are already in place.dotnet sln list and ls to discover project names and folder layout.{App} stands for the actual root namespace. Replace it everywhere.| Layer | Responsibility |
|-------|---------------|
| Infra — AutoMapper (Mappings) | Profile mapping Domain Model ↔ DTO in Infra/Mappings/ |
| Infra — AppDatabase | SQLite table registration |
| MAUI — ViewModels | MVVM with CommunityToolkit.Mvvm, binds to DTOs |
| MAUI — Pages | XAML + code-behind with DI |
| MAUI — Shell | Navigation registration |
| MAUI — MauiProgram.cs | DI for Repos, AppServices, ViewModels, Pages |
{Entity}Info)DependencyInjection.cs)DB (SQLite) → Repository → Model (Domain) → AutoMapper → {Entity}Info (DTO) → ViewModel → Page
Page → ViewModel → {Entity}Info (DTO) → AutoMapper → Model (Domain) → Repository → DB
{Entity}Info), never to Domain Models directly[PrimaryKey], [AutoIncrement], [Table], [Indexed]) — this is the only infrastructure concern allowed on Domain Models in MAUI appsCommunityToolkit.Mvvm source generators: [ObservableObject], [ObservableProperty], [RelayCommand]Packages: sqlite-net-pcl + SQLitePCLRaw.bundle_green, CommunityToolkit.Mvvm, AutoMapper.Extensions.Microsoft.DependencyInjection
{App}.Infra/Mappings/{Entity}Profile.csRecommended: Use AutoMapper (
AutoMapper.Extensions.Microsoft.DependencyInjection) to map between Domain Models and DTOs. It eliminates repetitive manual mapping code, reduces bugs from forgotten properties, and keeps the mapping logic centralized in Profile classes. Register once withAddAutoMapper(assembly)and all Profiles are auto-discovered.
using AutoMapper;
using {App}.DTOs;
using {App}.Models;
namespace {App}.Mappings;
public class {Entity}Profile : Profile
{
public {Entity}Profile()
{
CreateMap<{Entity}, {Entity}Info>(); // Model → DTO (read)
CreateMap<{Entity}Info, {Entity}>() // DTO → Model (write)
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore());
}
}
Conventions:
{App}.Infra/Mappings/CreatedAt, UpdatedAt) — the entity's Update()/Create() methods control thoseAddAutoMapper(assembly) in DI{App}.Infra/Context/AppDatabase.csAdd to InitializeAsync(): await _database.CreateTableAsync<{Entity}>();
{App}/ViewModels/{Entity}ListViewModel.cspublic partial class {Entity}ListViewModel : ObservableObject
{
private readonly I{Entity}Repository _{entity}Repository;
private readonly IMapper _mapper;
public {Entity}ListViewModel(I{Entity}Repository repo, IMapper mapper)
{ _{entity}Repository = repo; _mapper = mapper; }
[ObservableProperty] private ObservableCollection<{Entity}Info> _items = [];
[ObservableProperty] private bool _isLoading;
[ObservableProperty] private bool _isEmpty;
[RelayCommand]
private async Task LoadItemsAsync()
{
IsLoading = true;
try
{
var models = await _{entity}Repository.GetAllAsync();
Items = new(_mapper.Map<List<{Entity}Info>>(models));
IsEmpty = Items.Count == 0;
}
finally { IsLoading = false; }
}
[RelayCommand]
private async Task DeleteAsync({Entity}Info item)
{
if (!await Shell.Current.DisplayAlert("Delete", $"Delete \"{item.Name}\"?", "Yes", "No")) return;
await _{entity}Repository.DeleteAsync(item.Id); Items.Remove(item); IsEmpty = Items.Count == 0;
}
[RelayCommand]
private async Task GoToDetailAsync({Entity}Info item) =>
await Shell.Current.GoToAsync("{Entity}DetailPage", new Dictionary<string, object> { { "{Entity}Info", item } });
}
{App}/ViewModels/{Entity}DetailViewModel.cspublic partial class {Entity}DetailViewModel : ObservableObject, IQueryAttributable
{
private readonly I{Entity}Repository _{entity}Repository;
private readonly IMapper _mapper;
public {Entity}DetailViewModel(I{Entity}Repository repo, IMapper mapper)
{ _{entity}Repository = repo; _mapper = mapper; }
[ObservableProperty] private int _{entity}Id;
[ObservableProperty] private string _name = string.Empty;
[ObservableProperty] private bool _isSaving;
[ObservableProperty] private bool _isNewItem = true;
[ObservableProperty] private string _pageTitle = "New {Entity}";
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("{Entity}Info", out var obj) && obj is {Entity}Info info)
{ {Entity}Id = info.Id; Name = info.Name; IsNewItem = false; PageTitle = "Edit {Entity}"; }
}
[RelayCommand]
private async Task SaveAsync()
{
IsSaving = true;
try
{
var entity = IsNewItem ? {Entity}.Create(Name) : (await _{entity}Repository.GetByIdAsync({Entity}Id))!;
if (!IsNewItem) entity.Update(Name);
var error = entity.Validate();
if (error != null) { await Shell.Current.DisplayAlert("Error", error, "OK"); return; }
await _{entity}Repository.SaveAsync(entity);
await Shell.Current.GoToAsync("..");
}
catch (InvalidOperationException ex) { await Shell.Current.DisplayAlert("Error", ex.Message, "OK"); }
finally { IsSaving = false; }
}
[RelayCommand] private async Task GoBackAsync() => await Shell.Current.GoToAsync("..");
}
ViewModel conventions:
{Entity}Info (DTO), not ModelsIMapper injected for conversions[ObservableProperty] on _camelCase fields[RelayCommand] on {Method}Async methods{App}/Pages/{Entity}ListPage.xaml<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:{App}.ViewModels"
xmlns:dto="clr-namespace:{App}.DTOs;assembly={App}.DTO"
x:Class="{App}.Pages.{Entity}ListPage"
x:DataType="vm:{Entity}ListViewModel"
Title="{Entities}">
<Grid RowDefinitions="*,Auto" Padding="16">
<ActivityIndicator Grid.Row="0" IsRunning="{Binding IsLoading}" IsVisible="{Binding IsLoading}"
HorizontalOptions="Center" VerticalOptions="Center" />
<Label Grid.Row="0" Text="No items yet" IsVisible="{Binding IsEmpty}"
FontSize="18" HorizontalOptions="Center" VerticalOptions="Center" />
<CollectionView Grid.Row="0" ItemsSource="{Binding Items}" SelectionMode="None">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="dto:{Entity}Info">
<SwipeView>
<SwipeView.RightItems><SwipeItems>
<SwipeItem Text="Delete" BackgroundColor="#E53935"
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:{Entity}ListViewModel}}, Path=DeleteCommand}"
CommandParameter="{Binding}" />
</SwipeItems></SwipeView.RightItems>
<Frame Margin="0,4" Padding="16" CornerRadius="12" BorderColor="Transparent">
<Frame.GestureRecognizers>
<TapGestureRecognizer
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:{Entity}ListViewModel}}, Path=GoToDetailCommand}"
CommandParameter="{Binding}" />
</Frame.GestureRecognizers>
<Label Text="{Binding Name}" FontSize="16" FontAttributes="Bold" />
</Frame>
</SwipeView>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<Button Grid.Row="1" Text="+ New" Command="{Binding GoToDetailCommand}"
FontSize="16" HeightRequest="56" CornerRadius="28" Margin="0,12,0,0" />
</Grid>
</ContentPage>
XAML conventions:
assembly={App}.DTO in xmlnsx:DataType for compiled bindings{App}/Pages/{Entity}ListPage.xaml.cspublic partial class {Entity}ListPage : ContentPage
{
private readonly {Entity}ListViewModel _viewModel;
public {Entity}ListPage({Entity}ListViewModel viewModel)
{ InitializeComponent(); BindingContext = _viewModel = viewModel; }
protected override async void OnAppearing()
{ base.OnAppearing(); await _viewModel.LoadItemsCommand.ExecuteAsync(null); }
}
{App}/MauiProgram.cs// AutoMapper — scans Infra assembly for all Profiles (register ONCE)
builder.Services.AddAutoMapper(typeof(AppDatabase).Assembly);
// Application Services (Domain Services — registered via Application project)
builder.Services.AddApplicationServices();
// Infrastructure
builder.Services.AddSingleton<I{Entity}AppService, {Entity}AppService>(); // AppService (if needed)
builder.Services.AddSingleton<I{Entity}Repository, {Entity}Repository>(); // Repository
// MAUI Presentation
builder.Services.AddTransient<{Entity}ListViewModel>(); // ViewModels
builder.Services.AddTransient<{Entity}DetailViewModel>();
builder.Services.AddTransient<{Entity}ListPage>(); // Pages
builder.Services.AddTransient<{Entity}DetailPage>();
Key points:
AddApplicationServices() comes from {App}.Application project — registers all Domain ServicesMauiProgram.csAppShellIn .xaml: <ShellContent Title="{Entities}" ContentTemplate="{DataTemplate pages:{Entity}ListPage}" Route="{Entity}ListPage" />
In .xaml.cs: Routing.RegisterRoute("{Entity}DetailPage", typeof({Entity}DetailPage));
{App}.Tests/Services/{Entity}RepositoryTests.cspublic class {Entity}RepositoryTests : IAsyncLifetime
{
private AppDatabase _database = null!;
private {Entity}Repository _repository = null!;
private string _dbPath = null!;
public async Task InitializeAsync()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.db3");
_database = new AppDatabase(_dbPath); await _database.InitializeAsync();
_repository = new {Entity}Repository(_database);
}
public Task DisposeAsync() { if (File.Exists(_dbPath)) File.Delete(_dbPath); return Task.CompletedTask; }
[Fact] public async Task SaveAsync_Insert() { Assert.Equal(1, await _repository.SaveAsync({Entity}.Create("Test"))); }
[Fact] public async Task GetAllAsync() { /* save 2, assert count == 2 */ }
[Fact] public async Task SaveAsync_Update() { /* save, Update(), save again, assert new value */ }
[Fact] public async Task DeleteAsync() { /* save, delete, get returns null */ }
// Mapper tests
[Fact] public void MapModelToInfo()
{
var config = new MapperConfiguration(cfg => cfg.AddProfile<{Entity}Profile>());
var mapper = config.CreateMapper();
var model = {Entity}.Create("Test");
var info = mapper.Map<{Entity}Info>(model);
Assert.Equal(model.Name, info.Name);
}
}
| # | Layer | File | Notes |
|---|-------|------|-------|
| 0 | Backend | Run dotnet-architecture skill | DTO, Domain, Infra.Interfaces, Infra Repo, Application DI |
| 1 | Infra | {App}.Infra/Mappings/{Entity}Profile.cs | AutoMapper Profile: Model ↔ Info |
| 2 | Infra | Modify AppDatabase.cs → CreateTableAsync<{Entity}>() | SQLite table |
| 3-4 | MAUI | ViewModels/{Entity}ListViewModel.cs + {Entity}DetailViewModel.cs | MVVM |
| 5-6 | MAUI | Pages/{Entity}ListPage.xaml(.cs) + {Entity}DetailPage.xaml(.cs) | UI |
| 7 | MAUI | Modify MauiProgram.cs | DI: Repos, AppServices, VMs, Pages + AddApplicationServices() |
| 8 | MAUI | Modify AppShell.xaml + .xaml.cs | Navigation |
| 9 | Tests | {Entity}RepositoryTests.cs + mapper tests | Verification |
dotnet-architecture first.dotnet sln list, read existing entities to match patternsInfra/Mappings/MauiProgram.cs, scans Infra assemblytools
Guides how to integrate the zTools package for ChatGPT, DALL-E image generation, file upload (S3), slug generation, email sending, and document validation in a .NET 8 project. Use when the user wants to use AI features, upload files, generate slugs, send emails, or understand zTools integration.
documentation
Generates a comprehensive, standardized README.md for any project. Use when the user wants to create or regenerate a README file following the project's documentation standard.
development
Create modal dialogs in the frontend using a custom Modal component built on top of Radix UI Dialog. Use this skill whenever the user asks to create, add, or modify a modal, dialog, popup, or confirmation prompt in the React application.
development
Create the complete frontend architecture for a new entity in the React application. Generates TypeScript types, service class, context provider, custom hook, and registers the provider in main.tsx. Use this skill when the user asks to create a new entity, feature module, or domain area in the frontend.