Composable Validation in C# 12: Building a Clean Business Rules Pipeline with Map and Bind

The Validation Code Nobody Wants to Touch

Every sufficiently old .NET codebase has a service method that starts with fifteen if statements. Null checks, range checks, format checks, existence checks — each one throws a different exception or returns a different magic value. The caller catches them all at different layers, sometimes inconsistently. Adding a new rule means finding the right slot in the chain, hoping nothing upstream already bailed out for the wrong reason.

There is a better shape for this code. A Result<T> type makes the success/failure outcome explicit in the method signature. Map and Bind operators let you chain steps that short-circuit cleanly on failure without nested conditionals. The resulting pipeline reads left to right, each step named after what it does, and every failure path is visible at a glance.

C# 12 makes this cleaner than it has ever been. Primary constructors reduce the boilerplate for value types, collection expressions tighten error aggregation, and pattern matching on Result at API boundaries is genuinely readable. This article builds the full pattern from scratch — Result type, operators, validators, pipeline composition, and unit tests.

The Result Type: Making Failure Explicit

A Result<T> is a value that is either a success containing a T, or a failure containing an error. Nothing else. No nulls, no exceptions, no out parameters. The type system forces every caller to handle both cases before they can get at the value.

C# 12 primary constructors make the implementation compact without sacrificing clarity. Keep the type sealed — you don't want subclasses defeating the exhaustive pattern match at the boundary.

Domain/Result.cs — Core Result Type
// Lightweight error value — carries a code for programmatic handling
// and a message for humans (or API consumers)
public sealed record ValidationError(string Code, string Message);

// The Result type — either success with a value, or failure with one or more errors
public sealed class Result
{
    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;

    // Only valid when IsSuccess
    public T Value => IsSuccess
        ? _value!
        : throw new InvalidOperationException(
            "Cannot access Value on a failed Result. Check IsSuccess first.");

    // Only valid when IsFailure
    public IReadOnlyList Errors => _errors;

    // Shorthand for the first error (most pipelines produce exactly one)
    public ValidationError FirstError => _errors[0];

    private readonly T?                           _value;
    private readonly IReadOnlyList _errors;

    // Private constructors — force callers through the factory methods below
    private Result(T value)
    {
        IsSuccess = true;
        _value    = value;
        _errors   = [];
    }

    private Result(IReadOnlyList errors)
    {
        IsSuccess = false;
        _value    = default;
        _errors   = errors;
    }

    // ── Factory methods ──────────────────────────────────────────────
    public static Result Ok(T value)         => new(value);
    public static Result Fail(string code, string message)
        => new([new ValidationError(code, message)]);
    public static Result Fail(IReadOnlyList errors)
        => new(errors);

    // Implicit conversion: lets you return a T directly from a Result method
    public static implicit operator Result(T value) => Ok(value);

    // ── Map: transform success value, cannot itself fail ────────────
    public Result Map(Func transform) =>
        IsSuccess
            ? Result.Ok(transform(_value!))
            : Result.Fail(_errors);

    // ── Bind: chain a failable step — short-circuits on failure ─────
    public Result Bind(Func> next) =>
        IsSuccess
            ? next(_value!)
            : Result.Fail(_errors);

    // ── Match: pattern-match at the boundary (endpoint, controller) ──
    public TOut Match(
        Func                          onSuccess,
        Func, TOut> onFailure) =>
        IsSuccess ? onSuccess(_value!) : onFailure(_errors);
}

// Non-generic Result for operations with no return value (commands)
public sealed class Result
{
    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;
    public IReadOnlyList Errors { get; }

    private Result(bool success, IReadOnlyList errors)
    {
        IsSuccess = success;
        Errors    = errors;
    }

    public static Result Ok()   => new(true,  []);
    public static Result Fail(string code, string message)
        => new(false, [new ValidationError(code, message)]);
}

The implicit operator from T to Result<T> is a deliberate convenience — it lets pipeline steps that always succeed return a plain value instead of wrapping every return in Result<T>.Ok(). Some teams omit it for explicitness; both choices are defensible. Choose one convention and apply it consistently across the codebase.

Writing Validators as Functions, Not Classes

Each business rule is a function: it takes an input and returns a Result. No base classes, no interfaces, no DI-registered validators unless a rule genuinely needs external dependencies. Pure functions are trivially testable — pass input, assert output.

The example below validates a product creation request through four independent rules before the command reaches the database. Each rule is named, focused, and independently testable.

Domain/ProductValidators.cs — Rules as Functions
public record CreateProductCommand(
    string  Name,
    decimal Price,
    int     StockQuantity,
    string  CategoryId,
    string  Sku);

public static class ProductValidators
{
    // Each validator follows the same signature:
    // Input → Result (passes the value through on success for chaining)

    public static Result ValidateName(
        CreateProductCommand cmd)
    {
        if (string.IsNullOrWhiteSpace(cmd.Name))
            return Result.Fail(
                "PRODUCT_NAME_REQUIRED", "Product name is required.");

        if (cmd.Name.Length > 200)
            return Result.Fail(
                "PRODUCT_NAME_TOO_LONG",
                $"Product name cannot exceed 200 characters (got {cmd.Name.Length}).");

        return cmd;   // implicit operator: cmd → Result.Ok(cmd)
    }

    public static Result ValidatePrice(
        CreateProductCommand cmd)
    {
        if (cmd.Price <= 0)
            return Result.Fail(
                "PRODUCT_PRICE_INVALID", "Price must be greater than zero.");

        if (cmd.Price > 999_999.99m)
            return Result.Fail(
                "PRODUCT_PRICE_TOO_HIGH", "Price cannot exceed 999,999.99.");

        // Reject more than 2 decimal places — avoids silent rounding in storage
        if (decimal.Round(cmd.Price, 2) != cmd.Price)
            return Result.Fail(
                "PRODUCT_PRICE_PRECISION",
                "Price must have at most 2 decimal places.");

        return cmd;
    }

    public static Result ValidateStock(
        CreateProductCommand cmd)
    {
        if (cmd.StockQuantity < 0)
            return Result.Fail(
                "PRODUCT_STOCK_NEGATIVE", "Stock quantity cannot be negative.");

        if (cmd.StockQuantity > 1_000_000)
            return Result.Fail(
                "PRODUCT_STOCK_TOO_HIGH", "Stock quantity cannot exceed 1,000,000.");

        return cmd;
    }

    private static readonly Regex SkuRegex =
        new(@"^[A-Z]{2}-\d{4,6}$", RegexOptions.Compiled, TimeSpan.FromMilliseconds(50));

    public static Result ValidateSku(
        CreateProductCommand cmd)
    {
        if (string.IsNullOrWhiteSpace(cmd.Sku))
            return Result.Fail(
                "PRODUCT_SKU_REQUIRED", "SKU is required.");

        if (!SkuRegex.IsMatch(cmd.Sku))
            return Result.Fail(
                "PRODUCT_SKU_FORMAT",
                "SKU must match pattern AA-0000 through AA-999999 (e.g., EL-1234).");

        return cmd;
    }
}

Notice every validator returns Result<CreateProductCommand> — it passes the same command through on success. This is what makes Bind chaining possible: each step receives the full command and can read any field it needs, while the pipeline carries the same value from start to finish until a Map transforms it into the output type.

Composing the Pipeline with Bind and Map

With validators written as functions, the pipeline is a single expression. Bind chains the failable steps in sequence. The first failure short-circuits the entire chain — no subsequent validators run, no exceptions escape, and the caller gets back a Result that describes exactly what went wrong.

Application/CreateProductHandler.cs — Pipeline Composition
public class CreateProductHandler(
    IProductRepository  repository,
    ICategoryRepository categories)
{
    public async Task> HandleAsync(
        CreateProductCommand cmd,
        CancellationToken    ct = default)
    {
        // ── Pure validation pipeline (synchronous, no I/O) ──────────
        // Bind chains left to right — first failure short-circuits
        var validationResult = Result.Ok(cmd)
            .Bind(ProductValidators.ValidateName)
            .Bind(ProductValidators.ValidatePrice)
            .Bind(ProductValidators.ValidateStock)
            .Bind(ProductValidators.ValidateSku);

        if (validationResult.IsFailure)
            return Result.Fail(validationResult.Errors);

        // ── Business rule: category must exist (requires I/O) ────────
        var category = await categories.FindAsync(cmd.CategoryId, ct);
        if (category is null)
            return Result.Fail(
                "CATEGORY_NOT_FOUND",
                $"Category '{cmd.CategoryId}' does not exist.");

        // ── Business rule: SKU must be unique within the category ────
        var skuExists = await repository.SkuExistsAsync(
            cmd.Sku, cmd.CategoryId, ct);

        if (skuExists)
            return Result.Fail(
                "PRODUCT_SKU_DUPLICATE",
                $"SKU '{cmd.Sku}' is already in use in category '{category.Name}'.");

        // ── Persist — only reached if all rules pass ─────────────────
        var product = new Product(
            Name:          cmd.Name,
            Price:         cmd.Price,
            StockQuantity: cmd.StockQuantity,
            CategoryId:    cmd.CategoryId,
            Sku:           cmd.Sku);

        await repository.AddAsync(product, ct);

        // ── Map the domain entity to the DTO (cannot fail) ───────────
        return Result.Ok(new ProductDto(
            product.Id,
            product.Name,
            product.Price,
            product.Sku,
            category.Name));
    }
}

// ── Minimal API endpoint — Match at the HTTP boundary ────────────────────
app.MapPost("/products", async (
    CreateProductCommand cmd,
    CreateProductHandler handler,
    CancellationToken    ct) =>
{
    var result = await handler.HandleAsync(cmd, ct);

    return result.Match(
        onSuccess: dto   => Results.Created($"/products/{dto.Id}", dto),
        onFailure: errors => Results.ValidationProblem(
            errors.ToDictionary(e => e.Code, e => new[] { e.Message }),
            title:  "Product creation failed",
            type:   "https://tools.ietf.org/html/rfc7807"));
})
.WithTags("Products")
.RequireAuthorization();

The endpoint handler contains zero business logic. It calls HandleAsync and pattern-matches the Result into HTTP responses. The business rules live in the handler and validators, entirely decoupled from HTTP concerns. Swap the Minimal API surface for a gRPC handler, a console command, or an integration test — the core logic doesn't change.

Unit Testing Every Pipeline Stage

Validation functions are pure — they take input and return output with no side effects, no dependencies, no mocking. Each one is a one-liner to test. The pipeline composition test verifies that the stages wire together correctly and that short-circuiting works as expected.

Tests/ProductValidatorTests.cs
public class ProductValidatorTests
{
    // ── ValidateName ─────────────────────────────────────────────────────
    [Fact]
    public void ValidateName_EmptyName_ReturnsFailureWithCorrectCode()
    {
        var cmd    = ValidCommand() with { Name = "" };
        var result = ProductValidators.ValidateName(cmd);

        Assert.True(result.IsFailure);
        Assert.Equal("PRODUCT_NAME_REQUIRED", result.FirstError.Code);
    }

    [Fact]
    public void ValidateName_NameExceeding200Chars_ReturnsFailure()
    {
        var cmd    = ValidCommand() with { Name = new string('x', 201) };
        var result = ProductValidators.ValidateName(cmd);

        Assert.True(result.IsFailure);
        Assert.Equal("PRODUCT_NAME_TOO_LONG", result.FirstError.Code);
    }

    [Fact]
    public void ValidateName_ValidName_PassesThroughSameCommand()
    {
        var cmd    = ValidCommand();
        var result = ProductValidators.ValidateName(cmd);

        Assert.True(result.IsSuccess);
        Assert.Same(cmd, result.Value);  // identity — not a copy
    }

    // ── ValidatePrice ────────────────────────────────────────────────────
    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    [InlineData(-0.01)]
    public void ValidatePrice_ZeroOrNegative_ReturnsFailure(decimal price)
    {
        var cmd    = ValidCommand() with { Price = price };
        var result = ProductValidators.ValidatePrice(cmd);

        Assert.True(result.IsFailure);
        Assert.Equal("PRODUCT_PRICE_INVALID", result.FirstError.Code);
    }

    [Fact]
    public void ValidatePrice_MoreThanTwoDecimalPlaces_ReturnsFailure()
    {
        var cmd    = ValidCommand() with { Price = 9.999m };
        var result = ProductValidators.ValidatePrice(cmd);

        Assert.True(result.IsFailure);
        Assert.Equal("PRODUCT_PRICE_PRECISION", result.FirstError.Code);
    }

    // ── Pipeline short-circuit behaviour ─────────────────────────────────
    [Fact]
    public void Pipeline_FirstFailureShortCircuits_SecondValidatorNeverRuns()
    {
        // Both Name and Price are invalid — only the Name error should appear
        var cmd = ValidCommand() with { Name = "", Price = -1 };

        var result = Result.Ok(cmd)
            .Bind(ProductValidators.ValidateName)   // fails here
            .Bind(ProductValidators.ValidatePrice); // never reached

        Assert.True(result.IsFailure);
        Assert.Single(result.Errors);
        Assert.Equal("PRODUCT_NAME_REQUIRED", result.FirstError.Code);
    }

    [Fact]
    public void Pipeline_AllValid_ReturnsSuccessWithOriginalCommand()
    {
        var cmd = ValidCommand();

        var result = Result.Ok(cmd)
            .Bind(ProductValidators.ValidateName)
            .Bind(ProductValidators.ValidatePrice)
            .Bind(ProductValidators.ValidateStock)
            .Bind(ProductValidators.ValidateSku);

        Assert.True(result.IsSuccess);
        Assert.Same(cmd, result.Value);
    }

    // ── Test data factory ─────────────────────────────────────────────────
    private static CreateProductCommand ValidCommand() => new(
        Name:          "Wireless Keyboard",
        Price:         49.99m,
        StockQuantity: 250,
        CategoryId:    "cat-peripherals",
        Sku:           "KB-1234");
}

The short-circuit test (Pipeline_FirstFailureShortCircuits_SecondValidatorNeverRuns) is the most important test in the suite. It verifies that the Bind implementation actually bails out on the first failure rather than running all steps. Run it first when adopting a new Result library or refactoring the pipeline — it's the canary for broken chaining logic.

Where This Pattern Fits — and Where It Doesn't

Composable pipelines are not a universal replacement for every validation approach in .NET. They shine in a specific context and create unnecessary friction outside it.

Pattern Fit Guide
// ── USE composable validation pipelines for: ─────────────────────────────

// Multi-step business logic with sequential dependencies
// e.g., validate format → check existence → verify permissions → apply rule
// Each step depends on the previous one succeeding — short-circuit is correct

// Domain command handlers where the outcome has multiple named failure modes
// e.g., OrderService.PlaceOrder can return OrderResult:
//   ValidationFailed | InsufficientStock | PaymentDeclined | Success

// Internal service APIs where the caller is trusted code, not raw HTTP input
// The caller knows how to handle a Result — it's a contract, not a stringly-typed check

// Anything where you want tests to be pure functions with zero mocking


// ── PREFER other tools for: ───────────────────────────────────────────────

// HTTP request body validation (structural shape checking)
// → Use DataAnnotations + FluentValidation + endpoint filters
//   They integrate with ModelState and ProblemDetails automatically

// Cross-cutting infrastructure failures (DB down, network timeout)
// → These are exceptions — unexpected, not a business outcome
//   Don't swallow them in a Result; let them propagate to error middleware

// Single-field format checks with no business context
// → A [EmailAddress] attribute on a DTO is clearer than a two-line validator function

// Aggregate all errors in one pass (e.g., show every field error at once)
// → Bind short-circuits; for parallel collection, run validators independently
//   and collect all failures before returning:
var errors = new[]
{
    ProductValidators.ValidateName(cmd),
    ProductValidators.ValidatePrice(cmd),
    ProductValidators.ValidateSku(cmd)
}
.Where(r => r.IsFailure)
.SelectMany(r => r.Errors)
.ToList();

if (errors.Count > 0)
    return Result.Fail(errors);

The most common mistake when adopting this pattern is over-applying it. DataAnnotations on request DTOs and FluentValidation for structural rules are already excellent tools with deep framework integration — replacing them with a custom Result pipeline adds friction for no gain. Use composable pipelines where they replace business logic exceptions, not where they replace validation attributes.

Frequently Asked

What is the difference between Map and Bind on a Result type?

Map transforms the success value where the transformation itself cannot fail — you pass it a function that returns a plain value, not a Result. Bind chains operations where the next step can itself fail — you pass it a function that returns a Result. Both short-circuit on an existing failure without calling your function. Think of Map as a safe transform and Bind as a failable next step.

Should I use this Result pattern or just throw exceptions?

Use exceptions for genuinely unexpected failures — infrastructure errors, bugs, things that should never happen in correct code. Use Result for expected business outcomes: validation failures, not-found conditions, constraint violations, and operations with multiple legitimate failure modes. The distinction is intent: exceptions interrupt; Results communicate. A Result-returning method says "this might not work, here's what went wrong." An exception says "this should always work but something broke."

How do I collect all validation errors at once instead of failing on the first one?

Bind short-circuits on the first failure — it doesn't run subsequent steps. For collecting all errors in one pass (useful for form validation), run each validator independently and aggregate: validators.Select(v => v(cmd)).Where(r => r.IsFailure).SelectMany(r => r.Errors). Reserve Bind chains for sequential business rules where later steps genuinely depend on earlier ones succeeding.

Does this pattern work with async operations like database lookups?

Yes — add async variants of Map and Bind that accept Func<T, Task<TOut>> and return Task<Result<TOut>>. The logic is identical: short-circuit on failure without awaiting; on success, await and return the result. Alternatively, use a library like CSharpFunctionalExtensions or LanguageExt that ships production-grade async variants so you don't maintain them yourself.

How do I return a Result error from a Minimal API endpoint?

Pattern-match the Result at the HTTP boundary using Match. On success, return the appropriate 2xx result. On failure, map the error code to the correct HTTP status — ValidationError to 400 with ProblemDetails, NotFound to 404, Conflict to 409. Put this mapping in a shared extension method so every endpoint uses the same error-to-HTTP translation rather than implementing it ad hoc per handler.

Back to Articles