Marai Bridgetor

NuGet License License

A lightweight, extensible CQRS and Mediator pattern implementation for .NET applications. Decouple your application components with clean command/query separation and eliminate direct dependencies between layers.

Table of Contents

What is Marai.Bridgetor?

Marai.Bridgetor implements the Mediator and CQRS (Command Query Responsibility Segregation) patterns to:

  • Decouple components – Remove direct dependencies between your controllers, services, and data access layers
  • Separate concerns – Commands change state, Queries retrieve data
  • Simplify architecture – Single entry point (IDispatcher) for all requests
  • Improve testability – Each handler is an isolated, testable unit
  • Enable clean code – Follow SOLID principles with minimal boilerplate
The Mediator Pattern

Instead of controllers directly calling services:

C#
// Traditional approach - tight coupling
public class OrderController
{
    private readonly IOrderService _orderService;
    private readonly IEmailService _emailService;
    private readonly IInventoryService _inventoryService;
    
    public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
    {
        var order = await _orderService.CreateAsync(request);
        await _emailService.SendConfirmationAsync(order);
        await _inventoryService.UpdateStockAsync(order.Items);
        return Ok(order);
    }
}

Bridgetor provides a single dispatcher:

C#
// Bridgetor approach - loose coupling
public class OrderController
{
    private readonly IDispatcher _dispatcher;
    
    public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
    {
        var command = new CreateOrderCommand(request);
        var order = await _dispatcher.Send(command);
        return Ok(order);
    }
}

Features

Core Features
  • CQRS Pattern – Separate commands (write) from queries (read)
  • Mediator Pattern – Single dispatcher for all requests
  • Automatic Handler Registration – Assembly scanning for handlers
  • Type-Safe – Generic interfaces ensure compile-time safety
  • Async/Await – First-class async support with cancellation tokens
  • Lightweight – Minimal dependencies, maximum performance
  • Extensible – Easy to add behaviors, validation, logging
What’s Included
  • ICommand / ICommand<TResult> – Command markers
  • ICommandHandler<TCommand> / ICommandHandler<TCommand, TResult> – Command handlers
  • IQuery<TResult> – Query marker
  • IQueryHandler<TQuery, TResult> – Query handlers
  • IDispatcher – Central dispatcher
  • Unit – Represents void return type
  • Automatic DI registration

Installation

Using .NET CLI
Bash
dotnet add package Marai.Bridgetor
Using Package Manager Console
Bash
Install-Package Marai.Bridgetor
Using PackageReference

Add to your .csproj file:

XML
<PackageReference Include="Marai.Bridgetor" Version="1.0.0-Alpha.1" />
Requirements
  • .NET 10.0 or later
  • Microsoft.Extensions.DependencyInjection

Dependency Injection Setup

ASP.NET Core Web API
C#
// Program.cs
using Marai.Bridgetor;

var builder = WebApplication.CreateBuilder(args);

// Register Marai.Bridgetor with assemblies containing handlers
builder.Services.AddMaraiBridgetor(
    typeof(Program).Assembly  // Current assembly
    // Add more assemblies if handlers are in different projects
);

builder.Services.AddControllers();

var app = builder.Build();
app.MapControllers();
app.Run();
Multi-Project Solution
C#
// Program.cs
using Marai.Bridgetor;
using MyApp.Application;  // Assembly with handlers
using MyApp.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

// Register handlers from multiple assemblies
builder.Services.AddMaraiBridgetor(
    typeof(Program).Assembly,              // API layer
    typeof(CreateUserCommand).Assembly,    // Application layer
    typeof(OrderRepository).Assembly       // Infrastructure layer
);

var app = builder.Build();
app.Run();
Console Application
C#
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Marai.Bridgetor;

var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((context, services) =>
    {
        // Register Bridgetor
        services.AddMaraiBridgetor(typeof(Program).Assembly);
        
        // Register other services
        services.AddDbContext<ApplicationDbContext>();
    })
    .Build();

await host.RunAsync();
What Gets Registered

The AddMaraiBridgetor() method:

  1. Registers IDispatcher as ScopedDispatcher implementation
  2. Scans assemblies for all command and query handlers
  3. Auto-registers handlers as Scoped services:
    • ICommandHandler<TCommand, TResult> implementations
    • ICommandHandler<TCommand> implementations
    • IQueryHandler<TQuery, TResult> implementations

Core Concepts

ICommand

Marker interface for commands that change state (create, update, delete).

Definition

C#
// Command with return value
public interface ICommand<out TResult> { }

// Command without return value (returns Unit)
public interface ICommand : ICommand<Unit> { }

When to Use

  • Creating new entities
  • Updating existing data
  • Deleting records
  • Any operation that modifies state

Examples

Command with Return Value:

C#
// Returns the created user's ID
public record CreateUserCommand(string Email, string Name, string Password) 
    : ICommand<int>;

// Returns the updated order
public record UpdateOrderCommand(int OrderId, OrderStatus NewStatus) 
    : ICommand<Order>;

// Returns success indicator
public record ProcessPaymentCommand(int OrderId, decimal Amount) 
    : ICommand<bool>;

Command without Return Value:

C#
// No return value needed
public record SendEmailCommand(string To, string Subject, string Body) 
    : ICommand;

// Fire-and-forget notification
public record LogAuditCommand(string UserId, string Action) 
    : ICommand;

// Delete operation
public record DeleteUserCommand(int UserId) 
    : ICommand;

Best Practices

C#
// GOOD: Use records for immutability
public record CreateProductCommand(string Name, decimal Price) : ICommand<int>;

// GOOD: Descriptive names with "Command" suffix
public record PlaceOrderCommand(...) : ICommand<Order>;

// GOOD: Include all necessary data
public record UpdateUserProfileCommand(
    int UserId, 
    string FirstName, 
    string LastName, 
    string Email
) : ICommand;

// BAD: Vague names
public record Update(...) : ICommand;  // Update what?

// BAD: Including dependencies
public record CreateUserCommand(string Email, IUserRepository repo) : ICommand;  // No!

ICommandHandler

Handles command execution logic.

Definition

C#
// Handler for commands with return value
public interface ICommandHandler<in TCommand, TResult>
    where TCommand : ICommand<TResult>
{
    Task<TResult> Handle(TCommand command, CancellationToken ct);
}

// Handler for commands without return value
public interface ICommandHandler<in TCommand>
    where TCommand : ICommand
{
    Task Handle(TCommand command, CancellationToken ct);
}

Examples

Creating an Entity:

C#
public record CreateUserCommand(string Email, string Name, string Password) 
    : ICommand<int>;

public class CreateUserCommandHandler 
    : ICommandHandler<CreateUserCommand, int>
{
    private readonly ApplicationDbContext _dbContext;
    private readonly IPasswordHasher _passwordHasher;
    private readonly ILogger<CreateUserCommandHandler> _logger;

    public CreateUserCommandHandler(
        ApplicationDbContext dbContext,
        IPasswordHasher passwordHasher,
        ILogger<CreateUserCommandHandler> logger)
    {
        _dbContext = dbContext;
        _passwordHasher = passwordHasher;
        _logger = logger;
    }

    public async Task<int> Handle(CreateUserCommand command, CancellationToken ct)
    {
        // Validate
        if (await _dbContext.Users.AnyAsync(u => u.Email == command.Email, ct))
        {
            throw new InvalidOperationException("Email already exists");
        }

        // Create user
        var user = new User
        {
            Email = command.Email,
            Name = command.Name,
            PasswordHash = _passwordHasher.Hash(command.Password),
            CreatedAt = DateTime.UtcNow
        };

        _dbContext.Users.Add(user);
        await _dbContext.SaveChangesAsync(ct);

        _logger.LogInformation("User created: {UserId} - {Email}", user.Id, user.Email);

        return user.Id;
    }
}

Updating an Entity:

C#
public record UpdateOrderStatusCommand(int OrderId, OrderStatus NewStatus) 
    : ICommand<Order>;

public class UpdateOrderStatusCommandHandler 
    : ICommandHandler<UpdateOrderStatusCommand, Order>
{
    private readonly ApplicationDbContext _dbContext;
    private readonly IEventPublisher _eventPublisher;

    public async Task<Order> Handle(UpdateOrderStatusCommand command, CancellationToken ct)
    {
        var order = await _dbContext.Orders
            .FirstOrDefaultAsync(o => o.Id == command.OrderId, ct);

        if (order == null)
        {
            throw new NotFoundException($"Order {command.OrderId} not found");
        }

        // Update status
        var oldStatus = order.Status;
        order.Status = command.NewStatus;
        order.UpdatedAt = DateTime.UtcNow;

        await _dbContext.SaveChangesAsync(ct);

        // Publish event
        await _eventPublisher.PublishAsync(
            new OrderStatusChangedEvent(order.Id, oldStatus, command.NewStatus),
            ct
        );

        return order;
    }
}

Command without Return Value:

C#
public record SendWelcomeEmailCommand(string Email, string Name) : ICommand;

public class SendWelcomeEmailCommandHandler 
    : ICommandHandler<SendWelcomeEmailCommand>
{
    private readonly IEmailService _emailService;
    private readonly ILogger<SendWelcomeEmailCommandHandler> _logger;

    public async Task Handle(SendWelcomeEmailCommand command, CancellationToken ct)
    {
        try
        {
            await _emailService.SendAsync(
                to: command.Email,
                subject: "Welcome!",
                body: $"Hello {command.Name}, welcome to our platform!",
                ct
            );

            _logger.LogInformation("Welcome email sent to {Email}", command.Email);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to send welcome email to {Email}", command.Email);
            // Don't rethrow - email failure shouldn't block user creation
        }
    }
}

IQuery

Marker interface for queries that retrieve data without side effects.

Definition

C#
public interface IQuery<out TResult> { }

When to Use

  • Fetching single entities
  • Retrieving lists/collections
  • Read-only operations
  • Projections and reports
  • Any operation that doesn’t modify state

Examples

C#
// Get single entity by ID
public record GetUserByIdQuery(int UserId) : IQuery<User>;

// Get list of entities
public record GetActiveOrdersQuery(int CustomerId) : IQuery<List<Order>>;

// Complex query with filtering
public record SearchProductsQuery(
    string SearchTerm,
    decimal? MinPrice,
    decimal? MaxPrice,
    int Page,
    int PageSize
) : IQuery<PagedResult<Product>>;

// Projection/DTO query
public record GetUserProfileQuery(int UserId) : IQuery<UserProfileDto>;

// Aggregation query
public record GetOrderStatisticsQuery(DateTime StartDate, DateTime EndDate) 
    : IQuery<OrderStatistics>;

Best Practices

C#
// GOOD: Use "Get" or "Search" prefix
public record GetOrderByIdQuery(int OrderId) : IQuery<Order>;
public record SearchUsersQuery(string SearchTerm) : IQuery<List<UserDto>>;

// GOOD: Include pagination parameters
public record GetProductsQuery(int Page, int PageSize) : IQuery<PagedResult<Product>>;

// GOOD: Return DTOs, not entities (for queries)
public record GetUserDetailsQuery(int UserId) : IQuery<UserDetailsDto>;

// BAD: Modifying state in a query
public record GetAndIncrementViewCountQuery(int ProductId) : IQuery<Product>;  // No!

// BAD: Generic names
public record Query(int id) : IQuery<object>;  // Be specific!

IQueryHandler

Handles query execution logic (read-only operations).

Definition

C#
public interface IQueryHandler<in TQuery, TResult>
    where TQuery : IQuery<TResult>
{
    Task<TResult> Handle(TQuery query, CancellationToken ct);
}

Examples

Getting Single Entity:

C#
public record GetUserByIdQuery(int UserId) : IQuery<User>;

public class GetUserByIdQueryHandler 
    : IQueryHandler<GetUserByIdQuery, User>
{
    private readonly ApplicationDbContext _dbContext;

    public GetUserByIdQueryHandler(ApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<User> Handle(GetUserByIdQuery query, CancellationToken ct)
    {
        var user = await _dbContext.Users
            .Include(u => u.Profile)
            .FirstOrDefaultAsync(u => u.Id == query.UserId, ct);

        if (user == null)
        {
            throw new NotFoundException($"User {query.UserId} not found");
        }

        return user;
    }
}

Getting List with Filtering:

C#
public record GetActiveOrdersQuery(int CustomerId, DateTime? Since) 
    : IQuery<List<OrderDto>>;

public class GetActiveOrdersQueryHandler 
    : IQueryHandler<GetActiveOrdersQuery, List<OrderDto>>
{
    private readonly ApplicationDbContext _dbContext;

    public async Task<List<OrderDto>> Handle(GetActiveOrdersQuery query, CancellationToken ct)
    {
        var ordersQuery = _dbContext.Orders
            .Where(o => o.CustomerId == query.CustomerId)
            .Where(o => o.Status != OrderStatus.Completed && 
                       o.Status != OrderStatus.Cancelled);

        if (query.Since.HasValue)
        {
            ordersQuery = ordersQuery.Where(o => o.CreatedAt >= query.Since.Value);
        }

        var orders = await ordersQuery
            .OrderByDescending(o => o.CreatedAt)
            .Select(o => new OrderDto
            {
                Id = o.Id,
                OrderNumber = o.OrderNumber,
                TotalAmount = o.TotalAmount,
                Status = o.Status,
                CreatedAt = o.CreatedAt
            })
            .ToListAsync(ct);

        return orders;
    }
}

Paginated Query:

C#
public record SearchProductsQuery(
    string SearchTerm,
    int Page,
    int PageSize
) : IQuery<PagedResult<ProductDto>>;

public class SearchProductsQueryHandler 
    : IQueryHandler<SearchProductsQuery, PagedResult<ProductDto>>
{
    private readonly ApplicationDbContext _dbContext;

    public async Task<PagedResult<ProductDto>> Handle(
        SearchProductsQuery query, 
        CancellationToken ct)
    {
        var searchTerm = query.SearchTerm?.ToLower() ?? "";

        var productsQuery = _dbContext.Products
            .Where(p => p.IsActive)
            .Where(p => p.Name.ToLower().Contains(searchTerm) || 
                       p.Description.ToLower().Contains(searchTerm));

        var totalCount = await productsQuery.CountAsync(ct);

        var products = await productsQuery
            .OrderBy(p => p.Name)
            .Skip((query.Page - 1) * query.PageSize)
            .Take(query.PageSize)
            .Select(p => new ProductDto
            {
                Id = p.Id,
                Name = p.Name,
                Price = p.Price,
                ImageUrl = p.ImageUrl
            })
            .ToListAsync(ct);

        return new PagedResult<ProductDto>
        {
            Items = products,
            TotalCount = totalCount,
            Page = query.Page,
            PageSize = query.PageSize
        };
    }
}

IDispatcher

The central mediator that routes commands and queries to their handlers.

Definition

C#
public interface IDispatcher
{
    // Send command with return value
    Task<TResult> Send<TResult>(ICommand<TResult> command, CancellationToken ct = default);
    
    // Send command without return value
    Task Send(ICommand command, CancellationToken ct = default);
    
    // Send query
    Task<TResult> Send<TResult>(IQuery<TResult> query, CancellationToken ct = default);
}

Usage in Controllers

Basic Usage:

C#
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IDispatcher _dispatcher;

    public UsersController(IDispatcher dispatcher)
    {
        _dispatcher = dispatcher;
    }

    [HttpPost]
    public async Task<IActionResult> CreateUser(CreateUserRequest request)
    {
        var command = new CreateUserCommand(
            request.Email,
            request.Name,
            request.Password
        );

        var userId = await _dispatcher.Send(command);

        return CreatedAtAction(
            nameof(GetUser),
            new { id = userId },
            new { id = userId }
        );
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetUser(int id)
    {
        var query = new GetUserByIdQuery(id);
        var user = await _dispatcher.Send(query);
        return Ok(user);
    }

    [HttpPut("{id}")]
    public async Task<IActionResult> UpdateUser(int id, UpdateUserRequest request)
    {
        var command = new UpdateUserCommand(
            id,
            request.Name,
            request.Email
        );

        var user = await _dispatcher.Send(command);
        return Ok(user);
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteUser(int id)
    {
        var command = new DeleteUserCommand(id);
        await _dispatcher.Send(command);  // No return value
        return NoContent();
    }
}

With Cancellation Token:

C#
[HttpGet("search")]
public async Task<IActionResult> SearchProducts(
    [FromQuery] string searchTerm,
    [FromQuery] int page = 1,
    [FromQuery] int pageSize = 20,
    CancellationToken ct = default)
{
    var query = new SearchProductsQuery(searchTerm, page, pageSize);
    
    // Pass cancellation token from HTTP request
    var result = await _dispatcher.Send(query, ct);
    
    return Ok(result);
}

Complex Workflow:

C#
[HttpPost("orders")]
public async Task<IActionResult> PlaceOrder(PlaceOrderRequest request, CancellationToken ct)
{
    // Create the order
    var createCommand = new CreateOrderCommand(
        request.CustomerId,
        request.Items
    );
    var order = await _dispatcher.Send(createCommand, ct);

    // Process payment
    var paymentCommand = new ProcessPaymentCommand(
        order.Id,
        request.PaymentDetails
    );
    var paymentSuccess = await _dispatcher.Send(paymentCommand, ct);

    if (!paymentSuccess)
    {
        // Cancel order if payment fails
        var cancelCommand = new CancelOrderCommand(order.Id);
        await _dispatcher.Send(cancelCommand, ct);
        return BadRequest(new { error = "Payment failed" });
    }

    // Send confirmation email (fire-and-forget)
    var emailCommand = new SendOrderConfirmationEmailCommand(
        order.Id,
        request.CustomerId
    );
    await _dispatcher.Send(emailCommand, ct);

    return Ok(order);
}

Usage in Services

C#
public class OrderService
{
    private readonly IDispatcher _dispatcher;

    public OrderService(IDispatcher dispatcher)
    {
        _dispatcher = dispatcher;
    }

    public async Task<Order> CreateOrderAsync(CreateOrderDto dto)
    {
        // Validate inventory
        var inventoryQuery = new CheckInventoryQuery(dto.Items);
        var isAvailable = await _dispatcher.Send(inventoryQuery);

        if (!isAvailable)
        {
            throw new InvalidOperationException("Insufficient inventory");
        }

        // Create order
        var command = new CreateOrderCommand(dto.CustomerId, dto.Items);
        return await _dispatcher.Send(command);
    }
}

Unit

Represents a void/no return value for commands.

Definition

C#
public readonly struct Unit
{
    public static readonly Unit Value = new();
}

When to Use

Use Unit when a command doesn’t need to return a value. However, you can use the parameterless ICommand interface instead.

Examples

Option 1: Using ICommand (Recommended):

C#
// Command definition
public record SendEmailCommand(string To, string Subject, string Body) : ICommand;

// Handler
public class SendEmailCommandHandler : ICommandHandler<SendEmailCommand>
{
    public async Task Handle(SendEmailCommand command, CancellationToken ct)
    {
        // Send email logic
        await _emailService.SendAsync(command.To, command.Subject, command.Body);
        // No return needed
    }
}

// Usage
await _dispatcher.Send(new SendEmailCommand(...));  // Returns Task

Option 2: Using ICommand<Unit>:

C#
// Command definition
public record SendEmailCommand(string To, string Subject, string Body) : ICommand<Unit>;

// Handler
public class SendEmailCommandHandler : ICommandHandler<SendEmailCommand, Unit>
{
    public async Task<Unit> Handle(SendEmailCommand command, CancellationToken ct)
    {
        // Send email logic
        await _emailService.SendAsync(command.To, command.Subject, command.Body);
        return Unit.Value;  // Return Unit.Value
    }
}

// Usage
var result = await _dispatcher.Send(new SendEmailCommand(...));  // Returns Task<Unit>
// result is Unit.Value

Recommendation: Use the parameterless ICommand interface for cleaner code:

C#
// GOOD: Cleaner, no need to return Unit.Value
public record LogAuditCommand(string Action) : ICommand;

public class LogAuditCommandHandler : ICommandHandler<LogAuditCommand>
{
    public async Task Handle(LogAuditCommand command, CancellationToken ct)
    {
        await _auditLog.LogAsync(command.Action);
    }
}

// OK but more verbose
public record LogAuditCommand(string Action) : ICommand<Unit>;

public class LogAuditCommandHandler : ICommandHandler<LogAuditCommand, Unit>
{
    public async Task<Unit> Handle(LogAuditCommand command, CancellationToken ct)
    {
        await _auditLog.LogAsync(command.Action);
        return Unit.Value;  // Extra line
    }
}

Complete Examples

Example 1: User Management CRUD

C#
// ===== Commands =====

// Create
public record CreateUserCommand(string Email, string Name, string Password) 
    : ICommand<int>;

public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
    private readonly ApplicationDbContext _db;
    private readonly IPasswordHasher _hasher;

    public async Task<int> Handle(CreateUserCommand cmd, CancellationToken ct)
    {
        var user = new User
        {
            Email = cmd.Email,
            Name = cmd.Name,
            PasswordHash = _hasher.Hash(cmd.Password)
        };

        _db.Users.Add(user);
        await _db.SaveChangesAsync(ct);
        return user.Id;
    }
}

// Update
public record UpdateUserCommand(int UserId, string Name, string Email) 
    : ICommand<User>;

public class UpdateUserCommandHandler : ICommandHandler<UpdateUserCommand, User>
{
    private readonly ApplicationDbContext _db;

    public async Task<User> Handle(UpdateUserCommand cmd, CancellationToken ct)
    {
        var user = await _db.Users.FindAsync(new[] { (object)cmd.UserId }, ct);
        
        if (user == null)
            throw new NotFoundException($"User {cmd.UserId} not found");

        user.Name = cmd.Name;
        user.Email = cmd.Email;
        user.UpdatedAt = DateTime.UtcNow;

        await _db.SaveChangesAsync(ct);
        return user;
    }
}

// Delete
public record DeleteUserCommand(int UserId) : ICommand;

public class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
{
    private readonly ApplicationDbContext _db;

    public async Task Handle(DeleteUserCommand cmd, CancellationToken ct)
    {
        var user = await _db.Users.FindAsync(new[] { (object)cmd.UserId }, ct);
        
        if (user != null)
        {
            _db.Users.Remove(user);
            await _db.SaveChangesAsync(ct);
        }
    }
}

// ===== Queries =====

// Get by ID
public record GetUserByIdQuery(int UserId) : IQuery<UserDto>;

public class GetUserByIdQueryHandler : IQueryHandler<GetUserByIdQuery, UserDto>
{
    private readonly ApplicationDbContext _db;

    public async Task<UserDto> Handle(GetUserByIdQuery query, CancellationToken ct)
    {
        var user = await _db.Users
            .Where(u => u.Id == query.UserId)
            .Select(u => new UserDto
            {
                Id = u.Id,
                Email = u.Email,
                Name = u.Name
            })
            .FirstOrDefaultAsync(ct);

        if (user == null)
            throw new NotFoundException($"User {query.UserId} not found");

        return user;
    }
}

// List all
public record GetAllUsersQuery : IQuery<List<UserDto>>;

public class GetAllUsersQueryHandler : IQueryHandler<GetAllUsersQuery, List<UserDto>>
{
    private readonly ApplicationDbContext _db;

    public async Task<List<UserDto>> Handle(GetAllUsersQuery query, CancellationToken ct)
    {
        return await _db.Users
            .OrderBy(u => u.Name)
            .Select(u => new UserDto
            {
                Id = u.Id,
                Email = u.Email,
                Name = u.Name
            })
            .ToListAsync(ct);
    }
}

// ===== Controller =====

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IDispatcher _dispatcher;

    public UsersController(IDispatcher dispatcher)
    {
        _dispatcher = dispatcher;
    }

    [HttpPost]
    public async Task<IActionResult> Create(CreateUserRequest request)
    {
        var command = new CreateUserCommand(request.Email, request.Name, request.Password);
        var userId = await _dispatcher.Send(command);
        return CreatedAtAction(nameof(GetById), new { id = userId }, new { id = userId });
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetById(int id)
    {
        var query = new GetUserByIdQuery(id);
        var user = await _dispatcher.Send(query);
        return Ok(user);
    }

    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        var query = new GetAllUsersQuery();
        var users = await _dispatcher.Send(query);
        return Ok(users);
    }

    [HttpPut("{id}")]
    public async Task<IActionResult> Update(int id, UpdateUserRequest request)
    {
        var command = new UpdateUserCommand(id, request.Name, request.Email);
        var user = await _dispatcher.Send(command);
        return Ok(user);
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        var command = new DeleteUserCommand(id);
        await _dispatcher.Send(command);
        return NoContent();
    }
}

Example 2: E-Commerce Order Processing

C#
// ===== Commands =====

public record PlaceOrderCommand(
    int CustomerId,
    List<OrderItemDto> Items,
    PaymentDetails Payment
) : ICommand<OrderResult>;

public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand, OrderResult>
{
    private readonly ApplicationDbContext _db;
    private readonly IDispatcher _dispatcher;
    private readonly ILogger<PlaceOrderCommandHandler> _logger;

    public async Task<OrderResult> Handle(PlaceOrderCommand cmd, CancellationToken ct)
    {
        // 1. Validate inventory
        var inventoryQuery = new CheckInventoryQuery(cmd.Items);
        var hasStock = await _dispatcher.Send(inventoryQuery, ct);

        if (!hasStock)
        {
            return OrderResult.Failed("Insufficient inventory");
        }

        // 2. Create order
        var order = new Order
        {
            CustomerId = cmd.CustomerId,
            OrderNumber = GenerateOrderNumber(),
            Status = OrderStatus.Pending,
            CreatedAt = DateTime.UtcNow
        };

        foreach (var item in cmd.Items)
        {
            order.Items.Add(new OrderItem
            {
                ProductId = item.ProductId,
                Quantity = item.Quantity,
                UnitPrice = item.UnitPrice
            });
        }

        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);

        // 3. Process payment
        var paymentCommand = new ProcessPaymentCommand(order.Id, cmd.Payment);
        var paymentSuccess = await _dispatcher.Send(paymentCommand, ct);

        if (!paymentSuccess)
        {
            order.Status = OrderStatus.PaymentFailed;
            await _db.SaveChangesAsync(ct);
            return OrderResult.Failed("Payment processing failed");
        }

        // 4. Update inventory
        var updateInventoryCommand = new UpdateInventoryCommand(order.Items);
        await _dispatcher.Send(updateInventoryCommand, ct);

        // 5. Send confirmation email
        var emailCommand = new SendOrderConfirmationCommand(order.Id);
        await _dispatcher.Send(emailCommand, ct);

        order.Status = OrderStatus.Confirmed;
        await _db.SaveChangesAsync(ct);

        _logger.LogInformation("Order placed successfully: {OrderId}", order.Id);

        return OrderResult.Success(order.Id, order.OrderNumber);
    }

    private string GenerateOrderNumber() => $"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid():N}";
}
C#
// ===== Queries =====

public record GetOrderDetailsQuery(int OrderId) : IQuery<OrderDetailsDto>;

public class GetOrderDetailsQueryHandler 
    : IQueryHandler<GetOrderDetailsQuery, OrderDetailsDto>
{
    private readonly ApplicationDbContext _db;

    public async Task<OrderDetailsDto> Handle(GetOrderDetailsQuery query, CancellationToken ct)
    {
        var order = await _db.Orders
            .Include(o => o.Items)
                .ThenInclude(i => i.Product)
            .Include(o => o.Customer)
            .FirstOrDefaultAsync(o => o.Id == query.OrderId, ct);

        if (order == null)
            throw new NotFoundException($"Order {query.OrderId} not found");

        return new OrderDetailsDto
        {
            Id = order.Id,
            OrderNumber = order.OrderNumber,
            CustomerName = order.Customer.Name,
            Status = order.Status,
            TotalAmount = order.Items.Sum(i => i.Quantity * i.UnitPrice),
            Items = order.Items.Select(i => new OrderItemDetailsDto
            {
                ProductName = i.Product.Name,
                Quantity = i.Quantity,
                UnitPrice = i.UnitPrice,
                Subtotal = i.Quantity * i.UnitPrice
            }).ToList(),
            CreatedAt = order.CreatedAt
        };
    }
}
C#
// ===== Controller =====

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IDispatcher _dispatcher;

    [HttpPost]
    public async Task<IActionResult> PlaceOrder(PlaceOrderRequest request)
    {
        var command = new PlaceOrderCommand(
            request.CustomerId,
            request.Items,
            request.Payment
        );

        var result = await _dispatcher.Send(command);

        if (!result.Success)
        {
            return BadRequest(new { error = result.ErrorMessage });
        }

        return CreatedAtAction(
            nameof(GetOrderDetails),
            new { id = result.OrderId },
            result
        );
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetOrderDetails(int id)
    {
        var query = new GetOrderDetailsQuery(id);
        var order = await _dispatcher.Send(query);
        return Ok(order);
    }
}

Example 3: With Validation and Logging

C#
// Command with validation
public record CreateProductCommand(string Name, string Description, decimal Price) 
    : ICommand<int>;

public class CreateProductCommandHandler : ICommandHandler<CreateProductCommand, int>
{
    private readonly ApplicationDbContext _db;
    private readonly ILogger<CreateProductCommandHandler> _logger;

    public async Task<int> Handle(CreateProductCommand cmd, CancellationToken ct)
    {
        // Validation
        if (string.IsNullOrWhiteSpace(cmd.Name))
        {
            _logger.LogWarning("Create product failed: Name is required");
            throw new ValidationException("Product name is required");
        }

        if (cmd.Price <= 0)
        {
            _logger.LogWarning("Create product failed: Invalid price {Price}", cmd.Price);
            throw new ValidationException("Price must be greater than zero");
        }

        // Check for duplicates
        var exists = await _db.Products
            .AnyAsync(p => p.Name == cmd.Name, ct);

        if (exists)
        {
            _logger.LogWarning("Create product failed: Duplicate name {Name}", cmd.Name);
            throw new DuplicateException($"Product '{cmd.Name}' already exists");
        }

        // Create product
        var product = new Product
        {
            Name = cmd.Name,
            Description = cmd.Description,
            Price = cmd.Price,
            IsActive = true,
            CreatedAt = DateTime.UtcNow
        };

        _db.Products.Add(product);
        await _db.SaveChangesAsync(ct);

        _logger.LogInformation(
            "Product created: {ProductId} - {Name} - ${Price}",
            product.Id,
            product.Name,
            product.Price
        );

        return product.Id;
    }
}

Best Practices

DO

1. Use Records for Commands and Queries

C#
// GOOD: Records are immutable and concise
public record CreateUserCommand(string Email, string Name) : ICommand<int>;

// BAD: Mutable classes
public class CreateUserCommand : ICommand<int>
{
    public string Email { get; set; }  // Mutable
    public string Name { get; set; }
}

2. Keep Handlers Focused (Single Responsibility)

C#
// GOOD: Handler does one thing
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
    public async Task<int> Handle(CreateUserCommand cmd, CancellationToken ct)
    {
        // Only create user
        var user = new User { Email = cmd.Email, Name = cmd.Name };
        _db.Users.Add(user);
        await _db.SaveChangesAsync(ct);
        return user.Id;
    }
}

// BAD: Handler does too much
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
    public async Task<int> Handle(CreateUserCommand cmd, CancellationToken ct)
    {
        // Creating user
        var user = new User { ... };
        _db.Users.Add(user);
        await _db.SaveChangesAsync(ct);

        // Sending email
        await _emailService.SendWelcomeEmail(user);

        // Updating statistics
        await _analyticsService.TrackUserCreated(user);

        // Creating default settings
        await _settingsService.CreateDefaultSettings(user);

        return user.Id;
    }
}

// BETTER: Coordinate with multiple commands
var userId = await _dispatcher.Send(new CreateUserCommand(...));
await _dispatcher.Send(new SendWelcomeEmailCommand(userId));
await _dispatcher.Send(new TrackUserCreatedCommand(userId));
await _dispatcher.Send(new CreateDefaultSettingsCommand(userId));

3. Use Descriptive Names

C#
// GOOD: Clear and descriptive
public record CreateUserCommand(...) : ICommand<int>;
public record UpdateUserProfileCommand(...) : ICommand;
public record GetOrderDetailsQuery(int OrderId) : IQuery<OrderDetailsDto>;

// BAD: Vague names
public record UserCommand(...) : ICommand<int>;  // What does it do?
public record UpdateCommand(...) : ICommand;     // Update what?
public record Query(int id) : IQuery<object>;    // Too generic

4. Return DTOs from Queries, Not Entities

C#
// GOOD: Return DTO
public record GetUserQuery(int UserId) : IQuery<UserDto>;

public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
{
    public async Task<UserDto> Handle(GetUserQuery query, CancellationToken ct)
    {
        return await _db.Users
            .Where(u => u.Id == query.UserId)
            .Select(u => new UserDto
            {
                Id = u.Id,
                Name = u.Name,
                Email = u.Email
                // Only necessary properties
            })
            .FirstOrDefaultAsync(ct);
    }
}

// BAD: Return entity
public record GetUserQuery(int UserId) : IQuery<User>;  // Exposes entire entity

5. Use CancellationToken

C#
// GOOD: Accept and use cancellation token
public async Task<User> Handle(CreateUserCommand cmd, CancellationToken ct)
{
    var user = new User { ... };
    _db.Users.Add(user);
    await _db.SaveChangesAsync(ct);  // Pass ct
    return user;
}

// GOOD: Pass ct from controller
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id, CancellationToken ct)
{
    var query = new GetUserByIdQuery(id);
    var user = await _dispatcher.Send(query, ct);  // Pass ct
    return Ok(user);
}

6. One Handler Per Request Type

C#
// GOOD: One handler per command
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int> { }

// BAD: Multiple handlers for same command
public class CreateUserCommandHandler1 : ICommandHandler<CreateUserCommand, int> { }
public class CreateUserCommandHandler2 : ICommandHandler<CreateUserCommand, int> { }
// Will throw MultipleHandlersFoundException
DON’T

1. Don’t Put Business Logic in Controllers

C#
// BAD: Business logic in controller
[HttpPost]
public async Task<IActionResult> CreateUser(CreateUserRequest request)
{
    if (await _db.Users.AnyAsync(u => u.Email == request.Email))
    {
        return Conflict("Email exists");
    }

    var user = new User { ... };
    _db.Users.Add(user);
    await _db.SaveChangesAsync();

    await _emailService.SendWelcomeEmail(user);

    return Ok(user);
}

// GOOD: Delegate to handler via dispatcher
[HttpPost]
public async Task<IActionResult> CreateUser(CreateUserRequest request)
{
    var command = new CreateUserCommand(request.Email, request.Name);
    var userId = await _dispatcher.Send(command);
    return Ok(new { id = userId });
}

2. Don’t Modify State in Queries

C#
// BAD: Query modifies state
public class GetProductQueryHandler : IQueryHandler<GetProductQuery, ProductDto>
{
    public async Task<ProductDto> Handle(GetProductQuery query, CancellationToken ct)
    {
        var product = await _db.Products.FindAsync(query.ProductId);
        
        // BAD: Incrementing view count in a query
        product.ViewCount++;
        await _db.SaveChangesAsync(ct);
        
        return MapToDto(product);
    }
}

// GOOD: Separate command for state changes
await _dispatcher.Send(new IncrementProductViewCommand(productId));
var product = await _dispatcher.Send(new GetProductQuery(productId));

3. Don’t Use Queries in Commands (Avoid Query Reuse)

C#
// BAD: Reusing query in command handler
public class UpdateUserCommandHandler : ICommandHandler<UpdateUserCommand, User>
{
    private readonly IDispatcher _dispatcher;

    public async Task<User> Handle(UpdateUserCommand cmd, CancellationToken ct)
    {
        // BAD: Using dispatcher inside handler
        var user = await _dispatcher.Send(new GetUserQuery(cmd.UserId), ct);
        user.Name = cmd.Name;
        await _db.SaveChangesAsync(ct);
        return user;
    }
}

// GOOD: Access data directly
public class UpdateUserCommandHandler : ICommandHandler<UpdateUserCommand, User>
{
    private readonly ApplicationDbContext _db;

    public async Task<User> Handle(UpdateUserCommand cmd, CancellationToken ct)
    {
        var user = await _db.Users.FindAsync(new[] { (object)cmd.UserId }, ct);
        user.Name = cmd.Name;
        await _db.SaveChangesAsync(ct);
        return user;
    }
}

Exception Handling

Built-in Exceptions
ExceptionWhen ThrownHow to Handle
HandlerNotFoundExceptionNo handler registered for command/queryEnsure handler is in scanned assembly
MultipleHandlersFoundExceptionMultiple handlers for same requestRemove duplicate handlers
Exception Handling Examples

In Controllers:

C#
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IDispatcher _dispatcher;
    private readonly ILogger<UsersController> _logger;

    [HttpPost]
    public async Task<IActionResult> CreateUser(CreateUserRequest request)
    {
        try
        {
            var command = new CreateUserCommand(request.Email, request.Name);
            var userId = await _dispatcher.Send(command);
            return CreatedAtAction(nameof(GetUser), new { id = userId }, new { id = userId });
        }
        catch (HandlerNotFoundException ex)
        {
            _logger.LogError(ex, "Handler not found");
            return StatusCode(500, "Service configuration error");
        }
        catch (DuplicateException ex)
        {
            return Conflict(new { error = ex.Message });
        }
        catch (ValidationException ex)
        {
            return BadRequest(new { error = ex.Message });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unexpected error creating user");
            return StatusCode(500, "An error occurred");
        }
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetUser(int id)
    {
        try
        {
            var query = new GetUserByIdQuery(id);
            var user = await _dispatcher.Send(query);
            return Ok(user);
        }
        catch (NotFoundException)
        {
            return NotFound();
        }
        catch (HandlerNotFoundException ex)
        {
            _logger.LogError(ex, "Handler not found");
            return StatusCode(500, "Service configuration error");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unexpected error");
            return StatusCode(500, "An error occurred");
        }
    }
}

Global Exception Handler:

C#
public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    {
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "Exception occurred: {Message}", exception.Message);

        var (statusCode, message) = exception switch
        {
            HandlerNotFoundException => (500, "Service configuration error"),
            MultipleHandlersFoundException => (500, "Service configuration error"),
            ValidationException => (400, exception.Message),
            NotFoundException => (404, "Resource not found"),
            DuplicateException => (409, exception.Message),
            UnauthorizedAccessException => (401, "Unauthorized"),
            _ => (500, "An error occurred")
        };

        httpContext.Response.StatusCode = statusCode;
        await httpContext.Response.WriteAsJsonAsync(new { error = message }, cancellationToken);

        return true;
    }
}

// Register in Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
app.UseExceptionHandler(_ => { });

Testing

Unit Testing Handlers

C#
public class CreateUserCommandHandlerTests
{
    private readonly Mock<ApplicationDbContext> _mockDb;
    private readonly Mock<IPasswordHasher> _mockHasher;
    private readonly CreateUserCommandHandler _handler;

    public CreateUserCommandHandlerTests()
    {
        _mockDb = new Mock<ApplicationDbContext>();
        _mockHasher = new Mock<IPasswordHasher>();
        _handler = new CreateUserCommandHandler(_mockDb.Object, _mockHasher.Object);
    }

    [Fact]
    public async Task Handle_ValidCommand_CreatesUser()
    {
        // Arrange
        var command = new CreateUserCommand("test@example.com", "Test User", "password123");
        _mockHasher.Setup(h => h.Hash(It.IsAny<string>()))
            .Returns("hashed_password");

        // Act
        var userId = await _handler.Handle(command, CancellationToken.None);

        // Assert
        Assert.True(userId > 0);
        _mockDb.Verify(db => db.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Handle_DuplicateEmail_ThrowsException()
    {
        // Arrange
        var command = new CreateUserCommand("existing@example.com", "Test", "password");
        _mockDb.Setup(db => db.Users.AnyAsync(It.IsAny<Expression<Func<User, bool>>>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(true);

        // Act & Assert
        await Assert.ThrowsAsync<DuplicateException>(() => 
            _handler.Handle(command, CancellationToken.None));
    }
}

Integration Testing with Dispatcher

C#
public class UserIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly IDispatcher _dispatcher;

    public UserIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
        var scope = factory.Services.CreateScope();
        _dispatcher = scope.ServiceProvider.GetRequiredService<IDispatcher>();
    }

    [Fact]
    public async Task CreateAndRetrieveUser_Success()
    {
        // Create user
        var createCommand = new CreateUserCommand("test@example.com", "Test User", "password");
        var userId = await _dispatcher.Send(createCommand);

        Assert.True(userId > 0);

        // Retrieve user
        var getQuery = new GetUserByIdQuery(userId);
        var user = await _dispatcher.Send(getQuery);

        Assert.NotNull(user);
        Assert.Equal("test@example.com", user.Email);
        Assert.Equal("Test User", user.Name);
    }
}

FAQ

Q: When should I use Commands vs Queries?

Commands – Operations that change state:

  • Creating, updating, deleting entities
  • Processing payments
  • Sending emails
  • Any side effects

Queries – Operations that read data:

  • Fetching entities by ID
  • Listing/searching
  • Reports and analytics
  • Any read-only operation
Q: Can a Command return data?

Yes! Commands can return values using ICommand<TResult>:

C#
// Returns created entity ID
public record CreateOrderCommand(...) : ICommand<int>;

// Returns the updated entity
public record UpdateOrderCommand(...) : ICommand<Order>;

// Returns success indicator
public record ProcessPaymentCommand(...) : ICommand<bool>;
Q: Should I inject IDispatcher into handlers?

Generally no. Handlers should be focused and access dependencies directly:

C#
// BAD: Handler using dispatcher
public class CreateOrderHandler : ICommandHandler<CreateOrderCommand, Order>
{
    private readonly IDispatcher _dispatcher;

    public async Task<Order> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        var user = await _dispatcher.Send(new GetUserQuery(cmd.UserId));
        // ...
    }
}

// GOOD: Handler accessing dependencies directly
public class CreateOrderHandler : ICommandHandler<CreateOrderCommand, Order>
{
    private readonly ApplicationDbContext _db;

    public async Task<Order> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        var user = await _db.Users.FindAsync(cmd.UserId);
        // ...
    }
}

Exception: Orchestrating multiple commands in a complex workflow.

Q: How do I handle validation?

Several approaches:

1. In Handler (Simple validation):

C#
public async Task<int> Handle(CreateProductCommand cmd, CancellationToken ct)
{
    if (string.IsNullOrEmpty(cmd.Name))
        throw new ValidationException("Name is required");

    if (cmd.Price <= 0)
        throw new ValidationException("Price must be positive");

    // Create product...
}

2. FluentValidation (Recommended for complex validation):

C#
// Install FluentValidation.DependencyInjectionExtensions

public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
    public CreateProductCommandValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Price).GreaterThan(0);
    }
}

// Register in Program.cs
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

// Create validation behavior (pipeline)
// Or validate in handler
Q: Can I have multiple handlers for one command?

No. Bridgetor throws MultipleHandlersFoundException if multiple handlers are registered for the same request type. This is by design – each command/query should have exactly one handler.

Q: How do I handle transactions?
C#
public class CreateOrderHandler : ICommandHandler<CreateOrderCommand, Order>
{
    private readonly ApplicationDbContext _db;

    public async Task<Order> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        using var transaction = await _db.Database.BeginTransactionAsync(ct);

        try
        {
            // Create order
            var order = new Order { ... };
            _db.Orders.Add(order);
            await _db.SaveChangesAsync(ct);

            // Update inventory
            foreach (var item in cmd.Items)
            {
                var product = await _db.Products.FindAsync(item.ProductId);
                product.Stock -= item.Quantity;
            }
            await _db.SaveChangesAsync(ct);

            await transaction.CommitAsync(ct);
            return order;
        }
        catch
        {
            await transaction.RollbackAsync(ct);
            throw;
        }
    }
}
Q: How do I implement background processing?
C#
public class OrderProcessingService : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _serviceProvider.CreateScope();
            var dispatcher = scope.ServiceProvider.GetRequiredService<IDispatcher>();

            // Process pending orders
            var command = new ProcessPendingOrdersCommand();
            await dispatcher.Send(command, stoppingToken);

            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }
    }
}

License

This project is licensed under the Marai Proprietary Software License Agreement.

Free for personal and commercial use.

See LICENSE file or visit:

Proprietary Software License Agreement

Support