Adding Domains
Guide for adding new business domains to your Momentum application, following Domain-Driven Design principles and CQRS patterns.
Overview
Adding domains in Momentum follows a structured approach that maintains consistency, testability, and performance. Each domain encapsulates related business logic and follows established patterns for commands, queries, and data access.
Key Architectural Principles:
- Multi-Tenant by Design: All entities use composite primary keys with
TenantId
- Source Generation: Database access leverages compile-time code generation
- Wolverine Message Bus: Uses Wolverine instead of MediatR for message handling
Result<T>
Pattern: Commands and queries return strongly-typed results with error handling- Function-Based Database Access: PostgreSQL functions with
$
prefix for source generation
Domain Structure
Standard Domain Layout
src/AppDomain/Orders/
├── Commands/
│ ├── CreateOrderCommand.cs
│ ├── UpdateOrderStatusCommand.cs
│ └── CancelOrderCommand.cs
├── Queries/
│ ├── GetOrderByIdQuery.cs
│ ├── GetOrdersByCustomerQuery.cs
│ └── GetOrderHistoryQuery.cs
├── Data/
│ ├── Entities/
│ │ ├── Order.cs
│ │ └── OrderItem.cs
│ └── DbMapper.cs
└── Contracts/
├── DomainEvents/
│ ├── OrderCreated.cs
│ └── OrderStatusChanged.cs
└── IntegrationEvents/
├── OrderCreated.cs
└── OrderCompleted.cs
Multi-Tenant Database Functions
infra/AppDomain.Database/Liquibase/app_domain/orders/functions/
├── orders_create.sql
├── orders_get_by_id.sql
├── orders_get_by_customer.sql
└── orders_update_status.sql
Step-by-Step Domain Creation
1. Define Domain Entities
// src/AppDomain/Orders/Data/Entities/Order.cs
using LinqToDB.Mapping;
using Momentum.Extensions.Common.Data;
[Table(Schema = "app_domain", Name = "orders")]
public record Order : DbEntity
{
[PrimaryKey(Order = 0)]
[Column("tenant_id")]
public Guid TenantId { get; set; }
[PrimaryKey(Order = 1)]
[Column("order_id")]
public Guid OrderId { get; set; }
[Column("customer_id")]
public Guid CustomerId { get; set; }
[Column("order_date")]
public DateTime OrderDate { get; set; }
[Column("status")]
public OrderStatus Status { get; set; }
[Column("total_amount")]
public decimal TotalAmount { get; set; }
}
// src/AppDomain/Orders/Data/Entities/OrderItem.cs
[Table(Schema = "app_domain", Name = "order_items")]
public record OrderItem : DbEntity
{
[PrimaryKey(Order = 0)]
[Column("tenant_id")]
public Guid TenantId { get; set; }
[PrimaryKey(Order = 1)]
[Column("order_item_id")]
public Guid OrderItemId { get; set; }
[Column("order_id")]
public Guid OrderId { get; set; }
[Column("product_id")]
public Guid ProductId { get; set; }
[Column("product_name")]
public string ProductName { get; set; } = string.Empty;
[Column("quantity")]
public int Quantity { get; set; }
[Column("unit_price")]
public decimal UnitPrice { get; set; }
}
public enum OrderStatus
{
Pending,
Confirmed,
Shipped,
Delivered,
Cancelled
}
2. Create Commands
// src/AppDomain/Orders/Commands/CreateOrderCommand.cs
using Momentum.Extensions.Common.Messaging;
using Momentum.Extensions.Common.Results;
using Momentum.Extensions.Database;
using Wolverine;
public record CreateOrderCommand(
Guid TenantId,
Guid CustomerId,
IReadOnlyList<CreateOrderItem> Items
) : ICommand<Result<Order>>;
public record CreateOrderItem(
Guid ProductId,
string ProductName,
int Quantity,
decimal UnitPrice
);
public static partial class CreateOrderCommandHandler
{
[DbCommand(fn: "$app_domain.orders_create")]
public partial record DbCommand(
Guid TenantId,
Guid CustomerId,
string ItemsJson
) : ICommand<Result<Guid>>;
public static async Task<Result<Order>> Handle(
CreateOrderCommand command,
IMessageBus messaging,
CancellationToken cancellationToken)
{
// Business logic validation
if (!command.Items.Any())
return Result.Failure<Order>("Order must contain at least one item");
if (command.Items.Any(item => item.Quantity <= 0))
return Result.Failure<Order>("All items must have positive quantity");
// Serialize items to JSON for database function
var itemsJson = JsonSerializer.Serialize(command.Items);
// Execute database command
var dbCommand = new DbCommand(command.TenantId, command.CustomerId, itemsJson);
var result = await messaging.InvokeAsync(dbCommand, cancellationToken);
if (result.IsFailure)
return Result.Failure<Order>(result.Error);
// Fetch created order
var getOrderQuery = new GetOrderByIdQuery.DbQuery(command.TenantId, result.Value);
var order = await messaging.InvokeAsync(getOrderQuery, cancellationToken);
return order is not null
? Result.Success(order)
: Result.Failure<Order>("Failed to retrieve created order");
}
}
3. Create Queries
// src/AppDomain/Orders/Queries/GetOrderByIdQuery.cs
using Momentum.Extensions.Common.Messaging;
using Momentum.Extensions.Database;
using Wolverine;
public record GetOrderByIdQuery(Guid TenantId, Guid OrderId) : IQuery<Order?>;
public static partial class GetOrderByIdQueryHandler
{
[DbCommand(fn: "$app_domain.orders_get_by_id")]
public partial record DbQuery(Guid TenantId, Guid OrderId) : IQuery<Order?>;
public static async Task<Order?> Handle(
GetOrderByIdQuery query,
IMessageBus messaging,
CancellationToken cancellationToken)
{
var dbQuery = new DbQuery(query.TenantId, query.OrderId);
return await messaging.InvokeAsync(dbQuery, cancellationToken);
}
}
// src/AppDomain/Orders/Queries/GetOrdersByCustomerQuery.cs
public record GetOrdersByCustomerQuery(Guid TenantId, Guid CustomerId, int Limit = 10, int Offset = 0)
: IQuery<IEnumerable<GetOrdersByCustomerQuery.Result>>
{
public record Result(Guid OrderId, DateTime OrderDate, decimal TotalAmount, OrderStatus Status);
}
public static partial class GetOrdersByCustomerQueryHandler
{
[DbCommand(fn: "$app_domain.orders_get_by_customer")]
public partial record DbQuery(Guid TenantId, Guid CustomerId, int Limit, int Offset)
: IQuery<IEnumerable<Data.Entities.Order>>;
public static async Task<IEnumerable<GetOrdersByCustomerQuery.Result>> Handle(
GetOrdersByCustomerQuery query,
IMessageBus messaging,
CancellationToken cancellationToken)
{
var dbQuery = new DbQuery(query.TenantId, query.CustomerId, query.Limit, query.Offset);
var orders = await messaging.InvokeAsync(dbQuery, cancellationToken);
return orders.Select(o => new GetOrdersByCustomerQuery.Result(
o.OrderId, o.OrderDate, o.TotalAmount, o.Status));
}
}
4. Define Events
// src/AppDomain.Contracts/IntegrationEvents/OrderCreated.cs
using Momentum.Extensions.Kafka.Events;
[EventTopic("app_domain.orders.order-created")]
public record OrderCreated(
Guid TenantId,
Guid OrderId,
Guid CustomerId,
decimal TotalAmount,
DateTime OrderDate,
string Status
) : IIntegrationEvent;
// src/AppDomain/Orders/Contracts/DomainEvents/OrderStatusChanged.cs
using Momentum.Extensions.Common.Messaging;
public record OrderStatusChanged(
Guid TenantId,
Guid OrderId,
OrderStatus OldStatus,
OrderStatus NewStatus,
DateTime ChangedAt
) : IDomainEvent;
// src/AppDomain/Orders/Contracts/IntegrationEvents/OrderCompleted.cs
[EventTopic("app_domain.orders.order-completed")]
public record OrderCompleted(
Guid TenantId,
Guid OrderId,
Guid CustomerId,
decimal TotalAmount,
DateTime CompletedDate
) : IIntegrationEvent;
5. Database Schema
-- infra/AppDomain.Database/Liquibase/app_domain/orders/changesets/001-orders-tables.sql
-- Multi-tenant table structure with composite primary keys
CREATE TABLE app_domain.orders (
tenant_id UUID NOT NULL,
order_id UUID NOT NULL,
customer_id UUID NOT NULL,
order_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT timezone('utc', now()),
status VARCHAR(20) NOT NULL DEFAULT 'Pending',
total_amount DECIMAL(12,2) NOT NULL,
created_date_utc TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT timezone('utc', now()),
updated_date_utc TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT timezone('utc', now()),
-- Composite primary key ensures tenant isolation
PRIMARY KEY (tenant_id, order_id),
-- Check constraints for business rules
CONSTRAINT chk_orders_total_amount_positive CHECK (total_amount >= 0),
CONSTRAINT chk_orders_status_valid CHECK (status IN ('Pending', 'Confirmed', 'Shipped', 'Delivered', 'Cancelled'))
);
CREATE TABLE app_domain.order_items (
tenant_id UUID NOT NULL,
order_item_id UUID NOT NULL,
order_id UUID NOT NULL,
product_id UUID NOT NULL,
product_name VARCHAR(255) NOT NULL,
quantity INTEGER NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
created_date_utc TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT timezone('utc', now()),
updated_date_utc TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT timezone('utc', now()),
-- Composite primary key for tenant isolation
PRIMARY KEY (tenant_id, order_item_id),
-- Foreign key to orders table with tenant awareness
FOREIGN KEY (tenant_id, order_id) REFERENCES app_domain.orders(tenant_id, order_id) ON DELETE CASCADE,
-- Business rule constraints
CONSTRAINT chk_order_items_quantity_positive CHECK (quantity > 0),
CONSTRAINT chk_order_items_unit_price_positive CHECK (unit_price >= 0)
);
-- Indexes for efficient queries within tenant boundaries
CREATE INDEX idx_orders_customer_id ON app_domain.orders(tenant_id, customer_id);
CREATE INDEX idx_orders_status ON app_domain.orders(tenant_id, status);
CREATE INDEX idx_orders_order_date ON app_domain.orders(tenant_id, order_date DESC);
CREATE INDEX idx_order_items_order_id ON app_domain.order_items(tenant_id, order_id);
CREATE INDEX idx_order_items_product_id ON app_domain.order_items(tenant_id, product_id);
-- Add table comments for documentation
COMMENT ON TABLE app_domain.orders IS 'Orders table with multi-tenant architecture using composite primary keys';
COMMENT ON TABLE app_domain.order_items IS 'Order items table linked to orders with tenant-aware foreign keys';
6. Database Functions
-- infra/AppDomain.Database/Liquibase/app_domain/orders/functions/orders_create.sql
/**
* Creates a new order with items in a multi-tenant architecture.
* Returns the newly created order ID or raises an exception on failure.
*/
CREATE OR REPLACE FUNCTION app_domain.orders_create(
p_tenant_id UUID,
p_customer_id UUID,
p_items_json TEXT
) RETURNS UUID AS $$
DECLARE
v_order_id UUID;
v_total_amount DECIMAL(12,2) := 0;
v_items JSONB;
v_item JSONB;
BEGIN
-- Parse items JSON
v_items := p_items_json::JSONB;
-- Validate inputs
IF p_tenant_id IS NULL THEN
RAISE EXCEPTION 'Tenant ID cannot be null';
END IF;
IF p_customer_id IS NULL THEN
RAISE EXCEPTION 'Customer ID cannot be null';
END IF;
IF jsonb_array_length(v_items) = 0 THEN
RAISE EXCEPTION 'Order must contain at least one item';
END IF;
-- Generate new order ID
v_order_id := gen_random_uuid();
-- Calculate total amount and validate items
FOR v_item IN SELECT * FROM jsonb_array_elements(v_items)
LOOP
-- Validate item data
IF (v_item->>'quantity')::INTEGER <= 0 THEN
RAISE EXCEPTION 'Item quantity must be positive';
END IF;
IF (v_item->>'unitPrice')::DECIMAL < 0 THEN
RAISE EXCEPTION 'Item unit price cannot be negative';
END IF;
v_total_amount := v_total_amount +
((v_item->>'quantity')::INTEGER * (v_item->>'unitPrice')::DECIMAL);
END LOOP;
-- Create order with tenant isolation
INSERT INTO app_domain.orders (
tenant_id, order_id, customer_id, total_amount, status
) VALUES (
p_tenant_id, v_order_id, p_customer_id, v_total_amount, 'Pending'
);
-- Create order items with tenant isolation
FOR v_item IN SELECT * FROM jsonb_array_elements(v_items)
LOOP
INSERT INTO app_domain.order_items (
tenant_id, order_item_id, order_id, product_id,
product_name, quantity, unit_price
) VALUES (
p_tenant_id,
gen_random_uuid(),
v_order_id,
(v_item->>'productId')::UUID,
v_item->>'productName',
(v_item->>'quantity')::INTEGER,
(v_item->>'unitPrice')::DECIMAL
);
END LOOP;
RETURN v_order_id;
EXCEPTION
WHEN OTHERS THEN
-- Re-raise with context
RAISE EXCEPTION 'Failed to create order: %', SQLERRM;
END;
$$ LANGUAGE plpgsql;
-- infra/AppDomain.Database/Liquibase/app_domain/orders/functions/orders_get_by_id.sql
/**
* Retrieves an order by ID within tenant boundaries.
*/
CREATE OR REPLACE FUNCTION app_domain.orders_get_by_id(
p_tenant_id UUID,
p_order_id UUID
) RETURNS TABLE(
tenant_id UUID,
order_id UUID,
customer_id UUID,
order_date TIMESTAMP WITH TIME ZONE,
status VARCHAR(20),
total_amount DECIMAL(12,2),
created_date_utc TIMESTAMP WITH TIME ZONE,
updated_date_utc TIMESTAMP WITH TIME ZONE
) AS $$
BEGIN
RETURN QUERY
SELECT o.tenant_id, o.order_id, o.customer_id, o.order_date,
o.status, o.total_amount, o.created_date_utc, o.updated_date_utc
FROM app_domain.orders o
WHERE o.tenant_id = p_tenant_id
AND o.order_id = p_order_id;
END;
$$ LANGUAGE plpgsql;
-- infra/AppDomain.Database/Liquibase/app_domain/orders/functions/orders_get_by_customer.sql
/**
* Retrieves orders for a specific customer with pagination and tenant isolation.
*/
CREATE OR REPLACE FUNCTION app_domain.orders_get_by_customer(
p_tenant_id UUID,
p_customer_id UUID,
p_limit INTEGER DEFAULT 10,
p_offset INTEGER DEFAULT 0
) RETURNS TABLE(
tenant_id UUID,
order_id UUID,
customer_id UUID,
order_date TIMESTAMP WITH TIME ZONE,
status VARCHAR(20),
total_amount DECIMAL(12,2),
created_date_utc TIMESTAMP WITH TIME ZONE,
updated_date_utc TIMESTAMP WITH TIME ZONE
) AS $$
BEGIN
RETURN QUERY
SELECT o.tenant_id, o.order_id, o.customer_id, o.order_date,
o.status, o.total_amount, o.created_date_utc, o.updated_date_utc
FROM app_domain.orders o
WHERE o.tenant_id = p_tenant_id
AND o.customer_id = p_customer_id
ORDER BY o.order_date DESC
LIMIT p_limit
OFFSET p_offset;
END;
$$ LANGUAGE plpgsql;
7. API Endpoints
// src/AppDomain.Api/Orders/OrdersEndpoints.cs
using Microsoft.AspNetCore.Mvc;
using Wolverine;
using Momentum.Extensions.Api.Results;
using AppDomain.Orders.Commands;
using AppDomain.Orders.Queries;
public static class OrdersEndpoints
{
public static void MapOrdersEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/orders")
.WithTags("Orders")
.WithOpenApi()
.RequireAuthorization(); // Ensure authentication for tenant context
group.MapPost("/", CreateOrder)
.WithSummary("Create a new order")
.WithDescription("Creates a new order with the specified items for the authenticated tenant");
group.MapGet("/{id:guid}", GetOrder)
.WithSummary("Get order by ID")
.WithDescription("Retrieves a specific order by ID within the tenant context");
group.MapGet("/customer/{customerId:guid}", GetOrdersByCustomer)
.WithSummary("Get orders by customer")
.WithDescription("Retrieves all orders for a specific customer with pagination");
group.MapPut("/{id:guid}/status", UpdateOrderStatus)
.WithSummary("Update order status")
.WithDescription("Updates the status of an existing order");
}
private static async Task<IResult> CreateOrder(
[FromBody] CreateOrderRequest request,
IMessageBus messageBus,
HttpContext context)
{
// Extract tenant ID from authenticated user context
var tenantId = context.GetTenantId(); // Extension method for tenant extraction
var command = new CreateOrderCommand(
tenantId,
request.CustomerId,
request.Items.Select(i => new CreateOrderItem(
i.ProductId, i.ProductName, i.Quantity, i.UnitPrice
)).ToList()
);
var result = await messageBus.InvokeAsync(command);
return result.Match(
onSuccess: order => Results.Created($"/orders/{order.OrderId}", order),
onFailure: error => Results.BadRequest(new { Error = error })
);
}
private static async Task<IResult> GetOrder(
Guid id,
IMessageBus messageBus,
HttpContext context)
{
var tenantId = context.GetTenantId();
var query = new GetOrderByIdQuery(tenantId, id);
var order = await messageBus.InvokeAsync(query);
return order is not null ? Results.Ok(order) : Results.NotFound();
}
private static async Task<IResult> GetOrdersByCustomer(
Guid customerId,
[FromQuery] int limit = 10,
[FromQuery] int offset = 0,
IMessageBus messageBus,
HttpContext context)
{
var tenantId = context.GetTenantId();
var query = new GetOrdersByCustomerQuery(tenantId, customerId, limit, offset);
var orders = await messageBus.InvokeAsync(query);
return Results.Ok(new { Orders = orders, Limit = limit, Offset = offset });
}
private static async Task<IResult> UpdateOrderStatus(
Guid id,
[FromBody] UpdateOrderStatusRequest request,
IMessageBus messageBus,
HttpContext context)
{
var tenantId = context.GetTenantId();
var command = new UpdateOrderStatusCommand(tenantId, id, request.Status);
var result = await messageBus.InvokeAsync(command);
return result.Match(
onSuccess: _ => Results.NoContent(),
onFailure: error => Results.BadRequest(new { Error = error })
);
}
}
public record CreateOrderRequest(
Guid CustomerId,
IReadOnlyList<CreateOrderItemRequest> Items
);
public record CreateOrderItemRequest(
Guid ProductId,
string ProductName,
int Quantity,
decimal UnitPrice
);
public record UpdateOrderStatusRequest(OrderStatus Status);
8. Register Endpoints
// src/AppDomain.Api/Program.cs
using AppDomain.Api.Orders;
// Register domain endpoints
app.MapOrdersEndpoints();
// Extension method for tenant context extraction
public static class HttpContextExtensions
{
public static Guid GetTenantId(this HttpContext context)
{
var tenantClaim = context.User.FindFirst("tenant_id")?.Value;
if (string.IsNullOrEmpty(tenantClaim) || !Guid.TryParse(tenantClaim, out var tenantId))
{
throw new UnauthorizedAccessException("Invalid or missing tenant context");
}
return tenantId;
}
}
Domain Patterns
Aggregate Roots
Define clear aggregate boundaries:
public record Order
{
public void AddItem(OrderItem item)
{
// Business logic for adding items
// Validate business rules
// Raise domain events
}
public void ChangeStatus(OrderStatus newStatus)
{
// Validate status transitions
// Apply business rules
// Raise domain events
}
}
Value Objects
Encapsulate related values:
public record Money(decimal Amount, string Currency)
{
public static Money Zero(string currency) => new(0, currency);
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies");
return new Money(Amount + other.Amount, Currency);
}
}
Domain Services
Complex business logic that doesn't belong to entities:
public class OrderPricingService
{
public decimal CalculateTotal(IEnumerable<OrderItem> items, Customer customer)
{
var subtotal = items.Sum(item => item.Quantity * item.UnitPrice);
var discount = CalculateDiscount(subtotal, customer);
var tax = CalculateTax(subtotal - discount);
return subtotal - discount + tax;
}
}
Testing Domains
Unit Tests
public class CreateOrderHandlerTests
{
[Fact]
public async Task CreateOrder_WithValidData_ReturnsOrderId()
{
// Arrange
var command = new CreateOrder(
CustomerId: Guid.NewGuid(),
Items: [new CreateOrderItem(Guid.NewGuid(), 2, 19.99m)]
);
// Act
var orderId = await _mediator.Send(command);
// Assert
orderId.Should().NotBeEmpty();
}
}
Integration Tests
public class OrdersIntegrationTests : IntegrationTestFixture
{
[Fact]
public async Task CreateOrder_EndToEnd_Success()
{
// Arrange
var createCommand = new CreateOrder(
CustomerId: Guid.NewGuid(),
Items: [new CreateOrderItem(Guid.NewGuid(), 1, 29.99m)]
);
// Act
var orderId = await SendAsync(createCommand);
var order = await SendAsync(new GetOrderById(orderId));
// Assert
order.Should().NotBeNull();
order!.TotalAmount.Should().Be(29.99m);
}
}
Source Generation and Partial Classes
Understanding Source Generation
Momentum uses source generators to eliminate boilerplate code and ensure type safety. The [DbCommand]
attribute triggers compile-time code generation:
// Your partial class definition
[DbCommand(fn: "$app_domain.orders_create")]
public partial record DbCommand(Guid TenantId, Guid CustomerId, string ItemsJson)
: ICommand<Result<Guid>>;
// Generated code (simplified view)
public partial record DbCommand
{
public static async Task<Result<Guid>> Handle(
DbCommand command,
IDbConnection connection,
CancellationToken cancellationToken)
{
// Generated SQL execution code
var result = await connection.QuerySingleAsync<Guid>(
"SELECT * FROM app_domain.orders_create(@TenantId, @CustomerId, @ItemsJson)",
command,
cancellationToken);
return Result.Success(result);
}
}
Function Prefix Convention
The $
prefix in fn: "$app_domain.orders_create"
tells the generator to:
- Auto-generate SQL:
SELECT * FROM app_domain.orders_create(...)
- Map parameters: Automatically map record properties to function parameters
- Handle results: Convert database results to strongly-typed objects
- Error handling: Wrap exceptions in
Result<T>
pattern
Partial Class Requirements
// ✅ Correct: Partial class with static handler
public static partial class CreateOrderCommandHandler
{
[DbCommand(fn: "$app_domain.orders_create")]
public partial record DbCommand(...) : ICommand<Result<Guid>>;
// Your business logic handler
public static async Task<Result<Order>> Handle(
CreateOrderCommand command,
IMessageBus messaging,
CancellationToken cancellationToken)
{
// Business validation
// Database call via DbCommand
// Result transformation
}
}
// ❌ Incorrect: Missing partial keyword
public class CreateOrderCommandHandler { ... }
// ❌ Incorrect: Non-static class
public partial class CreateOrderCommandHandler { ... }
Result<T>
Pattern and Error Handling
Understanding Result<T>
Momentum uses the Result<T>
pattern for explicit error handling without exceptions:
// `Result<T>` represents either success with a value or failure with an error
public record Result<T>
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public T Value { get; } // Only available when IsSuccess = true
public string Error { get; } // Only available when IsFailure = true
}
// Creating results
var success = Result.Success(order); // `Result<Order>`
var failure = Result.Failure<Order>("Order not found");
Command Result Patterns
public static async Task<Result<Order>> Handle(
CreateOrderCommand command,
IMessageBus messaging,
CancellationToken cancellationToken)
{
// Validation with early returns
if (!command.Items.Any())
return Result.Failure<Order>("Order must contain at least one item");
if (command.Items.Any(item => item.Quantity <= 0))
return Result.Failure<Order>("All items must have positive quantity");
// Database operation
var dbResult = await messaging.InvokeAsync(new DbCommand(...));
if (dbResult.IsFailure)
return Result.Failure<Order>(dbResult.Error);
// Success path
var order = await GetCreatedOrder(dbResult.Value);
return order is not null
? Result.Success(order)
: Result.Failure<Order>("Failed to retrieve created order");
}
API Result Handling
// Extension method for `Result<T>` in APIs
public static IResult ToApiResult<T>(this Result<T> result)
{
return result.Match(
onSuccess: value => Results.Ok(value),
onFailure: error => Results.BadRequest(new { Error = error })
);
}
// Usage in endpoints
private static async Task<IResult> CreateOrder(
CreateOrderRequest request,
IMessageBus messageBus,
HttpContext context)
{
var command = new CreateOrderCommand(...);
var result = await messageBus.InvokeAsync(command);
return result.Match(
onSuccess: order => Results.Created($"/orders/{order.OrderId}", order),
onFailure: error => Results.BadRequest(new { Error = error })
);
}
Query Result Patterns
// Queries typically return nullable results instead of `Result<T>`
public static async Task<Order?> Handle(
GetOrderByIdQuery query,
IMessageBus messaging,
CancellationToken cancellationToken)
{
var dbQuery = new DbQuery(query.TenantId, query.OrderId);
return await messaging.InvokeAsync(dbQuery, cancellationToken);
}
// API handling for nullable queries
private static async Task<IResult> GetOrder(
Guid id,
IMessageBus messageBus,
HttpContext context)
{
var order = await messageBus.InvokeAsync(new GetOrderByIdQuery(...));
return order is not null ? Results.Ok(order) : Results.NotFound();
}
Best Practices
Naming Conventions
- Commands: Use action verbs with "Command" suffix (CreateOrderCommand, UpdateOrderStatusCommand)
- Queries: Use "Get" prefix with "Query" suffix (GetOrderByIdQuery, GetOrdersByCustomerQuery)
- Events: Use past tense (OrderCreated, OrderStatusChanged)
- Entities: Use singular nouns (Order, OrderItem)
- Database Functions: Use underscore notation (orders_create, orders_get_by_id)
Multi-Tenant Architecture
- Always include TenantId: Every entity, command, and query must include tenant context
- Composite Primary Keys: Use (tenant_id, entity_id) pattern for all tables
- Tenant Isolation: Ensure all queries filter by tenant_id
- Index Strategy: Create indexes with tenant_id as the first column
Performance Considerations
- Pagination: Use limit/offset pattern for queries returning multiple records
- Tenant-Aware Indexing: All indexes should start with tenant_id
- Function-Based Access: Use PostgreSQL functions instead of raw SQL for better performance
- Source Generation: Leverage compile-time generation to eliminate runtime reflection
Error Handling Strategy
- Commands: Return
Result<T>
for operations that can fail - Queries: Return nullable types for single entities, collections for lists
- Database Errors: Let functions handle validation and constraints
- API Layer: Convert
Result<T>
to appropriate HTTP responses
Testing Strategy
- Unit Tests: Test business logic in command/query handlers
- Integration Tests: Test database functions and complete workflows
- Architecture Tests: Enforce domain boundaries and tenant isolation
- API Tests: Test complete request/response cycles with tenant context
Common Patterns
Validation with FluentValidation
// FluentValidation with Wolverine integration
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.TenantId).NotEmpty().WithMessage("Tenant ID is required");
RuleFor(x => x.CustomerId).NotEmpty().WithMessage("Customer ID is required");
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must contain at least one item");
RuleForEach(x => x.Items).ChildRules(item => {
item.RuleFor(x => x.ProductId).NotEmpty();
item.RuleFor(x => x.ProductName).NotEmpty().MaximumLength(255);
item.RuleFor(x => x.Quantity).GreaterThan(0).WithMessage("Quantity must be positive");
item.RuleFor(x => x.UnitPrice).GreaterThanOrEqualTo(0).WithMessage("Unit price cannot be negative");
});
}
}
// Validation is automatically executed by Wolverine before handler execution
Event Publishing Patterns
// In command handler - two-tier event publishing
public static async Task<Result<Order>> Handle(
CreateOrderCommand command,
IMessageBus messaging,
CancellationToken cancellationToken)
{
// Execute database operation
var result = await ExecuteCreateOrder(command, messaging);
if (result.IsFailure) return result;
var order = result.Value;
// Publish domain event (internal to bounded context)
var domainEvent = new OrderStatusChanged(
order.TenantId, order.OrderId, OrderStatus.Pending,
OrderStatus.Pending, DateTime.UtcNow);
await messaging.PublishAsync(domainEvent, cancellationToken);
// Publish integration event (cross-bounded context)
var integrationEvent = new OrderCreated(
order.TenantId, order.OrderId, order.CustomerId,
order.TotalAmount, order.OrderDate, order.Status.ToString());
await messaging.PublishAsync(integrationEvent, cancellationToken);
return Result.Success(order);
}
Error Handling Strategies
// Domain-specific exceptions for `Result<T>` pattern
public static class OrderErrors
{
public static string OrderNotFound(Guid orderId) => $"Order {orderId} not found";
public static string InvalidOrderStatus(OrderStatus current, OrderStatus requested) =>
$"Cannot change order status from {current} to {requested}";
public static string InsufficientInventory(Guid productId, int requested, int available) =>
$"Insufficient inventory for product {productId}: requested {requested}, available {available}";
}
// Usage in handlers
if (order is null)
return Result.Failure<Order>(OrderErrors.OrderNotFound(command.OrderId));
if (!IsValidStatusTransition(order.Status, command.NewStatus))
return Result.Failure<Order>(OrderErrors.InvalidOrderStatus(order.Status, command.NewStatus));
// Global error handling middleware for unhandled exceptions
app.UseExceptionHandler(exceptionHandlerApp =>
{
exceptionHandlerApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerPathFeature>()?.Error;
var response = exception switch
{
UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized access"),
ArgumentException argEx => (StatusCodes.Status400BadRequest, argEx.Message),
_ => (StatusCodes.Status500InternalServerError, "An error occurred")
};
context.Response.StatusCode = response.Item1;
await context.Response.WriteAsJsonAsync(new { Error = response.Item2 });
});
});