Be honest: do your Blazor components still talk straight to EF Core and sprinkle business rules in event handlers? That “quick fix” is why the code gets hard to test, hard to change, and slow to ship. Let’s fix that with a clear structure you can apply today.
Why Blazor needs Clean Architecture
Blazor (Server or WebAssembly) makes UI work simple, but it’s easy to let components grow into mini “god objects”. Typical smells:
- Data access inside components (
new DbContext
or heavy injected services) - Duplicated validation spread across UI and API
- Stateful logic that is hard to cover with tests
- Tight coupling between UI and persistence
Clean Architecture gives you guardrails:
- Separation of concerns: UI shows data; Application orchestrates use cases; Domain holds rules; Infrastructure talks to the world (DB, HTTP, queues).
- Dependency inversion: outer layers depend on inner abstractions, never the other way around.
- Domain modeling: entities, value objects, and domain events keep rules close to the data that owns them.
- CQRS: reads and writes follow different paths, which reduces accidental coupling and makes flows clear.
Solution layout that works
A tiny but complete folder layout for a Blazor app that scales:
src/
BlazorApp/ # UI: Blazor Server or WASM host + Razor components
Application/ # Use cases, CQRS handlers, DTOs, validation
Domain/ # Entities, Value Objects, Aggregates, Events
Infrastructure/ # EF Core, Repositories, Email/SMS, Outbox
tests/
Domain.Tests/
Application.Tests/
Infrastructure.Tests/
BlazorApp.Tests/ # bUnit or UI tests where needed
References (one-way)
BlazorApp → Application → Domain
BlazorApp → Domain (for shared contracts like primitive Value Objects)
Infrastructure → Application, Domain
# Startup wiring happens in BlazorApp, but implementations live in Infrastructure
This keeps the Domain and Application projects free of UI and database concerns.
The layers at a glance
Domain
- Core language of the business: Entities, Value Objects, Aggregates
- Domain Events, business invariants
- No EF Core, no HTTP, no logging abstractions needed here
Application
- Use cases as Commands and Queries
- Transaction boundaries, validation, mapping to DTOs
- Interfaces (e.g.,
ITodoRepository
,IEmailSender
)
Infrastructure
- EF Core
DbContext
, repository implementations - Email/SMS/HTTP clients, file storage, event outbox
UI (Blazor)
- Razor components, minimal logic: send Commands/Queries, render state
Domain modeling: simple, strict, testable
We’ll build a tiny Todo feature. Core rules:
- A
TodoList
owns manyTodoItem
entries (aggregate root isTodoList
). - Title must be non‑empty and trimmed. Duplicate titles in the same list are not allowed.
- Completing an item raises a domain event
TodoItemCompleted
.
Domain/ValueObjects/Title.cs
namespace CleanBlazor.Domain.ValueObjects;
public sealed record Title
{
public string Value { get; }
private Title(string value) => Value = value;
public static Title From(string? input)
{
var value = (input ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Title cannot be empty.");
if (value.Length > 120)
throw new ArgumentException("Title is too long (max 120).");
return new Title(value);
}
public override string ToString() => Value;
}
Domain/Events/TodoItemCompleted.cs
namespace CleanBlazor.Domain.Events;
public sealed record TodoItemCompleted(Guid ListId, Guid ItemId, DateTime OccurredAtUtc);
Domain/Entities/TodoItem.cs
using CleanBlazor.Domain.Events;
using CleanBlazor.Domain.ValueObjects;
namespace CleanBlazor.Domain.Entities;
public class TodoItem
{
public Guid Id { get; private set; } = Guid.NewGuid();
public Title Title { get; private set; }
public bool IsDone { get; private set; }
private readonly List<object> _events = new();
public IReadOnlyList<object> Events => _events;
public TodoItem(Title title)
{
Title = title;
}
public void Complete()
{
if (IsDone) return;
IsDone = true;
_events.Add(new TodoItemCompleted(default, Id, DateTime.UtcNow));
}
}
Domain/Entities/TodoList.cs
using CleanBlazor.Domain.ValueObjects;
namespace CleanBlazor.Domain.Entities;
public class TodoList
{
private readonly List<TodoItem> _items = new();
public Guid Id { get; private set; } = Guid.NewGuid();
public string Name { get; private set; }
public IReadOnlyCollection<TodoItem> Items => _items.AsReadOnly();
public TodoList(string name)
{
Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Name required") : name.Trim();
}
public TodoItem AddItem(Title title)
{
if (_items.Any(i => i.Title.Value.Equals(title.Value, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException($"Item with title '{title}' already exists.");
var item = new TodoItem(title);
_items.Add(item);
return item;
}
}
Tip: keep Domain clean of framework ties. No annotations, no EF types, no MediatR. Plain C#.
Application layer with CQRS
Define contracts that the UI and handlers use. You can use MediatR or a minimal interface of your own. I’ll show a plain version (easy to swap later).
Application/Abstractions/ITodoRepository.cs
using CleanBlazor.Domain.Entities;
using CleanBlazor.Domain.ValueObjects;
namespace CleanBlazor.Application.Abstractions;
public interface ITodoRepository
{
Task<TodoList?> GetListAsync(Guid listId, CancellationToken ct);
Task<Guid> CreateListAsync(string name, CancellationToken ct);
Task<Guid> AddItemAsync(Guid listId, Title title, CancellationToken ct);
Task CompleteItemAsync(Guid listId, Guid itemId, CancellationToken ct);
Task<IReadOnlyList<TodoItemDto>> GetItemsAsync(Guid listId, CancellationToken ct);
}
public sealed record TodoItemDto(Guid Id, string Title, bool IsDone);
Commands
Application/Todos/AddItem/AddItemCommand.cs
namespace CleanBlazor.Application.Todos.AddItem;
public sealed record AddItemCommand(Guid ListId, string Title);
public interface ICommandHandler<TCommand>
{
Task Handle(TCommand command, CancellationToken ct);
}
Application/Todos/AddItem/AddItemHandler.cs
using CleanBlazor.Application.Abstractions;
using CleanBlazor.Domain.ValueObjects;
namespace CleanBlazor.Application.Todos.AddItem;
public sealed class AddItemHandler : ICommandHandler<AddItemCommand>
{
private readonly ITodoRepository _repo;
public AddItemHandler(ITodoRepository repo) => _repo = repo;
public async Task Handle(AddItemCommand command, CancellationToken ct)
{
var title = Title.From(command.Title);
await _repo.AddItemAsync(command.ListId, title, ct);
}
}
Queries
Application/Todos/GetItems/GetItemsQuery.cs
namespace CleanBlazor.Application.Todos.GetItems;
public sealed record GetItemsQuery(Guid ListId);
public interface IQueryHandler<TQuery, TResult>
{
Task<TResult> Handle(TQuery query, CancellationToken ct);
}
Application/Todos/GetItems/GetItemsHandler.cs
using CleanBlazor.Application.Abstractions;
namespace CleanBlazor.Application.Todos.GetItems;
public sealed class GetItemsHandler : IQueryHandler<GetItemsQuery, IReadOnlyList<TodoItemDto>>
{
private readonly ITodoRepository _repo;
public GetItemsHandler(ITodoRepository repo) => _repo = repo;
public Task<IReadOnlyList<TodoItemDto>> Handle(GetItemsQuery query, CancellationToken ct)
=> _repo.GetItemsAsync(query.ListId, ct);
}
This setup is tiny, testable, and leaves room to swap in MediatR later without touching Domain.
Infrastructure with EF Core
Keep EF Core out of Domain and Application by mapping in Infrastructure.
Infrastructure/Data/AppDbContext.cs
using CleanBlazor.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace CleanBlazor.Infrastructure.Data;
public class AppDbContext : DbContext
{
public DbSet<TodoList> Lists => Set<TodoList>();
public DbSet<TodoItem> Items => Set<TodoItem>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<TodoList>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.Name).IsRequired().HasMaxLength(80);
e.HasMany<TodoItem>("_items").WithOne().OnDelete(DeleteBehavior.Cascade);
});
b.Entity<TodoItem>(e =>
{
e.HasKey(x => x.Id);
e.OwnsOne(x => x.Title, nb =>
{
nb.Property(p => p.Value).HasColumnName("Title").HasMaxLength(120);
});
});
}
}
Infrastructure/Repositories/TodoRepository.cs
using CleanBlazor.Application.Abstractions;
using CleanBlazor.Domain.Entities;
using CleanBlazor.Domain.ValueObjects;
using CleanBlazor.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace CleanBlazor.Infrastructure.Repositories;
public sealed class TodoRepository : ITodoRepository
{
private readonly AppDbContext _db;
public TodoRepository(AppDbContext db) => _db = db;
public async Task<TodoList?> GetListAsync(Guid listId, CancellationToken ct)
=> await _db.Lists.Include("_items").FirstOrDefaultAsync(l => l.Id == listId, ct);
public async Task<Guid> CreateListAsync(string name, CancellationToken ct)
{
var list = new TodoList(name);
_db.Add(list);
await _db.SaveChangesAsync(ct);
return list.Id;
}
public async Task<Guid> AddItemAsync(Guid listId, Title title, CancellationToken ct)
{
var list = await GetListAsync(listId, ct) ?? throw new KeyNotFoundException("List not found");
var item = list.AddItem(title);
await _db.SaveChangesAsync(ct);
return item.Id;
}
public async Task CompleteItemAsync(Guid listId, Guid itemId, CancellationToken ct)
{
var list = await GetListAsync(listId, ct) ?? throw new KeyNotFoundException("List not found");
var item = list.Items.First(i => i.Id == itemId);
item.complete();
await _db.SaveChangesAsync(ct);
}
public async Task<IReadOnlyList<TodoItemDto>> GetItemsAsync(Guid listId, CancellationToken ct)
{
return await _db.Items
.Where(i => EF.Property<Guid>(i, "TodoListId") == listId)
.Select(i => new TodoItemDto(i.Id, i.Title.Value, i.IsDone))
.ToListAsync(ct);
}
}
Note: method casing typo
item.complete()
is intentional in code review checks. It should beitem.Complete()
. Spotting these in tests is cheap; in prod, not so much.
Wiring in Blazor (composition root)
BlazorApp/Program.cs
using CleanBlazor.Application.Abstractions;
using CleanBlazor.Application.Todos.AddItem;
using CleanBlazor.Application.Todos.GetItems;
using CleanBlazor.Infrastructure.Data;
using CleanBlazor.Infrastructure.Repositories;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor(); // or WASM host services
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlite(builder.Configuration.GetConnectionString("Default")));
// Application handlers
builder.Services.AddScoped<ICommandHandler<AddItemCommand>, AddItemHandler>();
builder.Services.AddScoped<IQueryHandler<GetItemsQuery, IReadOnlyList<TodoItemDto>>, GetItemsHandler>();
// Abstractions
builder.Services.AddScoped<ITodoRepository, TodoRepository>();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
}
app.UseStaticFiles();
app.UseRouting();
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
app.Run();
A thin component: no business rules inside
BlazorApp/Pages/Todos.razor
@page "/todos/{ListId:guid}"
@inject ICommandHandler<AddItemCommand> AddItem
@inject IQueryHandler<GetItemsQuery, IReadOnlyList<TodoItemDto>> GetItems
<h3>Todo</h3>
<input @bind="_newTitle" placeholder="What needs doing?" />
<button @onclick="OnAdd">Add</button>
@if (_items is null)
{
<p>Loading…</p>
}
else if (_items.Count == 0)
{
<p>No items yet.</p>
}
else
{
<ul>
@foreach (var i in _items)
{
<li>@i.Title (@(i.IsDone ? "done" : "open"))</li>
}
</ul>
}
@code {
[Parameter] public Guid ListId { get; set; }
private string _newTitle = string.Empty;
private IReadOnlyList<TodoItemDto>? _items;
protected override async Task OnParametersSetAsync()
{
_items = await GetItems.Handle(new GetItemsQuery(ListId), CancellationToken.None);
}
private async Task OnAdd()
{
await AddItem.Handle(new AddItemCommand(ListId, _newTitle), CancellationToken.None);
_newTitle = string.Empty;
_items = await GetItems.Handle(new GetItemsQuery(ListId), CancellationToken.None);
}
}
UI stays dumb: it sends commands and renders data. All rules live in Domain and Application.
Validation: where and how
- Validate shape at the boundary (DataAnnotations/FluentValidation on input models if you expose API endpoints).
- Validate business rules in Domain (
Title.From
,TodoList.AddItem
). - Validate use case flow in Application (e.g., user can only add items to lists they own).
This split keeps you from duplicating checks in random places.
Transactions and domain events
Keep transactions at the Application layer. Let Infrastructure implement an outbox later if you publish events.
Simple approach to start:
- Domain pushes events into an in‑memory list on the aggregate
- Repository flushes changes, then publishes those events to an in‑process dispatcher
You can replace the dispatcher with a message bus when you need cross‑process delivery.
Testing strategy that pays back
- Domain.Tests: value objects, invariants, events. No mocks.
- Application.Tests: handler behavior with a fake repository.
- Infrastructure.Tests: EF Core mapping tests with
Sqlite InMemory
. - BlazorApp.Tests: bUnit to render components and assert markup/state.
Example: a fast test for Title
and for duplicate item protection.
[Fact]
public void Title_cannot_be_empty()
{
Assert.Throws<ArgumentException>(() => Title.From(" "));
}
[Fact]
public void TodoList_prevents_duplicates()
{
var list = new TodoList("Home");
list.AddItem(Title.From("Buy milk"));
Assert.Throws<InvalidOperationException>(() => list.AddItem(Title.From("buy milk")));
}
Common mistakes (and quick fixes)
- Putting EF types in Domain – move them to Infrastructure, use owned types for Value Objects.
- Fat components with business rules – create Commands/Queries and handlers; inject them.
- Anemic Domain (all rules in handlers) – move invariants into Entities/Value Objects.
- Shared DbContext in UI – hide behind
ITodoRepository
. - Leaking domain entities to UI – map to DTOs in Application.
- One handler that does everything – split by use case, keep handlers short and focused.
When to pick CQRS in Blazor
You don’t need two databases or a full event store to get value. Start simple:
- Separate types and handlers for reads vs writes
- Reads can bypass aggregates (project straight from EF to DTOs)
- Writes go through aggregates to enforce rules
If reads get heavy, add pagination and projections. If writes get complex, domain events and outbox can keep things in sync.
Checklist to apply in an existing project
- Create Domain and Application projects; move rules into them.
- Extract interfaces the UI depends on (repositories, senders).
- Move EF Core code into Infrastructure, map Value Objects as owned types.
- Introduce Commands and Queries for the top 3 flows.
- Add tests: start with Value Objects and one handler.
- Keep components thin; no data access, no business rules.
- Wire DI in
Program.cs
; the UI remains a client of Application.
Tape this list on the wall. Review each PR against it.
FAQ: quick answers for busy teams
The inner layers stay the same. For WASM, keep Application and Domain in shared libraries. For Server, the same; Infrastructure sits on the server side.
No. It helps, but the core idea is the separation. You can start with the tiny command/query interfaces shown above and swap later.
For request shape (required fields, max length), use attributes on input models near the boundary. For business rules, keep them in Domain.
Aim for 15-40 lines. If it grows, cut it or push rules into Domain.
Start with a single DbContext
per request and commit in the handler. Add an outbox if you publish messages to other services.
Use DTOs. UI shouldn’t mutate aggregates directly.
Manual for small flows; it’s clearer and removes magic. Introduce a mapper only when it saves time.
Yes. That’s the point. Domain and Application are free of UI details, so reuse is simple.
Conclusion: a simple structure that keeps you fast
Clean Architecture in Blazor is not theory; it’s a set of small rules that keep changes cheap. Keep the UI thin, push rules into Domain, and let Application coordinate with clear Commands and Queries. Try the skeleton above on one feature this week. If it feels simpler, spread it to the rest of the app. And now it’s your turn: what part of your Blazor app will you refactor first? Leave a comment — I read every one.