Skip to content

ICommandHandler<TCommand, TResult>

ICommandHandler<TCommand, TResult> is the interface for operations with side effects. Implement it to handle a specific ICommand<TResult>.

Interface signature

csharp
public interface ICommandHandler<TCommand, TResult>
    where TCommand : ICommand<TResult>
{
    Task<Result<TResult>> HandleAsync(TCommand command, CancellationToken ct);
}

Use ICommand<Unit> (and ICommandHandler<TCommand, Unit>) when the operation returns no value — only success or failure.

Differences from IQueryHandler

IQueryHandlerICommandHandler
Side effectsNoneExpected (DB writes, events, emails…)
RetryableYesDepends on idempotency design
CacheableYesNo
Typical result typeDTO / domain objectCreated entity or Unit
Typical pipelineLogging, CachingLogging, Validation, Transaction

Example — create with Unit of Work

csharp
public record CreateProductCommand(
    string Name,
    decimal Price,
    string Sku) : ICommand<Product>;

public class CreateProductHandler(
    IProductRepository products,
    IUnitOfWork uow) : ICommandHandler<CreateProductCommand, Product>
{
    public async Task<Result<Product>> HandleAsync(CreateProductCommand cmd, CancellationToken ct)
    {
        // Check for duplicate SKU
        var existing = await products.FindBySkuAsync(cmd.Sku, ct);
        if (existing.IsSome)
            return Result.Failure<Product>(Error.Conflict("Product.DuplicateSku", cmd.Sku));

        var product = Product.Create(cmd.Name, cmd.Price, cmd.Sku);
        await products.AddAsync(product, ct);
        await uow.SaveChangesAsync(ct);

        return Result.Success(product);
    }
}

Example — command that returns Unit

csharp
public record DeleteProductCommand(Guid ProductId) : ICommand<Unit>;

public class DeleteProductHandler(
    IProductRepository products,
    IUnitOfWork uow) : ICommandHandler<DeleteProductCommand, Unit>
{
    public async Task<Result<Unit>> HandleAsync(DeleteProductCommand cmd, CancellationToken ct)
    {
        var product = await products.FindByIdAsync(cmd.ProductId, ct);

        return await product
            .ToResult(Error.NotFound("Product", cmd.ProductId))
            .BindAsync(async p =>
            {
                products.Remove(p);
                await uow.SaveChangesAsync(ct);
                return Result.Success(Unit.Value);
            });
    }
}

Composing with Bind

Bind chains fallible steps without nested if-statements. Each step only runs if the previous succeeded.

csharp
public class TransferFundsHandler(
    IAccountRepository accounts,
    ITransactionRepository transactions,
    IUnitOfWork uow) : ICommandHandler<TransferFundsCommand, Transaction>
{
    public Task<Result<Transaction>> HandleAsync(TransferFundsCommand cmd, CancellationToken ct) =>
        accounts.FindByIdAsync(cmd.SourceAccountId, ct)
            .BindAsync(source  => ValidateFunds(source, cmd.Amount))
            .BindAsync(source  => accounts.FindByIdAsync(cmd.DestinationAccountId, ct)
                                    .MapAsync(dest => (source, dest)))
            .BindAsync(pair    => ExecuteTransferAsync(pair.source, pair.dest, cmd.Amount, ct))
            .TapAsync(_        => uow.SaveChangesAsync(ct));

    private static Result<Account> ValidateFunds(Account account, decimal amount) =>
        account.Balance >= amount
            ? Result.Success(account)
            : Result.Failure<Account>(Error.BusinessRule("Account.InsufficientFunds"));

    private async Task<Result<Transaction>> ExecuteTransferAsync(
        Account source, Account destination, decimal amount, CancellationToken ct)
    {
        source.Debit(amount);
        destination.Credit(amount);
        var tx = Transaction.Create(source.Id, destination.Id, amount);
        await transactions.AddAsync(tx, ct);
        return Result.Success(tx);
    }
}

Dispatching via IMediator

csharp
[HttpPost]
public async Task<IActionResult> CreateProduct(
    CreateProductRequest req,
    IMediator mediator,
    CancellationToken ct)
{
    var result = await mediator.CommandAsync(
        new CreateProductCommand(req.Name, req.Price, req.Sku), ct);

    return result.Match(
        onSuccess: product => CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product),
        onFailure: error   => error.ToActionResult());
}

Released under the MIT License.