Commands with Results: Rare but Necessary
This recipe demonstrates the rare case where a command needs to return data. Most commands should use ICommand (no result), but some scenarios genuinely require ICommand<T>.
99% of commands should NOT have result types. Only use ICommand<T> when:
- The command generates data that cannot be queried (temporary tokens, session IDs)
- The data is security-sensitive and shown only once (API keys, reset tokens)
- The data is ephemeral and not persisted as-is (presigned URLs, OTPs)
Do NOT use result types for:
- ❌ Entity IDs (query for the entity instead)
- ❌ Success flags (
result.IsSuccessis enough) - ❌ Convenience (it couples your write and read models)
The PearDrop CLI scaffolds the command structure. Use the --result flag to generate a command with a result type:
# 1. Create the domain aggregate
peardrop add aggregate ApiKey
# 2. Create command WITH result type
peardrop add command GenerateApiKey --aggregate ApiKey --result GenerateApiKeyCommandResult
# 3. Create queries to list keys
peardrop add query GetMyApiKeys --aggregate ApiKey
The CLI automatically generates:
GenerateApiKeyCommandimplementingICommand<GenerateApiKeyCommandResult>GenerateApiKeyCommandResultrecord with anIdproperty- Handler accepting the correct generic types:
AuditableCommandHandler<GenerateApiKeyCommand, GenerateApiKeyCommandResult, ApiKeyAggregate>
You only need to customize the domain logic in the aggregate and handler.
Note: Omit --result to generate a standard ICommand (the default for 99% of commands). Use --no-result to skip result types entirely and use EmptyCommandResult.
Scenario: Generate API Key
When a user generates an API key, the plain-text key is shown once and never retrievable again. Only the hash is stored in the database. This is a perfect use case for a command result.
Why a result is needed:
- The plain-text key is generated in memory
- Only the hash is persisted to the database
- The plain-text key cannot be queried after creation
- The UI must display it immediately for the user to copy
Step 1: Customize Command with Result Type
After running peardrop add command GenerateApiKey --result GenerateApiKeyCommandResult, open and customize:
File: Infrastructure/Domain/ApiKeyAggregate/Commands/GenerateApiKeyCommand.cs
using BluQube.Commands;
namespace YourApp.Infrastructure.Domain.ApiKeyAggregate.Commands;
/// <summary>
/// Generate a new API key for the current user.
/// Returns the plain-text key (shown once only).
/// </summary>
public sealed record GenerateApiKeyCommand(
string KeyName,
DateTime ExpiresAt) : ICommand<GenerateApiKeyCommandResult>; // MUST return the key
public sealed record GenerateApiKeyCommandResult(
Guid ApiKeyId,
string PlainTextKey, // This cannot be queried - only shown once
DateTime CreatedAt,
DateTime ExpiresAt);
Key Points:
- ✅ Uses
ICommand<GenerateApiKeyCommandResult>because the plain-text key must be returned - ✅ Result contains data that will never be stored as-is (plain-text key)
- ✅ Result includes metadata useful for immediate UI display
Step 2: Add Validation Rules
The CLI generates validator stubs. Customize with your rules:
File: Infrastructure/Domain/ApiKeyAggregate/CommandValidators/GenerateApiKeyCommandValidator.cs
using FluentValidation;
namespace YourApp.Infrastructure.Domain.ApiKeyAggregate.CommandValidators;
public sealed class GenerateApiKeyCommandValidator : AbstractValidator<GenerateApiKeyCommand>
{
// CLI generates stub - YOU add rules
public GenerateApiKeyCommandValidator()
{
RuleFor(x => x.KeyName)
.NotEmpty().WithMessage("Key name is required")
.MaximumLength(100).WithMessage("Key name max 100 characters");
RuleFor(x => x.ExpiresAt)
.GreaterThan(DateTime.UtcNow).WithMessage("Expiration must be in the future");
}
}
Step 3: Implement Domain Aggregate
The CLI generates the aggregate skeleton. Customize with your business logic:
File: Infrastructure/Domain/ApiKeyAggregate/AggregateRoot/ApiKeyAggregate.cs
using PearDrop.Domain;
using PearDrop.Domain.Contracts;
using System.Security.Cryptography;
using System.Text;
namespace YourApp.Infrastructure.Domain.ApiKeyAggregate.AggregateRoot;
public sealed class ApiKeyAggregate : Entity, IAggregateRoot
{
private ApiKeyAggregate() { } // EF Core
// Public constructor - accepts HASHED key only
public ApiKeyAggregate(
Guid id,
string keyName,
string keyHash, // NOT plain-text
Guid userId,
DateTime expiresAt)
{
Id = id;
KeyName = keyName;
KeyHash = keyHash;
UserId = userId;
ExpiresAt = expiresAt;
IsRevoked = false;
CreatedAt = DateTime.UtcNow;
}
public Guid Id { get; private set; }
public string KeyName { get; private set; } = null!;
public string KeyHash { get; private set; } = null!; // SHA256 hash
public Guid UserId { get; private set; }
public DateTime ExpiresAt { get; private set; }
public bool IsRevoked { get; private set; }
public DateTime CreatedAt { get; private set; }
// Static method to generate key and hash
public static (string PlainTextKey, string Hash) GenerateKeyAndHash()
{
// Generate random key (32 bytes = 256 bits)
var keyBytes = RandomNumberGenerator.GetBytes(32);
var plainTextKey = $"pk_{Convert.ToBase64String(keyBytes).Replace("+", "-").Replace("/", "_").TrimEnd('=')}";
// Hash the key for storage
var hash = HashKey(plainTextKey);
return (plainTextKey, hash);
}
public static string HashKey(string plainTextKey)
{
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(plainTextKey));
return Convert.ToBase64String(hashBytes);
}
public void Revoke()
{
IsRevoked = true;
}
}
Key Points:
- ✅ Aggregate stores only the hash, not the plain-text key
- ✅ Static method generates both plain-text and hash (handler uses this)
- ✅ Business logic for revocation included
Step 4: Implement Command Handler
The CLI generates the handler signature with three generics. Customize with your business logic:
File: Infrastructure/Domain/ApiKeyAggregate/CommandHandlers/GenerateApiKeyCommandHandler.cs
using BluQube.Commands;
using FluentValidation;
using Microsoft.Extensions.Logging;
using PearDrop.Client.Constants;
using PearDrop.Domain.Contracts;
using PearDrop.Extensions;
namespace YourApp.Infrastructure.Domain.ApiKeyAggregate.CommandHandlers;
// CLI generates class signature with three generics: Command, Result, Aggregate
internal sealed class GenerateApiKeyCommandHandler :
AuditableCommandHandler<GenerateApiKeyCommand, GenerateApiKeyCommandResult, ApiKeyAggregate>
{
private readonly IHttpContextAccessor httpContextAccessor;
// CLI generates constructor signature
public GenerateApiKeyCommandHandler(
IEnumerable<IValidator<GenerateApiKeyCommand>> validators,
ILogger<GenerateApiKeyCommandHandler> logger,
ICommandStore commandStore,
IRepositoryFactory<ApiKeyAggregate> repositoryFactory,
IHttpContextAccessor httpContextAccessor)
: base(validators, logger, commandStore, repositoryFactory)
{
this.httpContextAccessor = httpContextAccessor;
}
// YOU implement this method with business logic
protected override async Task<CommandResult<GenerateApiKeyCommandResult>> HandleInternalWithRepository(
GenerateApiKeyCommand command,
CancellationToken cancellationToken)
{
// Get current user
var userMaybe = this.httpContextAccessor.HttpContext?.ToSystemUser();
if (userMaybe?.HasNoValue != false)
{
return CommandResult<GenerateApiKeyCommandResult>.Failed(new BluQubeErrorData(
ErrorCodes.CoreValidation, "User not authenticated"));
}
var user = userMaybe.Value;
// Generate plain-text key and hash (uses domain logic from Step 3)
var (plainTextKey, keyHash) = ApiKeyAggregate.GenerateKeyAndHash();
// Create aggregate with ONLY the hash
var apiKeyId = Guid.NewGuid();
var apiKey = new ApiKeyAggregate(
apiKeyId,
command.KeyName,
keyHash, // Store hash only
user.UserId,
command.ExpiresAt);
// Persist
this.Repository.Add(apiKey);
var saveResult = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
if (saveResult.IsFailure)
{
return CommandResult<GenerateApiKeyCommandResult>.Failed(saveResult.Error!);
}
// Return plain-text key to UI (only time it's available)
return CommandResult<GenerateApiKeyCommandResult>.Succeeded(
new GenerateApiKeyCommandResult(
apiKeyId,
plainTextKey, // Plain-text returned ONCE
apiKey.CreatedAt,
apiKey.ExpiresAt));
}
}
Key Points:
- ✅ Handler inherits
AuditableCommandHandler<TCommand, TResult, TAggregate>(three generics) - ✅ Returns
CommandResult<GenerateApiKeyCommandResult> - ✅ Plain-text key is generated in memory and returned
- ✅ Only the hash is persisted to the database
Step 5: Build Blazor Component (Manually Created)
Blazor components are NOT generated by CLI—create them manually. This component highlights the security-sensitive nature of showing the plain-text key.
File: Pages/ApiKeys/GenerateApiKey.razor
@page "/api-keys/generate"
@using YourApp.Contracts.Commands
@inject ICommandRunner CommandRunner
@inject NavigationManager Navigation
<div class="container mt-4">
<div class="row">
<div class="col-md-8">
<h2>Generate API Key</h2>
@if (generatedKey != null)
{
<div class="alert alert-warning">
<h4>⚠️ Save Your API Key Now</h4>
<p>This is the <strong>only time</strong> you'll see this key. Copy it and store it securely.</p>
<div class="mb-3">
<label class="form-label">API Key:</label>
<div class="input-group">
<input type="text"
readonly
class="form-control font-monospace"
value="@generatedKey.PlainTextKey" />
<button class="btn btn-outline-secondary"
@onclick="CopyToClipboard">
📋 Copy
</button>
</div>
</div>
<p class="mb-1"><strong>Key Name:</strong> @generatedKey.KeyName</p>
<p class="mb-1"><strong>Expires:</strong> @generatedKey.ExpiresAt.ToString("yyyy-MM-dd")</p>
<button class="btn btn-primary mt-3" @onclick="GoToList">
I've Saved My Key
</button>
</div>
}
else
{
<EditForm Model="@form" OnValidSubmit="@HandleSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="mb-3">
<label for="keyName" class="form-label">Key Name</label>
<InputText id="keyName"
@bind-Value="form.KeyName"
class="form-control"
placeholder="Production API Key" />
<ValidationMessage For="@(() => form.KeyName)" />
</div>
<div class="mb-3">
<label for="expiresAt" class="form-label">Expires On</label>
<InputDate id="expiresAt"
@bind-Value="form.ExpiresAt"
class="form-control" />
<ValidationMessage For="@(() => form.ExpiresAt)" />
</div>
<button type="submit" class="btn btn-primary" disabled="@submitting">
@(submitting ? "Generating..." : "Generate Key")
</button>
<a href="/api-keys" class="btn btn-secondary">Cancel</a>
@if (!string.IsNullOrEmpty(error))
{
<div class="alert alert-danger mt-3">@error</div>
}
</EditForm>
}
</div>
</div>
</div>
@code {
private GenerateKeyForm form = new() { ExpiresAt = DateTime.UtcNow.AddYears(1) };
private bool submitting = false;
private string? error;
private GenerateApiKeyCommandResult? generatedKey;
private async Task HandleSubmit()
{
submitting = true;
error = null;
var command = new GenerateApiKeyCommand(form.KeyName, form.ExpiresAt);
var result = await CommandRunner.ExecuteAsync(command);
if (result.IsSuccess && result.Content != null)
{
// Store result to display the key
generatedKey = result.Content;
}
else
{
error = result.DetailedError?.Message ?? "Failed to generate API key";
}
submitting = false;
}
private async Task CopyToClipboard()
{
if (generatedKey != null)
{
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", generatedKey.PlainTextKey);
}
}
private void GoToList()
{
Navigation.NavigateTo("/api-keys");
}
private class GenerateKeyForm
{
public string KeyName { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
}
}
Key Points:
- ✅ Displays the plain-text key with a warning
- ✅ Provides copy-to-clipboard functionality
- ✅ Forces user to acknowledge they've saved the key
- ✅ After navigation, the key is gone forever
Summary: When to Use Command Results
| Scenario | Use Result? | Why |
|---|---|---|
| Generate API key | ✅ Yes | Plain-text key shown once, hash stored |
| Create password reset token | ✅ Yes | Temporary token, not stored as-is |
| Generate presigned upload URL | ✅ Yes | Ephemeral URL from S3, not persisted |
| Create payment session | ✅ Yes | External provider returns session ID |
| Create note | ❌ No | Query for the note after creation |
| Update user profile | ❌ No | IsSuccess is sufficient |
| Delete equipment | ❌ No | Success/failure is enough |
Pattern Comparison
Without Result (99% of commands):
public sealed record CreateNoteCommand(string Title) : ICommand;
// Handler
protected override async Task<CommandResult> HandleInternalWithRepository(...)
{
// ... create note ...
return CommandResult.Succeeded(); // UI queries for data
}
With Result (1% of commands):
public sealed record GenerateApiKeyCommand(string Name) : ICommand<GenerateApiKeyCommandResult>;
// Handler
protected override async Task<CommandResult<GenerateApiKeyCommandResult>> HandleInternalWithRepository(...)
{
var (plainText, hash) = GenerateKey();
// ... store hash only ...
return CommandResult<GenerateApiKeyCommandResult>.Succeeded(
new GenerateApiKeyCommandResult(id, plainText, ...)); // Must return plain-text
}
Summary: CLI-First Workflow for Commands with Results
Here's the complete workflow. Use the standard CLI commands, then customize for result types:
# 1. Scaffold the aggregate
peardrop add aggregate ApiKey
# 2. Scaffold command (CLI creates ICommand format)
# - You will customize to add result type
peardrop add command GenerateApiKey --aggregate ApiKey
# 3. Scaffold queries for listing keys
peardrop add query GetMyApiKeys --aggregate ApiKey
Then manually customize the generated files:
| Step | File | CLI Generated | You Customize |
|---|---|---|---|
| 1 | Command | ICommand record (no result) | Change to ICommand<GenerateApiKeyCommandResult> + add result DTO |
| 2 | Validator | Validator stub | Add rules for key name, expiration validation |
| 3 | Aggregate | Skeleton with properties | Add key generation logic, hashing, revocation methods |
| 4 | Handler | Handler signature (2 generics: <Command, Aggregate>) | Change to 3 generics: <Command, Result, Aggregate> + implement business logic |
| 5 | Blazor | — (manual creation) | Create form, result display, copy-to-clipboard, "save once" warning |
Key principle: CLI scaffolds the ICommand baseline, you add result types only when security/ephemeral data requires it.
Pattern Comparison
Without Result (99% of commands) - No CLI customization needed:
public sealed record CreateNoteCommand(string Title) : ICommand;
// Handler (as generated by CLI)
protected override async Task<CommandResult> HandleInternalWithRepository(...)
{
// ... create note ... return CommandResult.Succeeded(); // UI queries for data }
**With Result (1% of commands) - Requires customization:**
```csharp
// CLI generates:
public sealed record GenerateApiKeyCommand(string Name) : ICommand;
// YOU CUSTOMIZE TO:
public sealed record GenerateApiKeyCommand(string Name) : ICommand<GenerateApiKeyCommandResult>;
public sealed record GenerateApiKeyCommandResult(Guid Id, string PlainTextKey, ...);
// CLI generates:
// AuditableCommandHandler<GenerateApiKeyCommand, ApiKeyAggregate>
// YOU CUSTOMIZE TO:
// AuditableCommandHandler<GenerateApiKeyCommand, GenerateApiKeyCommandResult, ApiKeyAggregate>
// YOU implement:
protected override async Task<CommandResult<GenerateApiKeyCommandResult>> HandleInternalWithRepository(...)
{
var (plainText, hash) = GenerateKey();
// ... store hash only ...
return CommandResult<GenerateApiKeyCommandResult>.Succeeded(
new GenerateApiKeyCommandResult(id, plainText, ...)); // Must return plain-text
}
- 99% of commands: Use CLI-generated
ICommandas-is → saves code - 1% of commands: Customize CLI output to add result type → requires explicit changes
Decision Flowchart
Does the UI need data back from the command?
│
├─ No → Use ICommand (default)
│
└─ Yes → Can this data be queried after creation?
│
├─ Yes → Use ICommand (query for it instead)
│
└─ No (ephemeral/security-sensitive) → Use ICommand<T>
Default to ICommand and only add a result type when:
- You've confirmed the data cannot be queried
- The data is security-sensitive or ephemeral
- You've documented why the result is necessary
Most developers overuse command results. When in doubt, use ICommand and query afterward.