Skip to main content

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.

Using the CLI

The PearDrop CLI generates file stubs automatically. This guide shows you:

  1. Generate stubs with CLI - Let the tool create the structure
  2. Fill in implementations - Add domain logic, validation, handlers
  3. Wire it together - Register services and create UI

This approach keeps you focused on domain logic, not boilerplate.

What You'll Build

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 ResultMonad for 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;
Command Results are Very Rare

Default Pattern: Commands should use ICommand (not ICommand<T>).

  • ✅ Commands represent side effects (create, update, delete, archive)
  • ✅ UI checks result.IsSuccess for 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");
}
}
See Also

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!);
}
}
All Handlers Use This Pattern

Notice all command handlers in this recipe follow the same pattern:

  • ✅ Inherit AuditableCommandHandler<TCommand, TAggregate> (two generics, no result type)
  • ✅ Return CommandResult (not CommandResult<T>)
  • ✅ Still validate, log, and handle errors
  • ✅ UI reads result.IsSuccess to 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 __EFMigrationsHistory table entry

Summary: From Domain Model to UI

You now have a complete feature:

LayerFilesResponsibility
DomainNoteAggregate.csBusiness rules & invariants
CommandsCreateNoteCommand, UpdateNoteCommandUser intent
HandlersCreateNoteCommandHandlerCommand execution & persistence
QueriesGetMyNotesQueryRead optimization
Read ModelsNoteProjection, IAppReadModelsQuery performance
DatabaseEntity config, migrationsData schema
UIBlazor componentsUser interaction

The CLI generated the scaffolds. You implemented the domain logic. That's the workflow.

Key Takeaways

  1. CLI scaffolds, you customize: Don't write boilerplate. Use peardrop add aggregate and customize for your domain.

  2. Read/Write separation:

    • Write: Domain aggregates with business logic
    • Read: Projections optimized for queries
  3. Authorization in handlers: Check permissions before modifying aggregates (see Step 2)

  4. Validators in commands: Move validation logic into dedicated validator classes (see Step 3)

  5. Blazor components use runners: Inject ICommandRunner and IQueryRunner to execute CQRS operations

  6. Domain events coordinate aggregates: For complex scenarios, use domain events within the same bounded context (see Domain Events)

  7. 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

  1. Run the application:

    dotnet run
  2. Navigate to: https://localhost:7001/notes

  3. Create a note:

    • Click "New Note"
    • Enter title and content
    • Click "Save Note"
  4. 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


See Creating a New Project for a guided project bootstrap flow.