Skip to main content

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:

  1. Aggregate - Domain entity with business logic
  2. Commands - State-changing operations
  3. Command Handlers - Execute commands with validation
  4. Queries - Read operations
  5. Read Models - Optimized projections
  6. Database Migrations - Persist changes to database
  7. Query Handlers - Execute queries
  8. Blazor Components - UI that uses commands & queries

Next Steps