Build Your First Feature
This guide walks you through implementing a complete feature using the PearDrop framework.
Overview
We'll build a simple Note feature with these operations:
- Create a new note
- View a note by ID
- Update a note
- Archive a note
This demonstrates the full CQRS pattern: commands (write), queries (read), aggregates, and command/query handlers.
Step 1: Create the Domain Aggregate
Use the CLI to scaffold the aggregate:
dotnet tool run peardrop add aggregate Note --properties "Title:string,Content:string,IsArchived:bool"
The CLI generates: source/<YourProject>.App/Infrastructure/Domain/NoteAggregate/AggregateRoot/NoteAggregate.cs
Now add your business logic to the generated aggregate. Open the file and add these methods:
using BluQube.Commands;
using PearDrop.Domain;
using PearDrop.Domain.Contracts;
using ResultMonad;
namespace YourProject.App.Infrastructure.Domain.NoteAggregate.AggregateRoot;
public class NoteAggregate : Entity, IAggregateRoot
{
// ... generated properties ...
public ResultWithError<BluQubeErrorData> UpdateContent(string newTitle, string newContent)
{
if (this.IsArchived)
return ResultWithError.Fail<BluQubeErrorData>(
new BluQubeErrorData("Note.Archived", "Cannot update archived note"));
if (string.IsNullOrWhiteSpace(newTitle))
return ResultWithError.Fail<BluQubeErrorData>(
new BluQubeErrorData("Note.InvalidTitle", "Title cannot be empty"));
this.Title = newTitle;
this.Content = newContent;
return ResultWithError.Ok<BluQubeErrorData>();
}
public ResultWithError<BluQubeErrorData> Archive()
{
if (this.IsArchived)
return ResultWithError.Fail<BluQubeErrorData>(
new BluQubeErrorData("Note.AlreadyArchived", "Note is already archived"));
this.IsArchived = true;
return ResultWithError.Ok<BluQubeErrorData>();
}
}
Step 2: Create Commands
Use the CLI to generate commands:
dotnet tool run peardrop add command CreateNote --aggregate Note --properties "Title:string,Content:string"
dotnet tool run peardrop add command UpdateNote --aggregate Note --properties "NoteId:Guid,Title:string,Content:string"
dotnet tool run peardrop add command ArchiveNote --aggregate Note --properties "NoteId:Guid"
The CLI generates command files in: source/<YourProject>.App/Infrastructure/Domain/NoteAggregate/Commands/
These are created as records (immutable) - no editing needed unless adding new properties.
Step 3: Create Command Handlers
The CLI automatically generates handler stubs:
# Handlers are created automatically with the commands above
# They're in: source/<YourProject>.App/Infrastructure/Domain/NoteAggregate/CommandHandlers/
Open the generated handler file and add your validation and business logic:
File: source/<YourProject>.App/Infrastructure/Domain/NoteAggregate/CommandHandlers/CreateNoteCommandHandler.cs
using BluQube.Commands;
using PearDrop.Domain.Contracts;
using PearDrop.Extensions;
using YourProject.App.Infrastructure.Domain.NoteAggregate.AggregateRoot;
using YourProject.App.Infrastructure.Domain.NoteAggregate.Commands;
namespace YourProject.App.Infrastructure.Domain.NoteAggregate.CommandHandlers;
internal sealed class CreateNoteCommandHandler :
AuditableCommandHandler<CreateNoteCommand, Guid>
{
private readonly IHttpContextAccessor httpContextAccessor;
public CreateNoteCommandHandler(
IEnumerable<IValidator<CreateNoteCommand>> validators,
ILogger<CreateNoteCommandHandler> logger,
ICommandStore commandStore,
IRepositoryFactory<NoteAggregate> repositoryFactory,
IHttpContextAccessor httpContextAccessor)
: base(validators, logger, commandStore, repositoryFactory)
{
this.httpContextAccessor = httpContextAccessor;
}
protected override async Task<CommandResult<Guid>> HandleInternalWithRepository(
CreateNoteCommand request,
CancellationToken cancellationToken)
{
// Get current user using PearDrop's extension method
var userMaybe = this.httpContextAccessor.HttpContext?.ToSystemUser();
if (userMaybe?.HasNoValue != false)
{
return CommandResult<Guid>.Failed(new BluQubeErrorData(
"AUTH_001", "User not authenticated"));
}
var user = userMaybe.Value;
// Create aggregate
var note = new NoteAggregate(
Guid.NewGuid(),
request.Title,
request.Content,
user.UserId);
// Add to repository and save
this.Repository.Add(note);
var result = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
if (result.IsSuccess)
{
return CommandResult<Guid>.Succeeded(note.Id);
}
return CommandResult<Guid>.Failed(result.Error!);
}
}
Step 4: Create Queries
Use the CLI to generate query operations:
dotnet tool run peardrop add query GetNoteById --aggregate Note --properties "NoteId:Guid" --result NoteDetailDto
dotnet tool run peardrop add query GetMyNotes --aggregate Note --result "List<NoteSummaryDto>"
The CLI generates: source/<YourProject>.App/Queries/GetNoteByIdQuery.cs and GetMyNotesQuery.cs
These define the query contracts - typically they don't need editing unless you want different result structures.
Step 5: Create Read Models
Read models are optimized projections for queries. Create them manually:
File: source/<YourProject>.App/Infrastructure/Data/ReadModel/Projections/NoteProjection.cs
using PearDrop.ReadModels;
namespace YourProject.App.Infrastructure.Data.ReadModel.Projections;
public sealed class NoteProjection : IProjection
{
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 DateTime CreatedAt { get; init; }
}
Step 6: Create Database Migrations
Create and apply database migrations to persist your aggregate and read model changes:
dotnet ef migrations add CreateNoteAggregate
dotnet tool run peardrop migrate
This creates the Notes table and the view for NoteProjection.
Step 7: Create Query Handlers
Query handlers generate the read data. Open the generated handler and implement it:
File: source/<YourProject>.App/Queries/QueryHandlers/GetNoteByIdQueryHandler.cs
using BluQube.Queries;
using System.Security.Claims;
using YourProject.App.Infrastructure.Data.ReadModel;
using YourProject.App.Queries;
namespace YourProject.App.Queries.QueryHandlers;
internal 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)
{
var userIdClaim = this.httpContextAccessor.HttpContext?
.User.FindFirst(ClaimTypes.NameIdentifier);
if (userIdClaim == null || !Guid.TryParse(userIdClaim.Value, out var userId))
{
return QueryResult<GetNoteByIdQueryResult>.Failed();
}
var note = await this.readModels.Notes
.Where(n => n.Id == request.NoteId && n.UserId == userId)
.Select(n => new GetNoteByIdQueryResult.NoteDto(
n.Id,
n.Title,
n.Content,
n.IsArchived,
n.CreatedAt))
.FirstOrDefaultAsync(cancellationToken);
if (note == null)
{
return QueryResult<GetNoteByIdQueryResult>.Failed();
}
return QueryResult<GetNoteByIdQueryResult>.Succeeded(
new GetNoteByIdQueryResult(note));
}
}
Step 8: Create Blazor Components
Create a Blazor component to render your notes and interact with commands and queries:
File: source/<YourProject>.App.Client/Components/Pages/Notes.razor
@page "/notes"
<h3>My Notes</h3>
@if (IsLoading)
{
<p>Loading notes...</p>
}
else if (Notes.Any())
{
@foreach (var note in Notes)
{
<div class="note-card">
<h4>@note.Title</h4>
<p>@note.Content</p>
<small>Created: @note.CreatedAt.ToString("g")</small>
<button @onclick="() => OnDeleteAsync(note.Id)">Delete</button>
</div>
}
}
<button @onclick="OnCreateAsync">Create Note</button>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="alert alert-danger">@ErrorMessage</div>
}
File: source/<YourProject>.App.Client/Components/Pages/Notes.razor.cs
using Microsoft.AspNetCore.Components;
using BluQube.Commands;
using BluQube.Queries;
using YourProject.App.Contracts.Notes;
namespace YourProject.App.Client.Components.Pages;
public partial class Notes(
ICommandRunner commandRunner,
IQueryRunner queryRunner,
ILogger<Notes> logger) : ComponentBase
{
protected List<GetMyNotesQueryResult.NoteDto> Notes { get; set; } = new();
protected bool IsLoading { get; set; } = true;
protected string? ErrorMessage { get; set; }
protected override async Task OnInitializedAsync()
{
await LoadNotesAsync();
}
protected async Task LoadNotesAsync()
{
IsLoading = true;
ErrorMessage = null;
// Use ExecuteAsync for automatic tracing in Blazor components
await queryRunner.ExecuteAsync(
new GetMyNotesQuery(),
onSuccess: async (result) =>
{
Notes = result.Notes;
logger.LogInformation("Loaded {Count} notes", Notes.Count);
},
onError: async (errorMessage) =>
{
ErrorMessage = $"Failed to load notes: {errorMessage}";
logger.LogWarning("Query failed: {Error}", errorMessage);
});
IsLoading = false;
}
protected async Task OnCreateAsync()
{
var command = new CreateNoteCommand(
Title: "New Note",
Content: "Enter content here...");
// ExecuteAsync provides automatic error handling and tracing
await commandRunner.ExecuteAsync(
command,
onSuccess: async (result) =>
{
logger.LogInformation("Note created: {NoteId}", result.NoteId);
await LoadNotesAsync(); // Reload list
ErrorMessage = null;
},
onError: async (errorMessage) =>
{
ErrorMessage = $"Failed to create note: {errorMessage}";
logger.LogWarning("Create failed: {Error}", errorMessage);
});
}
protected async Task OnDeleteAsync(Guid noteId)
{
var command = new DeleteNoteCommand(noteId);
await commandRunner.ExecuteAsync(
command,
onSuccess: async (result) =>
{
logger.LogInformation("Note deleted: {NoteId}", noteId);
await LoadNotesAsync(); // Reload list
ErrorMessage = null;
},
onError: async (errorMessage) =>
{
ErrorMessage = $"Failed to delete note: {errorMessage}";
logger.LogWarning("Delete failed: {Error}", errorMessage);
});
}
}
Summary
You've implemented a complete feature using:
- ✅ Aggregate - Domain entity with business logic
- ✅ Commands - State-changing operations
- ✅ Command Handlers - Execute commands with validation
- ✅ Queries - Read operations
- ✅ Read Models - Optimized projections
- ✅ Database Migrations - Persist changes to database
- ✅ Query Handlers - Execute queries
- ✅ Blazor Components - UI that uses commands & queries
Next Steps
- CQRS Operations - Deep dive into CQRS patterns
- Project Structure - Understand the full layout
- API Reference - API documentation