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?
- Features
- Installation
- Dependency Injection Setup
- Core Concepts
- Complete Examples
- Best Practices
- Exception Handling
- Testing
- FAQ
- License
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:
// 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:
// 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 markersICommandHandler<TCommand>/ICommandHandler<TCommand, TResult>– Command handlersIQuery<TResult>– Query markerIQueryHandler<TQuery, TResult>– Query handlersIDispatcher– Central dispatcherUnit– Represents void return type- Automatic DI registration
Installation
Using .NET CLI
dotnet add package Marai.BridgetorUsing Package Manager Console
Install-Package Marai.BridgetorUsing PackageReference
Add to your .csproj file:
<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
// 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
// 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
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:
- Registers
IDispatcheras Scoped →Dispatcherimplementation - Scans assemblies for all command and query handlers
- Auto-registers handlers as Scoped services:
ICommandHandler<TCommand, TResult>implementationsICommandHandler<TCommand>implementationsIQueryHandler<TQuery, TResult>implementations
Core Concepts
ICommand
Marker interface for commands that change state (create, update, delete).
Definition
// 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:
// 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:
// 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
// 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
// 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:
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:
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:
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
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
// 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
// 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
public interface IQueryHandler<in TQuery, TResult>
where TQuery : IQuery<TResult>
{
Task<TResult> Handle(TQuery query, CancellationToken ct);
}Examples
Getting Single Entity:
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:
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:
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
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:
[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:
[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:
[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
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
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):
// 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 TaskOption 2: Using ICommand<Unit>:
// 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.ValueRecommendation: Use the parameterless ICommand interface for cleaner code:
// 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
// ===== 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
// ===== 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}";
}// ===== 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
};
}
}// ===== 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
// 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
// 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)
// 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
// 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 generic4. Return DTOs from Queries, Not Entities
// 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 entity5. Use CancellationToken
// 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
// 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 MultipleHandlersFoundExceptionDON’T
1. Don’t Put Business Logic in Controllers
// 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
// 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)
// 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
| Exception | When Thrown | How to Handle |
|---|---|---|
HandlerNotFoundException | No handler registered for command/query | Ensure handler is in scanned assembly |
MultipleHandlersFoundException | Multiple handlers for same request | Remove duplicate handlers |
Exception Handling Examples
In Controllers:
[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:
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
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
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>:
// 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:
// 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):
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):
// 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 handlerQ: 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?
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?
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
- Documentation: https://github.com/maraisystems/Marai.Bridgetor
- NuGet: https://www.nuget.org/packages/Marai.Bridgetor/
- Email: contact@marai.dev



