Clean Architecture for Blazor: DDD and CQRS in real projects

Clean Architecture for Blazor with DDD & CQRS

Learn how to structure Blazor apps with Clean Architecture, DDD, and CQRS. Clear layers, EF Core mapping, and tested handlers.

.NET Development·By amarozka · October 10, 2025

Clean Architecture for Blazor with DDD & CQRS

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 many TodoItem entries (aggregate root is TodoList).
  • 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 be item.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

  1. Create Domain and Application projects; move rules into them.
  2. Extract interfaces the UI depends on (repositories, senders).
  3. Move EF Core code into Infrastructure, map Value Objects as owned types.
  4. Introduce Commands and Queries for the top 3 flows.
  5. Add tests: start with Value Objects and one handler.
  6. Keep components thin; no data access, no business rules.
  7. 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

Blazor Server or WebAssembly – does the structure change?

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.

Do I need MediatR?

No. It helps, but the core idea is the separation. You can start with the tiny command/query interfaces shown above and swap later.

Where do I put validation attributes?

For request shape (required fields, max length), use attributes on input models near the boundary. For business rules, keep them in Domain.

How big should a handler be?

Aim for 15-40 lines. If it grows, cut it or push rules into Domain.

How do I handle transactions?

Start with a single DbContext per request and commit in the handler. Add an outbox if you publish messages to other services.

DTOs vs Entities in UI?

Use DTOs. UI shouldn’t mutate aggregates directly.

Mapping: AutoMapper or manual?

Manual for small flows; it’s clearer and removes magic. Introduce a mapper only when it saves time.

Can I share Domain with a mobile app or API?

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.

Leave a Reply

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