Skip to content

Adding Domains

This guide walks you through adding new business domains to your Momentum application, following established patterns for consistency and maintainability.

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/main/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

csharp
// src/AppDomain/Orders/Data/Entities/Order.cs
using LinqToDB.Mapping;
using Momentum.Extensions.Common.Data;

[Table(Schema = "main", 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 = "main", 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

csharp
// 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: "$main.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

csharp
// 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: "$main.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: "$main.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

csharp
// src/AppDomain.Contracts/IntegrationEvents/OrderCreated.cs
using Momentum.Extensions.Kafka.Events;

[EventTopic("main.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("main.orders.order-completed")]
public record OrderCompleted(
    Guid TenantId,
    Guid OrderId,
    Guid CustomerId,
    decimal TotalAmount,
    DateTime CompletedDate
) : IIntegrationEvent;

5. Database Schema

sql
-- infra/AppDomain.Database/Liquibase/main/orders/changesets/001-orders-tables.sql
-- Multi-tenant table structure with composite primary keys
CREATE TABLE main.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 main.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 main.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 main.orders(tenant_id, customer_id);
CREATE INDEX idx_orders_status ON main.orders(tenant_id, status);
CREATE INDEX idx_orders_order_date ON main.orders(tenant_id, order_date DESC);
CREATE INDEX idx_order_items_order_id ON main.order_items(tenant_id, order_id);
CREATE INDEX idx_order_items_product_id ON main.order_items(tenant_id, product_id);

-- Add table comments for documentation
COMMENT ON TABLE main.orders IS 'Orders table with multi-tenant architecture using composite primary keys';
COMMENT ON TABLE main.order_items IS 'Order items table linked to orders with tenant-aware foreign keys';

6. Database Functions

sql
-- infra/AppDomain.Database/Liquibase/main/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 main.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 main.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 main.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/main/orders/functions/orders_get_by_id.sql
/**
 * Retrieves an order by ID within tenant boundaries.
 */
CREATE OR REPLACE FUNCTION main.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 main.orders o
    WHERE o.tenant_id = p_tenant_id
      AND o.order_id = p_order_id;
END;
$$ LANGUAGE plpgsql;

-- infra/AppDomain.Database/Liquibase/main/orders/functions/orders_get_by_customer.sql
/**
 * Retrieves orders for a specific customer with pagination and tenant isolation.
 */
CREATE OR REPLACE FUNCTION main.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 main.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

csharp
// 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")
            .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

csharp
// 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:

csharp
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:

csharp
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:

csharp
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

csharp
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

csharp
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:

csharp
// Your partial class definition
[DbCommand(fn: "$main.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 main.orders_create(@TenantId, @CustomerId, @ItemsJson)",
            command,
            cancellationToken);
        return Result.Success(result);
    }
}

Function Prefix Convention

The $ prefix in fn: "$main.orders_create" tells the generator to:

  1. Auto-generate SQL: SELECT * FROM main.orders_create(...)
  2. Map parameters: Automatically map record properties to function parameters
  3. Handle results: Convert database results to strongly-typed objects
  4. Error handling: Wrap exceptions in Result<T> pattern

Partial Class Requirements

csharp
// ✅ Correct: Partial class with static handler
public static partial class CreateOrderCommandHandler
{
    [DbCommand(fn: "$main.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:

csharp
// `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

csharp
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

csharp
// 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

csharp
// 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

csharp
// 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

csharp
// 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

csharp
// 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 });
    });
});