Skip to main content

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

This is the Exception, Not the Rule

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.IsSuccess is enough)
  • ❌ Convenience (it couples your write and read models)
Using the CLI

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:

  • GenerateApiKeyCommand implementing ICommand<GenerateApiKeyCommandResult>
  • GenerateApiKeyCommandResult record with an Id property
  • 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:

  1. The plain-text key is generated in memory
  2. Only the hash is persisted to the database
  3. The plain-text key cannot be queried after creation
  4. 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

ScenarioUse Result?Why
Generate API key✅ YesPlain-text key shown once, hash stored
Create password reset token✅ YesTemporary token, not stored as-is
Generate presigned upload URL✅ YesEphemeral URL from S3, not persisted
Create payment session✅ YesExternal provider returns session ID
Create note❌ NoQuery for the note after creation
Update user profile❌ NoIsSuccess is sufficient
Delete equipment❌ NoSuccess/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:

StepFileCLI GeneratedYou Customize
1CommandICommand record (no result)Change to ICommand<GenerateApiKeyCommandResult> + add result DTO
2ValidatorValidator stubAdd rules for key name, expiration validation
3AggregateSkeleton with propertiesAdd key generation logic, hashing, revocation methods
4HandlerHandler signature (2 generics: <Command, Aggregate>)Change to 3 generics: <Command, Result, Aggregate> + implement business logic
5Blazor— (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
}
Key Difference
  • 99% of commands: Use CLI-generated ICommand as-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>
Best Practice

Default to ICommand and only add a result type when:

  1. You've confirmed the data cannot be queried
  2. The data is security-sensitive or ephemeral
  3. You've documented why the result is necessary

Most developers overuse command results. When in doubt, use ICommand and query afterward.