Building Your First Domain Feature (End-to-End)
This recipe walks you through implementing a complete domain feature in PearDrop—from aggregate design through command/query handlers to the Blazor UI. We'll build a "Notes" feature that showcases DDD and CQRS patterns.
The PearDrop CLI generates file stubs automatically. This guide shows you:
- Generate stubs with CLI - Let the tool create the structure
- Fill in implementations - Add domain logic, validation, handlers
- Wire it together - Register services and create UI
This approach keeps you focused on domain logic, not boilerplate.
A "Notes" feature where users can create, view, edit, and delete their personal notes. This demonstrates the full PearDrop pattern stack.
Architecture Overview
Write Side (Commands) Read Side (Queries)
───────────────────── ───────────────────
CreateNoteCommand GetMyNotesQuery
↓ ↓
CreateNoteCommandHandler GetMyNotesQueryHandler
↓ ↓
NoteAggregate (Domain) IAppReadModels.Notes
↓ ↓
AppDbContext (EF tracking) AppReadDbContext (no tracking)
↓ ↓
SQL Server SQL Server (views)
Quick Start: Using the CLI
The PearDrop CLI scaffolds most of the boilerplate for you. Use it to generate stubs, then customize:
# 1. Create the domain aggregate with basic structure
peardrop add aggregate Note
# 2. Create commands (this also creates stubs for validators/handlers)
peardrop add command CreateNote --aggregate Note
peardrop add command UpdateNote --aggregate Note
peardrop add command ArchiveNote --aggregate Note
# 3. Create queries
peardrop add query GetMyNotes --aggregate Note
peardrop add query GetNoteById --aggregate Note
What the CLI generates:
- ✅ Aggregate structure and base methods
- ✅ Command DTOs (you customize parameters)
- ✅ Command handler stubs (you add business logic)
- ✅ Validator stubs (you add rules)
- ✅ Query DTOs and handlers
- ✅ Entity configuration skeleton
- ✅ Read model projection stub
See CLI Concepts for detailed CLI reference.
Step 1: Design and Implement the Domain Aggregate
After running peardrop add aggregate Note, you'll have a stub. Open it and add your business logic:
File: Infrastructure/Domain/NoteAggregate/AggregateRoot/NoteAggregate.cs
using PearDrop.Domain;
using PearDrop.Domain.Contracts;
namespace YourApp.Infrastructure.Domain.NoteAggregate.AggregateRoot;
public sealed class NoteAggregate : Entity, IAggregateRoot
{
// Private constructor for EF Core (generated by CLI)
private NoteAggregate() { }
// Public constructor for creating new notes (ADD THIS)
public NoteAggregate(
Guid id,
string title,
string content,
Guid userId)
{
Id = id;
Title = title;
Content = content;
UserId = userId;
IsArchived = false;
CreatedAt = DateTimeOffset.UtcNow;
UpdatedAt = DateTimeOffset.UtcNow;
}
// Properties (ADD THESE - beyond default stub)
public string Title { get; private set; } = string.Empty;
public string Content { get; private set; } = string.Empty;
public Guid UserId { get; private set; }
public bool IsArchived { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }
// Business logic: Update note (ADD THIS)
public ResultMonad Update(string title, string content)
{
if (this.IsArchived)
{
return ResultMonad.Failure(new BluQubeErrorData(
"NOTE-001", "Cannot modify archived note"));
}
if (string.IsNullOrWhiteSpace(title))
{
return ResultMonad.Failure(new BluQubeErrorData(
"NOTE-002", "Title cannot be empty"));
}
this.Title = title;
this.Content = content;
this.UpdatedAt = DateTimeOffset.UtcNow;
return ResultMonad.Success();
}
// Business logic: Archive note (ADD THIS)
public void Archive()
{
this.IsArchived = true;
this.UpdatedAt = DateTimeOffset.UtcNow;
}
// Business logic: Restore archived note (ADD THIS)
public void Restore()
{
this.IsArchived = false;
this.UpdatedAt = DateTimeOffset.UtcNow;
}
}
Key Points:
- ✅ Sealed class inheriting
Entity, IAggregateRoot(generated by CLI) - ✅ Private parameterless constructor for EF Core (generated by CLI)
- ✅ Public constructor for creating instances (YOU add logic)
- ✅ Private setters (encapsulation)
- ✅ Business logic in methods (not in handlers)
- ✅ Returns
ResultMonadfor operations that can fail
Step 2: Customize Commands
The CLI generates command stubs with basic structure. Customize the parameters:
File: Infrastructure/Domain/NoteAggregate/Commands/CreateNoteCommand.cs
using BluQube.Commands;
namespace YourApp.Infrastructure.Domain.NoteAggregate.Commands;
/// <summary>
/// Command to create a new note.
/// CLI generates the stub - you define parameters.
/// CRITICAL: Use positional syntax for required parameters.
/// </summary>
public sealed record CreateNoteCommand(
string Title, // Added by you
string Content) : ICommand; // No result type - commands rarely need to return data
File: Infrastructure/Domain/NoteAggregate/Commands/UpdateNoteCommand.cs
public sealed record UpdateNoteCommand(
Guid NoteId,
string Title,
string Content) : ICommand; // No result type needed
File: Infrastructure/Domain/NoteAggregate/Commands/ArchiveNoteCommand.cs
public sealed record ArchiveNoteCommand(Guid NoteId) : ICommand;
Default Pattern: Commands should use ICommand (not ICommand<T>).
- ✅ Commands represent side effects (create, update, delete, archive)
- ✅ UI checks
result.IsSuccessfor success/failure - ✅ Any data needed by the UI comes from subsequent queries
- ❌ Don't return data just to avoid a query - it couples write and read models
When to use ICommand<T> (very rare):
- Only when the command generates data that cannot be queried (e.g., temporary tokens, session IDs)
- NOT for entity IDs (query for the entity instead)
- NOT for confirmation flags (IsSuccess is enough)
In this recipe, all commands use ICommand with no result types.
Step 3: Add Validation Rules
The CLI generates validator stubs. Add your validation rules:
File: Infrastructure/Domain/NoteAggregate/CommandValidators/CreateNoteCommandValidator.cs
using FluentValidation;
namespace YourApp.Infrastructure.Domain.NoteAggregate.CommandValidators;
public sealed class CreateNoteCommandValidator : AbstractValidator<CreateNoteCommand>
{
public CreateNoteCommandValidator()
{
// CLI generates the stub - you add rules
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Title is required")
.MaximumLength(200).WithMessage("Title cannot exceed 200 characters");
RuleFor(x => x.Content)
.NotEmpty().WithMessage("Content is required")
.MaximumLength(5000).WithMessage("Content cannot exceed 5000 characters");
}
}
File: Infrastructure/Domain/NoteAggregate/CommandValidators/UpdateNoteCommandValidator.cs
public sealed class UpdateNoteCommandValidator : AbstractValidator<UpdateNoteCommand>
{
public UpdateNoteCommandValidator()
{
RuleFor(x => x.NoteId)
.NotEqual(Guid.Empty).WithMessage("Note ID is required");
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Title is required")
.MaximumLength(200).WithMessage("Title max 200 chars");
RuleFor(x => x.Content)
.NotEmpty().WithMessage("Content is required")
.MaximumLength(5000).WithMessage("Content max 5000 chars");
}
}
File: Infrastructure/Domain/NoteAggregate/CommandValidators/ArchiveNoteCommandValidator.cs
public sealed class ArchiveNoteCommandValidator : AbstractValidator<ArchiveNoteCommand>
{
public ArchiveNoteCommandValidator()
{
RuleFor(x => x.NoteId)
.NotEqual(Guid.Empty).WithMessage("Note ID is required");
}
}
For advanced validation patterns, see Form Validation Patterns.
Step 4: Implement Command Handlers
The CLI generates handler stubs with the boilerplate. You add the business logic:
File: Infrastructure/Domain/NoteAggregate/CommandHandlers/CreateNoteCommandHandler.cs
using BluQube.Commands;
using FluentValidation;
using Microsoft.Extensions.Logging;
using PearDrop.Domain.Contracts;
using PearDrop.Extensions;
namespace YourApp.Infrastructure.Domain.NoteAggregate.CommandHandlers;
public sealed class CreateNoteCommandHandler
: AuditableCommandHandler<CreateNoteCommand, NoteAggregate> // No result type generic
{
private readonly IHttpContextAccessor httpContextAccessor;
// CLI generates constructor signature
public CreateNoteCommandHandler(
IEnumerable<IValidator<CreateNoteCommand>> validators,
ILogger<CreateNoteCommandHandler> logger,
ICommandStore commandStore,
IRepositoryFactory<NoteAggregate> repositoryFactory,
IHttpContextAccessor httpContextAccessor)
: base(validators, logger, commandStore, repositoryFactory)
{
this.httpContextAccessor = httpContextAccessor;
}
// YOU implement this method with business logic
protected override async Task<CommandResult> HandleInternalWithRepository(
CreateNoteCommand command,
CancellationToken cancellationToken)
{
// Get current user
var userMaybe = this.httpContextAccessor.HttpContext?.ToSystemUser();
if (userMaybe?.HasNoValue != false)
{
return CommandResult.Failed(new BluQubeErrorData(
ErrorCodes.CoreValidation, "User not authenticated"));
}
var user = userMaybe.Value;
// Create aggregate (uses business logic from Step 1)
var noteId = Guid.NewGuid();
var note = new NoteAggregate(noteId, command.Title, command.Content, user.UserId);
// Persist
this.Repository.Add(note);
var saveResult = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
if (saveResult.IsFailure)
{
return CommandResult.Failed(saveResult.Error!);
}
// No data returned - UI will query for the note list or redirect
return CommandResult.Succeeded();
}
}
File: Infrastructure/Domain/NoteAggregate/CommandHandlers/UpdateNoteCommandHandler.cs
public sealed class UpdateNoteCommandHandler
: AuditableCommandHandler<UpdateNoteCommand, NoteAggregate> // No result type generic
{
private readonly IHttpContextAccessor httpContextAccessor;
public UpdateNoteCommandHandler(
IEnumerable<IValidator<UpdateNoteCommand>> validators,
ILogger<UpdateNoteCommandHandler> logger,
ICommandStore commandStore,
IRepositoryFactory<NoteAggregate> repositoryFactory,
IHttpContextAccessor httpContextAccessor)
: base(validators, logger, commandStore, repositoryFactory)
{
this.httpContextAccessor = httpContextAccessor;
}
protected override async Task<CommandResult> HandleInternalWithRepository(
UpdateNoteCommand command,
CancellationToken cancellationToken)
{
// Get current user
var userMaybe = this.httpContextAccessor.HttpContext?.ToSystemUser();
if (userMaybe?.HasNoValue != false)
{
return CommandResult.Failed(new BluQubeErrorData(
ErrorCodes.CoreValidation, "User not authenticated"));
}
var user = userMaybe.Value;
// Load aggregate
var noteMaybe = await this.Repository.FindOne(
new ByIdSpecification<NoteAggregate>(command.NoteId),
cancellationToken);
if (noteMaybe.HasNoValue)
{
return CommandResult.Failed(new BluQubeErrorData(
ErrorCodes.NotFound, "Note not found"));
}
var note = noteMaybe.Value;
// Authorization check - user can only edit their own notes
if (note.UserId != user.UserId)
{
return CommandResult.Failed(new BluQubeErrorData(
ErrorCodes.CoreValidation, "You cannot edit another user's note"));
}
// Execute business logic (calls method from Step 1 aggregate)
var updateResult = note.Update(command.Title, command.Content);
if (updateResult.IsFailure)
{
return CommandResult.Failed(updateResult.Error!);
}
// Persist
var saveResult = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
if (saveResult.IsFailure)
{
return CommandResult.Failed(saveResult.Error!);
}
// No data returned - success is indicated by IsSuccess
return CommandResult.Succeeded();
}
}
File: Infrastructure/Domain/NoteAggregate/CommandHandlers/ArchiveNoteCommandHandler.cs
using BluQube.Commands;
using FluentValidation;
namespace YourApp.Infrastructure.Domain.NoteAggregate.CommandHandlers;
// All handlers use the same pattern: two generics (command, aggregate), no result type
internal sealed class ArchiveNoteCommandHandler :
AuditableCommandHandler<ArchiveNoteCommand, NoteAggregate> // Command and aggregate, no result
{
public ArchiveNoteCommandHandler(
IEnumerable<IValidator<ArchiveNoteCommand>> validators,
ILogger<ArchiveNoteCommandHandler> logger,
ICommandStore commandStore,
IRepositoryFactory<NoteAggregate> repositoryFactory)
: base(validators, logger, commandStore, repositoryFactory)
{
}
protected override async Task<CommandResult> HandleInternalWithRepository(
ArchiveNoteCommand command,
CancellationToken cancellationToken)
{
// Load aggregate
var noteMaybe = await this.Repository.FindOne(
new ByIdSpecification<NoteAggregate>(command.NoteId),
cancellationToken);
if (noteMaybe.HasNoValue)
{
return CommandResult.Failed(new BluQubeErrorData(
ErrorCodes.NotFound, "Note not found"));
}
var note = noteMaybe.Value;
// Execute business logic (no parameters, just state change)
note.Archive();
// Persist - no result data to return
var saveResult = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return saveResult.IsSuccess ? CommandResult.Succeeded() : CommandResult.Failed(saveResult.Error!);
}
}
Notice all command handlers in this recipe follow the same pattern:
- ✅ Inherit
AuditableCommandHandler<TCommand, TAggregate>(two generics, no result type) - ✅ Return
CommandResult(notCommandResult<T>) - ✅ Still validate, log, and handle errors
- ✅ UI reads
result.IsSuccessto determine outcome - ✅ UI fetches any needed data via queries after command succeeds
This is the standard pattern for 99% of commands in PearDrop.
Step 5: Configure Entity Mapping
The CLI generates a configuration stub. Customize it for your properties and add indexes:
File: Infrastructure/Data/WriteModel/EntityConfigs/NoteAggregateTypeConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PearDrop.Database.Contracts;
namespace YourApp.Infrastructure.Data.WriteModel.EntityConfigs;
public sealed class NoteAggregateTypeConfiguration
: EntityTypeConfigurationBase<NoteAggregate>, IMutableTypeConfiguration
{
// CLI generates the constructor
public NoteAggregateTypeConfiguration() : base("App") { }
// YOU customize this method with your properties and indexes
public override void Configure(EntityTypeBuilder<NoteAggregate> builder)
{
builder.ToTable("Notes");
builder.HasKey(n => n.Id);
// Property configurations - match your aggregate
builder.Property(n => n.Title)
.IsRequired()
.HasMaxLength(200);
builder.Property(n => n.Content)
.IsRequired()
.HasMaxLength(5000);
builder.Property(n => n.UserId)
.IsRequired();
builder.Property(n => n.IsArchived)
.IsRequired()
.HasDefaultValue(false);
builder.Property(n => n.CreatedAt)
.IsRequired();
builder.Property(n => n.UpdatedAt)
.IsRequired();
// Add indexes for queries
builder.HasIndex(n => new { n.UserId, n.IsArchived });
}
}
Register in AppDbContext:
// CLI generates the DbSet
public DbSet<NoteAggregate> Notes { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// CLI generates this registration
modelBuilder.ApplyConfiguration(new NoteAggregateTypeConfiguration());
}
Step 6: Create Read Models
Read models optimize queries. Create projection entities (NOT domain aggregates) for your read view:
CLI Command:
peardrop add projection NoteProjection --schema ReadModel
File: Infrastructure/Data/ReadModel/Projections/NoteProjection.cs
using PearDrop.Database.Contracts;
namespace YourApp.Infrastructure.Data.ReadModel.Projections;
// Implements IProjection - NOT a domain aggregate
public sealed class NoteProjection : IProjection
{
public required Guid Id { get; init; }
public required Guid UserId { get; init; }
public required string Title { get; init; }
public required string Content { get; init; }
public required bool IsArchived { get; init; }
public required DateTime CreatedAt { get; init; }
public required DateTime UpdatedAt { get; init; }
}
Register in Read DbContext:
public class AppReadDbContext : PearDropReadDbContextBase<AppReadDbContext>
{
// CLI generates this DbSet
public DbSet<NoteProjection> Notes { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// YOU configure the view mapping
modelBuilder.Entity<NoteProjection>()
.ToView("vw_Notes", "ReadModel")
.HasNoKey();
}
}
Create Read Model Interface:
using PearDrop.Database.Contracts;
namespace YourApp.Infrastructure.Data.ReadModel;
/// <summary>
/// Provides safe queryable access to read models without exposing DbContext.
/// This is the ONLY way consumers access read-optimized data.
/// </summary>
public interface IAppReadModels : IModuleReadModels
{
IReadModelQueryable<NoteProjection> Notes { get; }
}
Implement with Lazy Initialization:
using PearDrop.Database.Contracts;
namespace YourApp.Infrastructure.Data.ReadModel;
public sealed class AppReadModels : ModuleReadModelsBase<AppReadDbContext>, IAppReadModels
{
private readonly Lazy<IReadModelQueryable<NoteProjection>> notes;
public AppReadModels(
IDbContextFactory<AppReadDbContext> dbContextFactory,
IMultiTenantContextAccessor tenantContextAccessor,
IReadModelExpressionValidator expressionValidator)
: base(dbContextFactory, tenantContextAccessor, expressionValidator)
{
// CLI generates the lazy initialization pattern
this.notes = new Lazy<IReadModelQueryable<NoteProjection>>(() =>
this.CreateQueryable<NoteProjection>(new ReadModelMetadata
{
EntityName = nameof(NoteProjection),
RequiresTenantIsolation = false, // Single-tenant in this example
MaxTakeSize = 1000,
AllowedIncludes = new HashSet<string>(), // No relationships for this projection
}));
}
public override string ModuleName => "App";
public IReadModelQueryable<NoteProjection> Notes => this.notes.Value;
}
Register in Dependency Injection:
// In ServiceCollectionExtensions.cs
services.AddScoped<IAppReadModels, AppReadModels>();
services.AddPearDropSqlServerDbContextFactory<AppReadDbContext>(
"YourApp.App",
"__EFMigrationsHistory_App");
Step 7: Create Read-Side Queries
The CLI generates read query stubs. Customize to use your read models:
CLI Command:
peardrop add query GetMyNotes
File: Contracts/Queries/GetMyNotesQuery.cs
using BluQube.Infrastructure;
namespace YourApp.Contracts.Queries;
// CLI generates positional record syntax
public sealed record GetMyNotesQuery(
bool? IncludeArchived = false) : IQuery;
public sealed record GetMyNotesQueryResult(
IReadOnlyList<NoteSummaryDto> Notes);
public sealed record NoteSummaryDto(
Guid Id,
string Title,
DateTime CreatedAt,
bool IsArchived);
Query Handler:
// File: Application/Queries/GetMyNotesQueryHandler.cs
using BluQube.Infrastructure;
using YourApp.Contracts.Queries;
using YourApp.Infrastructure.Data.ReadModel;
namespace YourApp.Application.Queries;
internal sealed class GetMyNotesQueryHandler :
IQueryProcessor<GetMyNotesQuery, GetMyNotesQueryResult>
{
private readonly IHttpContextAccessor httpContextAccessor;
private readonly IAppReadModels readModels; // Inject read models interface!
public GetMyNotesQueryHandler(
IHttpContextAccessor httpContextAccessor,
IAppReadModels readModels)
{
this.httpContextAccessor = httpContextAccessor;
this.readModels = readModels;
}
public async Task<QueryResult<GetMyNotesQueryResult>> Handle(
GetMyNotesQuery request,
CancellationToken cancellationToken = default)
{
// Get current user ID
var userIdClaim = this.httpContextAccessor.HttpContext?
.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
if (userIdClaim == null || !Guid.TryParse(userIdClaim.Value, out var userId))
{
return QueryResult<GetMyNotesQueryResult>.Failed();
}
// Query read models
var notes = await this.readModels.Notes
.Where(n => n.UserId == userId && (request.IncludeArchived ?? false ? true : !n.IsArchived))
.OrderByDescending(n => n.CreatedAt)
.Select(n => new NoteSummaryDto(n.Id, n.Title, n.CreatedAt, n.IsArchived))
.ToListAsync(cancellationToken);
return QueryResult<GetMyNotesQueryResult>.Succeeded(
new GetMyNotesQueryResult(notes));
}
}
For detailed validation patterns, see Form Validation Patterns. For complex queries with filtering/pagination, see Search and Filtering.
Step 8: Build Blazor Components
Blazor components are NOT generated by CLI—create them following best practices:
File: Pages/Notes/NotesList.razor
@page "/notes"
@using YourApp.Contracts.Queries
@using YourApp.Contracts.Commands
@inject IQueryRunner QueryRunner
@inject ICommandRunner CommandRunner
@inject NavigationManager Navigation
<div class="container mt-4">
<div class="row mb-4">
<div class="col-md-8">
<h1>My Notes</h1>
</div>
<div class="col-md-4 text-end">
<a href="/notes/create" class="btn btn-primary">+ New Note</a>
</div>
</div>
@if (loading)
{
<div class="alert alert-info">Loading notes...</div>
}
else if (notes == null || !notes.Any())
{
<div class="alert alert-warning">
No notes yet. <a href="/notes/create">Create your first note</a>.
</div>
}
else
{
<div class="row">
@foreach (var note in notes)
{
<div class="col-md-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">@note.Title</h5>
<p class="card-text text-muted">
Created: @note.CreatedAt.ToString("MMM dd, yyyy")
</p>
@if (note.IsArchived)
{
<span class="badge bg-secondary">Archived</span>
}
</div>
<div class="card-footer">
<a href="/notes/edit/@note.Id" class="btn btn-sm btn-link">
Edit
</a>
<button class="btn btn-sm btn-link text-danger"
@onclick="() => DeleteNote(note.Id)">
Delete
</button>
</div>
</div>
</div>
}
</div>
}
</div>
@code {
private IReadOnlyList<NoteSummaryDto>? notes;
private bool loading = true;
protected override async Task OnInitializedAsync()
{
await LoadNotes();
}
private async Task LoadNotes()
{
loading = true;
var result = await QueryRunner.ExecuteAsync(
new GetMyNotesQuery(IncludeArchived: false));
notes = result.IsSuccess ? result.Content?.Notes : null;
loading = false;
}
private async Task DeleteNote(Guid id)
{
if (!await JSRuntime.InvokeAsync<bool>(
"confirm", "Are you sure?"))
{
return;
}
var result = await CommandRunner.ExecuteAsync(
new DeleteNoteCommand(id));
if (result.IsSuccess)
{
await LoadNotes();
}
}
}
File: Pages/Notes/CreateNote.razor
@page "/notes/create"
@using YourApp.Contracts.Commands
@inject ICommandRunner CommandRunner
@inject NavigationManager Navigation
<div class="container mt-4">
<div class="row">
<div class="col-md-8">
<h2>Create Note</h2>
<EditForm Model="@form" OnValidSubmit="@HandleSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<InputText id="title"
@bind-Value="form.Title"
class="form-control"
placeholder="Note title" />
<ValidationMessage For="@(() => form.Title)" />
</div>
<div class="mb-3">
<label for="content" class="form-label">Content</label>
<InputTextArea id="content"
@bind-Value="form.Content"
class="form-control"
rows="6"
placeholder="Note content" />
<ValidationMessage For="@(() => form.Content)" />
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary" disabled="@submitting">
@if (submitting)
{
<span class="spinner-border spinner-border-sm me-2"></span>
<span>Creating...</span>
}
else
{
<span>Create Note</span>
}
</button>
<a href="/notes" class="btn btn-secondary">Cancel</a>
</div>
@if (!string.IsNullOrEmpty(error))
{
<div class="alert alert-danger">@error</div>
}
</EditForm>
</div>
</div>
</div>
@code {
private CreateNoteForm form = new();
private bool submitting = false;
private string? error;
private async Task HandleSubmit()
{
submitting = true;
error = null;
var command = new CreateNoteCommand(form.Title, form.Content);
var result = await CommandRunner.ExecuteAsync(command);
if (result.IsSuccess)
{
Navigation.NavigateTo("/notes");
}
else
{
error = result.DetailedError?.Message ?? "Failed to create note";
}
submitting = false;
}
private class CreateNoteForm
{
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
}
}
For more Blazor patterns, validation, and layout best practices, see Best Practices.
Step 9: Create Database Migration
Run the CLI migration command:
# CLI generates migration from your DbContext changes
peardrop migrate add "Initial Notes feature"
# Or use dotnet EF directly
dotnet ef migrations add InitialNotesFeature --project src/YourApp
dotnet ef database update
This creates:
- Migration file in
Infrastructure/Data/Migrations/with Up/Down methods - Applies schema changes to your database
- Adds
__EFMigrationsHistorytable entry
Summary: From Domain Model to UI
You now have a complete feature:
| Layer | Files | Responsibility |
|---|---|---|
| Domain | NoteAggregate.cs | Business rules & invariants |
| Commands | CreateNoteCommand, UpdateNoteCommand | User intent |
| Handlers | CreateNoteCommandHandler | Command execution & persistence |
| Queries | GetMyNotesQuery | Read optimization |
| Read Models | NoteProjection, IAppReadModels | Query performance |
| Database | Entity config, migrations | Data schema |
| UI | Blazor components | User interaction |
The CLI generated the scaffolds. You implemented the domain logic. That's the workflow.
Key Takeaways
-
CLI scaffolds, you customize: Don't write boilerplate. Use
peardrop add aggregateand customize for your domain. -
Read/Write separation:
- Write: Domain aggregates with business logic
- Read: Projections optimized for queries
-
Authorization in handlers: Check permissions before modifying aggregates (see Step 2)
-
Validators in commands: Move validation logic into dedicated validator classes (see Step 3)
-
Blazor components use runners: Inject
ICommandRunnerandIQueryRunnerto execute CQRS operations -
Domain events coordinate aggregates: For complex scenarios, use domain events within the same bounded context (see Domain Events)
-
Integration events for cross-module: Use CAP for eventual consistency across modules (see Integration Events with CAP)
What's Next?
- Search & Filtering: Add advanced query features see Search and Filtering
- Validation: Expand to async validation & custom rules see Form Validation Patterns
- Testing: Unit test aggregates, handlers, and queries (coverage guide coming soon) base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfiguration(new NoteAggregateTypeConfiguration()); }
## Step 6: Create Read Model Projection
**Location:** `Infrastructure/Data/ReadModel/Projections/NoteProjection.cs`
```csharp
using PearDrop.Queries.Contracts;
namespace YourApp.Infrastructure.Data.ReadModel.Projections;
public sealed class NoteProjection : IProjection
{
private NoteProjection() { }
public required Guid Id { get; init; }
public required string Title { get; init; }
public required string Content { get; init; }
public required Guid UserId { get; init; }
public required bool IsArchived { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
}
Update IAppReadModels interface:
public interface IAppReadModels : IModuleReadModels
{
IReadModelQueryable<NoteProjection> Notes { get; }
}
Update AppReadModels implementation:
private readonly Lazy<IReadModelQueryable<NoteProjection>> notes;
public AppReadModels(
IDbContextFactory<AppReadDbContext> dbContextFactory,
IReadModelExpressionValidator expressionValidator)
: base(dbContextFactory, null!, expressionValidator)
{
this.notes = new Lazy<IReadModelQueryable<NoteProjection>>(() =>
this.CreateQueryable<NoteProjection>(new ReadModelMetadata
{
EntityName = nameof(NoteProjection),
RequiresTenantIsolation = false, // Set true for multi-tenant apps
MaxTakeSize = 1000,
}));
}
public IReadModelQueryable<NoteProjection> Notes => this.notes.Value;
Register in AppReadDbContext:
public DbSet<NoteProjection> Notes { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<NoteProjection>(entity =>
{
entity.ToTable("Notes"); // Maps directly to table for simplicity
entity.HasNoKey(); // Read-only projection
});
}
Step 7: Create Queries
GetMyNotesQuery
Location: Infrastructure/Queries/GetMyNotesQuery.cs
using BluQube.Queries;
namespace YourApp.Infrastructure.Queries;
public sealed record GetMyNotesQuery : IQuery<GetMyNotesQueryResult>;
public sealed record GetMyNotesQueryResult(List<NoteDto> Notes)
{
public sealed record NoteDto(
Guid Id,
string Title,
string ContentPreview,
bool IsArchived,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
}
GetNoteByIdQuery
public sealed record GetNoteByIdQuery(Guid NoteId) : IQuery<GetNoteByIdQueryResult>;
public sealed record GetNoteByIdQueryResult(NoteDetailDto? Note)
{
public sealed record NoteDetailDto(
Guid Id,
string Title,
string Content,
bool IsArchived,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
}
Step 8: Create Query Handlers
GetMyNotesQueryHandler
Location: Infrastructure/Queries/QueryHandlers/GetMyNotesQueryHandler.cs
using BluQube.Queries;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace YourApp.Infrastructure.Queries.QueryHandlers;
public sealed class GetMyNotesQueryHandler
: IQueryProcessor<GetMyNotesQuery, GetMyNotesQueryResult>
{
private readonly IAppReadModels readModels;
private readonly IHttpContextAccessor httpContextAccessor;
public GetMyNotesQueryHandler(
IAppReadModels readModels,
IHttpContextAccessor httpContextAccessor)
{
this.readModels = readModels;
this.httpContextAccessor = httpContextAccessor;
}
public async Task<QueryResult<GetMyNotesQueryResult>> Handle(
GetMyNotesQuery request,
CancellationToken cancellationToken = default)
{
// Get current user ID
var userIdClaim = this.httpContextAccessor.HttpContext?
.User.FindFirst(ClaimTypes.NameIdentifier);
if (userIdClaim == null || !Guid.TryParse(userIdClaim.Value, out var userId))
{
return QueryResult<GetMyNotesQueryResult>.Failed();
}
// Query read models
var notes = await readModels.Notes
.Where(n => n.UserId == userId && !n.IsArchived)
.OrderByDescending(n => n.UpdatedAt)
.Select(n => new GetMyNotesQueryResult.NoteDto(
n.Id,
n.Title,
n.Content.Length > 100 ? n.Content.Substring(0, 100) + "..." : n.Content,
n.IsArchived,
n.CreatedAt,
n.UpdatedAt))
.ToListAsync(cancellationToken);
return QueryResult<GetMyNotesQueryResult>.Succeeded(
new GetMyNotesQueryResult(notes));
}
}
GetNoteByIdQueryHandler
public sealed class GetNoteByIdQueryHandler
: IQueryProcessor<GetNoteByIdQuery, GetNoteByIdQueryResult>
{
private readonly IAppReadModels readModels;
private readonly IHttpContextAccessor httpContextAccessor;
public GetNoteByIdQueryHandler(
IAppReadModels readModels,
IHttpContextAccessor httpContextAccessor)
{
this.readModels = readModels;
this.httpContextAccessor = httpContextAccessor;
}
public async Task<QueryResult<GetNoteByIdQueryResult>> Handle(
GetNoteByIdQuery request,
CancellationToken cancellationToken = default)
{
// Get current user ID
var userIdClaim = this.httpContextAccessor.HttpContext?
.User.FindFirst(ClaimTypes.NameIdentifier);
if (userIdClaim == null || !Guid.TryParse(userIdClaim.Value, out var userId))
{
return QueryResult<GetNoteByIdQueryResult>.Failed();
}
// Query single note (with user authorization)
var note = await readModels.Notes
.Where(n => n.Id == request.NoteId && n.UserId == userId)
.Select(n => new GetNoteByIdQueryResult.NoteDetailDto(
n.Id,
n.Title,
n.Content,
n.IsArchived,
n.CreatedAt,
n.UpdatedAt))
.FirstOrDefaultAsync(cancellationToken);
return QueryResult<GetNoteByIdQueryResult>.Succeeded(
new GetNoteByIdQueryResult(note));
}
}
Step 9: Create Blazor Components
NotesList Component
Location: Components/Pages/Notes/NotesList.razor
@page "/notes"
@using YourApp.App.Client.Contracts.Queries
@inject IQueryRunner QueryRunner
@inject ICommandRunner CommandRunner
@inject NavigationManager Navigation
<PageTitle>My Notes</PageTitle>
<div class="notes-container">
<div class="notes-header">
<h1>My Notes</h1>
<button class="btn-primary" @onclick="CreateNote">
<i class="icon-plus"></i> New Note
</button>
</div>
@if (isLoading)
{
<div class="loading-spinner">Loading notes...</div>
}
else if (notes == null || !notes.Any())
{
<div class="empty-state">
<p>No notes yet. Create your first note!</p>
</div>
}
else
{
<div class="notes-grid">
@foreach (var note in notes)
{
<div class="note-card" @onclick="() => EditNote(note.Id)">
<h3>@note.Title</h3>
<p class="note-preview">@note.ContentPreview</p>
<div class="note-meta">
<span>Updated: @note.UpdatedAt.ToString("MMM dd, yyyy")</span>
</div>
</div>
}
</div>
}
</div>
Code-Behind: NotesList.razor.cs
using Microsoft.AspNetCore.Components;
using BluQube.Queries;
using YourApp.App.Client.Contracts.Queries;
namespace YourApp.App.Client.Components.Pages.Notes;
public partial class NotesList
{
private bool isLoading = true;
private List<GetMyNotesQueryResult.NoteDto>? notes;
protected override async Task OnInitializedAsync()
{
await LoadNotes();
}
private async Task LoadNotes()
{
isLoading = true;
var result = await QueryRunner.Send(new GetMyNotesQuery());
if (result.IsSuccess)
{
notes = result.Data!.Notes;
}
isLoading = false;
}
private void CreateNote()
{
Navigation.NavigateTo("/notes/new");
}
private void EditNote(Guid noteId)
{
Navigation.NavigateTo($"/notes/edit/{noteId}");
}
}
NoteEditor Component
Location: Components/Pages/Notes/NoteEditor.razor
@page "/notes/new"
@page "/notes/edit/{NoteId:guid}"
@using YourApp.App.Client.Contracts.Commands
@using YourApp.App.Client.Contracts.Queries
@inject IQueryRunner QueryRunner
@inject ICommandRunner CommandRunner
@inject NavigationManager Navigation
<PageTitle>@(NoteId == null ? "New Note" : "Edit Note")</PageTitle>
<div class="note-editor">
<div class="editor-header">
<h1>@(NoteId == null ? "New Note" : "Edit Note")</h1>
<button class="btn-secondary" @onclick="Cancel">Cancel</button>
</div>
<EditForm Model="model" OnValidSubmit="Save">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label for="title">Title</label>
<InputText id="title" class="form-control" @bind-Value="model.Title" />
<ValidationMessage For="@(() => model.Title)" />
</div>
<div class="form-group">
<label for="content">Content</label>
<InputTextArea id="content" class="form-control" rows="10"
@bind-Value="model.Content" />
<ValidationMessage For="@(() => model.Content)" />
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" disabled="@isSaving">
@(isSaving ? "Saving..." : "Save Note")
</button>
</div>
</EditForm>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">@errorMessage</div>
}
</div>
Code-Behind: NoteEditor.razor.cs
using Microsoft.AspNetCore.Components;
using System.ComponentModel.DataAnnotations;
using YourApp.App.Client.Contracts.Commands;
using YourApp.App.Client.Contracts.Queries;
namespace YourApp.App.Client.Components.Pages.Notes;
public partial class NoteEditor
{
[Parameter]
public Guid? NoteId { get; set; }
private NoteModel model = new();
private bool isSaving = false;
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
if (NoteId.HasValue)
{
await LoadNote();
}
}
private async Task LoadNote()
{
var result = await QueryRunner.Send(new GetNoteByIdQuery(NoteId!.Value));
if (result.IsSuccess && result.Data?.Note != null)
{
var note = result.Data.Note;
model.Title = note.Title;
model.Content = note.Content;
}
else
{
Navigation.NavigateTo("/notes");
}
}
private async Task Save()
{
isSaving = true;
errorMessage = null;
try
{
if (NoteId.HasValue)
{
var command = new UpdateNoteCommand(NoteId.Value, model.Title, model.Content);
var result = await CommandRunner.Send(command);
if (result.IsSuccess)
{
Navigation.NavigateTo("/notes");
}
else
{
errorMessage = result.Error?.Message ?? "Failed to update note";
}
}
else
{
var command = new CreateNoteCommand(model.Title, model.Content);
var result = await CommandRunner.Send(command);
if (result.IsSuccess)
{
Navigation.NavigateTo("/notes");
}
else
{
errorMessage = result.Error?.Message ?? "Failed to create note";
}
}
}
finally
{
isSaving = false;
}
}
private void Cancel()
{
Navigation.NavigateTo("/notes");
}
private class NoteModel
{
[Required(ErrorMessage = "Title is required")]
[MaxLength(200, ErrorMessage = "Title max 200 characters")]
public string Title { get; set; } = string.Empty;
[Required(ErrorMessage = "Content is required")]
[MaxLength(5000, ErrorMessage = "Content max 5000 characters")]
public string Content { get; set; } = string.Empty;
}
}
Step 10: Create Migration and Update Database
# Create migration
cd source/YourApp.App
dotnet ef migrations add AddNotes --context AppDbContext
# Apply migration
dotnet ef database update --context AppDbContext
Testing Your Feature
-
Run the application:
dotnet run -
Navigate to:
https://localhost:7001/notes -
Create a note:
- Click "New Note"
- Enter title and content
- Click "Save Note"
-
Verify:
- Note appears in list
- Click note to edit
- Update and save
- Note updates in list
Key Takeaways
✅ Write Side: Domain aggregate → Commands → Validators → Handlers → DbContext
✅ Read Side: Projections → Queries → Query Handlers → Read Models
✅ UI Layer: Blazor components → ICommandRunner / IQueryRunner
✅ Data Flow: User action → Command/Query → Handler → Database → Response
Next Steps
- Form Validation Patterns - Advanced validation
- Search and Filtering - Add search to your lists
- Security & Authorization - Resource-based auth
See Creating a New Project for a guided project bootstrap flow.