Commands in Momentum
Commands represent write operations in your application that change state. In Momentum, commands follow a specific pattern that ensures consistency, validation, and event publishing.
Command Definition
Commands are immutable records that implement ICommand<TResult>
:
public record CreateCashierCommand(Guid TenantId, string Name, string Email) : ICommand<Result<Cashier>>;
Command Characteristics
- Immutable: Commands are records that cannot be modified after creation
- Typed: Commands specify their return type through
ICommand<TResult>
- Descriptive: Command names should clearly indicate the action (CreateX, UpdateX, DeleteX)
- Focused: Each command should have a single responsibility
Basic Command Example
Here's a complete example from the AppDomain reference implementation:
// Commands/CreateCashier.cs
using AppDomain.Cashiers.Contracts.IntegrationEvents;
using AppDomain.Cashiers.Contracts.Models;
using FluentValidation;
namespace AppDomain.Cashiers.Commands;
public record CreateCashierCommand(Guid TenantId, string Name, string Email) : ICommand<Result<Cashier>>;
public class CreateCashierValidator : AbstractValidator<CreateCashierCommand>
{
public CreateCashierValidator()
{
RuleFor(c => c.TenantId).NotEmpty();
RuleFor(c => c.Name).NotEmpty().MinimumLength(2).MaximumLength(100);
RuleFor(c => c.Email).NotEmpty().EmailAddress();
}
}
public static class CreateCashierCommandHandler
{
public record DbCommand(Data.Entities.Cashier Cashier) : ICommand<Data.Entities.Cashier>;
public static async Task<(Result<Cashier>, CashierCreated?)> Handle(
CreateCashierCommand command,
IMessageBus messaging,
CancellationToken cancellationToken)
{
var dbCommand = CreateInsertCommand(command);
var insertedCashier = await messaging.InvokeCommandAsync(dbCommand, cancellationToken);
var result = insertedCashier.ToModel();
var createdEvent = new CashierCreated(result.TenantId, PartitionKeyTest: 0, result);
return (result, createdEvent);
}
public static async Task<Data.Entities.Cashier> Handle(
DbCommand command,
AppDomainDb db,
CancellationToken cancellationToken)
{
return await db.Cashiers.InsertWithOutputAsync(command.Cashier, token: cancellationToken);
}
private static DbCommand CreateInsertCommand(CreateCashierCommand command) =>
new(new Data.Entities.Cashier
{
TenantId = command.TenantId,
CashierId = Guid.CreateVersion7(),
Name = command.Name,
Email = command.Email,
CreatedDateUtc = DateTime.UtcNow,
UpdatedDateUtc = DateTime.UtcNow
});
}
Command Handler Pattern
Two-Tier Handler Structure
Momentum uses a two-tier handler structure:
- Main Handler - Business logic and orchestration
- Database Handler - Pure database operations
This separation provides several benefits:
- Testability: You can unit test business logic separately from database operations
- Reusability: Database commands can be reused across different handlers
- Clarity: Clear separation of concerns
Main Handler Signature
The main handler always returns a tuple with the result and optional integration event:
public static async Task<(Result<T>, IntegrationEvent?)> Handle(
TCommand command,
IMessageBus messaging,
CancellationToken cancellationToken)
Database Handler Signature
Database handlers perform the actual data operations:
public static async Task<TEntity> Handle(
DbCommand command,
TDbContext db,
CancellationToken cancellationToken)
Command Validation
Commands are automatically validated using FluentValidation before the handler executes:
public class UpdateCashierValidator : AbstractValidator<UpdateCashierCommand>
{
public UpdateCashierValidator()
{
RuleFor(c => c.Id).NotEmpty();
RuleFor(c => c.TenantId).NotEmpty();
RuleFor(c => c.Name)
.NotEmpty()
.WithMessage("Name is required")
.MinimumLength(2)
.WithMessage("Name must be at least 2 characters")
.MaximumLength(100)
.WithMessage("Name cannot exceed 100 characters");
}
}
Validation Features
- Automatic execution: Validation runs before the handler
- Early return: Invalid commands return validation errors immediately
- Custom messages: Provide user-friendly error messages
- Complex rules: Support for conditional validation and cross-field validation
Advanced Command Patterns
Update Commands
Update commands typically need to fetch existing data first:
public record UpdateCashierCommand(Guid TenantId, Guid Id, string Name, string Email) : ICommand<Result<Cashier>>;
public static class UpdateCashierCommandHandler
{
public record DbCommand(Data.Entities.Cashier Cashier) : ICommand<Data.Entities.Cashier>;
public static async Task<(Result<Cashier>, CashierUpdated?)> Handle(
UpdateCashierCommand command,
IMessageBus messaging,
CancellationToken cancellationToken)
{
// First, get the existing cashier
var getQuery = new GetCashierQuery(command.TenantId, command.Id);
var existingResult = await messaging.InvokeAsync(getQuery, cancellationToken);
if (!existingResult.IsSuccess)
{
return (existingResult, null);
}
var existing = existingResult.Value;
// Create update command
var dbCommand = CreateUpdateCommand(command, existing);
var updatedCashier = await messaging.InvokeCommandAsync(dbCommand, cancellationToken);
var result = updatedCashier.ToModel();
var updatedEvent = new CashierUpdated(result.TenantId, result);
return (result, updatedEvent);
}
public static async Task<Data.Entities.Cashier> Handle(
DbCommand command,
AppDomainDb db,
CancellationToken cancellationToken)
{
return await db.Cashiers
.Where(c => c.CashierId == command.Cashier.CashierId)
.UpdateWithOutputAsync(
_ => new Data.Entities.Cashier
{
Name = command.Cashier.Name,
Email = command.Cashier.Email,
UpdatedDateUtc = DateTime.UtcNow
},
token: cancellationToken);
}
private static DbCommand CreateUpdateCommand(UpdateCashierCommand command, Cashier existing) =>
new(new Data.Entities.Cashier
{
TenantId = existing.TenantId,
CashierId = existing.Id,
Name = command.Name,
Email = command.Email,
CreatedDateUtc = existing.CreatedDate,
UpdatedDateUtc = DateTime.UtcNow
});
}
Delete Commands
Delete commands should verify the entity exists before deletion:
public record DeleteCashierCommand(Guid TenantId, Guid Id) : ICommand<Result<bool>>;
public static class DeleteCashierCommandHandler
{
public record DbCommand(Guid TenantId, Guid CashierId) : ICommand<int>;
public static async Task<(Result<bool>, CashierDeleted?)> Handle(
DeleteCashierCommand command,
IMessageBus messaging,
CancellationToken cancellationToken)
{
// Verify the cashier exists
var getQuery = new GetCashierQuery(command.TenantId, command.Id);
var existingResult = await messaging.InvokeAsync(getQuery, cancellationToken);
if (!existingResult.IsSuccess)
{
return (existingResult.Errors, null);
}
var dbCommand = new DbCommand(command.TenantId, command.Id);
var deletedCount = await messaging.InvokeCommandAsync(dbCommand, cancellationToken);
if (deletedCount > 0)
{
var deletedEvent = new CashierDeleted(command.TenantId, command.Id);
return (true, deletedEvent);
}
return (new List<ValidationFailure> { new("Id", "Cashier could not be deleted") }, null);
}
public static async Task<int> Handle(
DbCommand command,
AppDomainDb db,
CancellationToken cancellationToken)
{
return await db.Cashiers
.Where(c => c.TenantId == command.TenantId && c.CashierId == command.CashierId)
.DeleteAsync(token: cancellationToken);
}
}
Error Handling
Commands use the Result<T>
pattern for consistent error handling:
Success Results
// Implicit conversion from value to Result<T>
return cashier; // Automatically becomes Result<Cashier>.Success(cashier)
Error Results
// From validation failures
return new List<ValidationFailure>
{
new("Property", "Error message")
};
// From custom errors
return Result<Cashier>.Failure("Custom error message");
Integration Events
Commands can publish integration events to notify other services:
public static async Task<(Result<Cashier>, CashierCreated?)> Handle(
CreateCashierCommand command,
IMessageBus messaging,
CancellationToken cancellationToken)
{
// ... command logic ...
var result = insertedCashier.ToModel();
// Create integration event
var createdEvent = new CashierCreated(
TenantId: result.TenantId,
PartitionKeyTest: 0,
Cashier: result
);
return (result, createdEvent);
}
The integration event will be automatically published by the framework if the command succeeds.
Best Practices
Command Design
- Use descriptive names:
CreateCashierCommand
, notCashierCommand
- Keep commands focused: One command should do one thing
- Make commands immutable: Always use records
- Include tenant context: Multi-tenant applications should include
TenantId
Handler Design
- Separate concerns: Keep business logic in the main handler, database operations in DbCommand handlers
- Use transactions: Database operations should be atomic
- Validate early: Use FluentValidation for input validation
- Handle errors gracefully: Return meaningful error messages
Validation Rules
- Validate all inputs: Every command should have a validator
- Provide clear messages: Users should understand validation errors
- Use async rules sparingly: Prefer fast, synchronous validation
- Group related rules: Use
RuleSet
for different validation scenarios
Event Publishing
- Only publish on success: Integration events should only be published when commands succeed
- Include necessary data: Events should contain all data needed by consumers
- Use partition keys: Ensure event ordering with appropriate partition keys
- Document events: Use XML documentation for integration events
Testing Commands
See our Testing Guide for comprehensive examples of testing commands, including:
- Unit testing command handlers
- Testing validation rules
- Integration testing with databases
- Mocking dependencies
Next Steps
- Learn about Queries for read operations
- Understand Handlers in more detail
- Explore Validation patterns
- See Integration Events for cross-service communication