Skip to content

Repository files navigation

Shamoradi.Ensuring

Validate. Return. Respond. — One fluent flow from input to HTTP.

Not just guard clauses. Not just a result pattern. The entire failure-handling story of your application, unified — in MVC, Minimal APIs, and FastEndpoints.

NuGet NuGet License: MIT .NET

dotnet add package Shamoradi.Ensuring
dotnet add package Shamoradi.Ensuring.AspNetCore                 # MVC + Minimal API integration
dotnet add package Shamoradi.Ensuring.AspNetCore.FastEndpoints   # FastEndpoints integration
// validate → build the domain object → return a result → the API maps it to HTTP. One flow:
public static Result<Order> Create(string? name, int quantity, decimal unitPrice)
{
    return Ensure.That
        .For(name).NotNullOrWhiteSpace().MaxLength(200)
        .For(quantity).Between(1, 1_000)
        .For(unitPrice).IsPositive()
        .Return(() => new Order(name!, quantity, unitPrice));
}

[HttpPost]  // controller: just return it — 200 / 400 + ProblemDetails / 404 / 409 handled for you
public async Task<Result<OrderDto>> Place(PlaceOrderRequest request, CancellationToken ct)
    => await _orderService.PlaceAsync(request, ct);

Give it a star ⭐

If Shamoradi.Ensuring saves you from writing one more if (result.IsFailure) return BadRequest(...), a star helps other developers find it. Thanks!


The 30-second pitch

Most libraries solve one slice of the problem:

Validates input Collects all errors Guard-style throw Result pattern Rich errors (code + status) Paged results Automatic HTTP mapping
Guard clause libraries
Validation libraries
Result libraries partial
Shamoradi.Ensuring

So a typical codebase ends up stitching together a guard library in the domain, a validation library for DTOs, a result library in services, and hand-written mapping code in controllers — four libraries, four error models, four conventions.

Shamoradi.Ensuring is one pipeline:

  Ensure.That.For(value)...   ──►   Result / Result<T> / PagedResult<T>   ──►   HTTP response
        validation                        your services return it              mapped automatically

One error model (Error with code + message + status) travels untouched from the deepest domain rule to the JSON your API returns.


Show me

Same product, two coding styles — pick what fits the team. Controllers stay identical either way.

Fluent style

Domain → application → API. One chain, errors short-circuit:

// ── Domain ──────────────────────────────────────────────────────────
public static Result<Money> Create(decimal amount, string? currency)
{
    return Ensure.That
        .For(amount)
            .IsPositive()
        .For(currency)
            .NotNullOrWhiteSpace()
            .Length(3, "Currency must be a 3-letter ISO code.")
            .UpperCase()
        .Return(() => new Money(amount, currency!));
}

// ── Application ─────────────────────────────────────────────────────
public async Task<Result<CustomerDto>> RegisterAsync(
    RegisterCustomerRequest request, CancellationToken ct)
{
    return (await Ensure.That
        .For(request.FullName).NotNullOrWhiteSpace().MinLength(3).MaxLength(100)
        .For(request.Email).NotNullOrWhiteSpace().IsEmail()
        .Return(() => Customer.Create(request.FullName, request.Email))
        .ThenAsync(customer => _repository.AddAsync(customer, ct)))
        .Then(CustomerDto.FromDomain);
}

public async Task<Result<CustomerDto>> GetByIdAsync(Guid id, CancellationToken ct)
{
    return (await Ensure.That
        .For(id).NotDefault("A customer identifier is required.")
        .Return(() => id)
        .ThenAsync(validId => _repository.GetByIdAsync(new CustomerId(validId), ct)))
        .Then(CustomerDto.FromDomain);
}

// ── API ─────────────────────────────────────────────────────────────
[HttpPost]
public async Task<Result<CustomerDto>> Register(RegisterCustomerRequest request, CancellationToken ct)
    => await _customerService.RegisterAsync(request, ct);

Familiar style (imperative)

You are not required to chain everything. Early returns, ifs, and implicits work the same — still one Error model, still automatic HTTP mapping:

public async Task<Result<CustomerDto>> RegisterAsync(
    RegisterCustomerRequest request, CancellationToken ct)
{
    var validation = Ensure.That
        .For(request.FullName).NotNullOrWhiteSpace().MinLength(3).MaxLength(100)
        .For(request.Email).NotNullOrWhiteSpace().IsEmail()
        .Return();

    if (validation.IsFailure)
        return validation.Errors.ToList();

    var created = Customer.Create(request.FullName, request.Email);
    if (created.IsFailure)
        return created.Errors.ToList();

    var saved = await _repository.AddAsync(created.Value!, ct);
    if (saved.IsFailure)
        return saved.Errors.ToList();

    return CustomerDto.FromDomain(saved.Value!);
}

public async Task<Result<CustomerDto>> GetByIdAsync(Guid id, CancellationToken ct)
{
    if (id == Guid.Empty)
        return Error.Validation("Customer.Id", "A customer identifier is required.");

    var customer = await _repository.GetByIdAsync(new CustomerId(id), ct);
    if (customer.IsFailure)
        return customer.Errors.ToList();

    return CustomerDto.FromDomain(customer.Value!);
}

[HttpPost]
public async Task<Result<CustomerDto>> Register(RegisterCustomerRequest request, CancellationToken ct)
    => await _customerService.RegisterAsync(request, ct);

Mix freely: fluent in domain factories, imperative in handlers — or the reverse. The NuGet does not lock you into one style.

What the API returns

The host maps Result / Result<T> / PagedResult<T> for you. No if (result.IsFailure) in controllers. No manual ProblemDetails.

Success shapes

Result (void op — e.g. rename / submit):

{ "status": "Success" }

Result<T> (e.g. GET /api/customers/{id}200 OK):

{
  "isSuccess": true,
  "status": "Success",
  "value": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "fullName": "Sara Ahmadi",
    "email": "sara@example.com"
  }
}

PagedResult<T> (e.g. GET /api/customers?pageNumber=1&pageSize=10200 OK):

{
  "isSuccess": true,
  "status": "Success",
  "items": [
    { "id": "...", "fullName": "Sara Ahmadi", "email": "sara@example.com" }
  ],
  "pageNumber": 1,
  "pageSize": 10,
  "totalCount": 42,
  "totalPages": 5,
  "hasPreviousPage": false,
  "hasNextPage": true,
  "startItem": 1,
  "endItem": 10,
  "query": { "search": null, "sorts": [], "filters": {} }
}

HTTP status map

Outcome HTTP
Success (Result / Result<T> / PagedResult<T>) 200 OK
WithCreated(...) 201 Created + Location
WithAccepted(...) 202 Accepted + Location
Result success configured as NoContent 204 No Content
Validation (bad name and bad email, …) 400 Bad Request + ProblemDetails
Unauthorized 401 Unauthorized
Forbidden 403 Forbidden
NotFound 404 Not Found
Conflict (e.g. duplicate email) 409 Conflict
Failure / unexpected 500 Internal Server Error

Failure shape (ProblemDetails when enabled):

{
  "type": "https://httpstatuses.com/400",
  "title": "Bad Request",
  "status": 400,
  "detail": "'FullName' must be at least 3 characters long.",
  "instance": "/api/customers",
  "errors": {
    "errors": [
      { "code": "MinLength", "message": "'FullName' must be at least 3 characters long." },
      { "code": "IsEmail",   "message": "'Email' must be a valid email address." }
    ]
  }
}

Table of contents


Getting started

Replace guard clauses

Throwing — first failure wins, the rest of the rules never run, the caller must catch:

public Order(string name, int quantity, decimal unitPrice)
{
    if (string.IsNullOrWhiteSpace(name))
        throw new ArgumentException("Name is required", nameof(name));
    if (quantity <= 0)
        throw new ArgumentOutOfRangeException(nameof(quantity));
    if (unitPrice <= 0)
        throw new ArgumentOutOfRangeException(nameof(unitPrice));

    // ...
}

Returning a result — every rule runs, every violation is collected, nothing is thrown:

public static Result<Order> Create(string? name, int quantity, decimal unitPrice)
{
    return Ensure.That
        .For(name)
            .NotNullOrWhiteSpace()
            .MaxLength(200)
        .For(quantity)
            .Between(1, 1_000)
        .For(unitPrice)
            .IsPositive()
        .Return(() => new Order(name!, quantity, unitPrice));
}

Neither is “wrong.” Prefer Return for domain / application / API flows where failures are expected input (bad payloads, not-found, conflict) — they become data, not control flow. Prefer Throw at hard boundaries (startup config, “this must never happen”) where an exception is the right signal. Same rules either way; only the terminal changes.

Compose nested results with Return

Return(() => ...) already chooses for you: on failure it returns the pipeline errors; on success it runs the factory. Nest another Result with Then — no manual Build + Concat + ternary:

public static Result<Customer> Create(string? fullName, string? email)
{
    return Ensure.That
        .For(fullName)
            .NotNullOrWhiteSpace()
            .MinLength(3)
            .MaxLength(100)
        .Return(() => Email.Create(email)
            .Then(e => new Customer(CustomerId.New(), fullName!.Trim(), e)));
}

Inside a single Ensure.That pipeline, rules never short-circuit — a bad name and a bad age in the same chain both appear in Errors. Nested Email.Create runs only after the outer rules succeed (same idea as Then on results).

Prefer to throw? You still can

When you're at a boundary where exceptions make sense, end the same pipeline with Throw():

Ensure.That
    .For(connectionString)
        .NotNullOrWhiteSpace()
    .For(timeoutSeconds)
        .Between(1, 300)
    .Throw();   // throws EnsureValidationException with ALL collected errors

Same rules, same messages — Return(...) for expected failures, Throw() for exceptional ones.

Results at work

Validation is only half the story. Results also carry queries, maps, and side effects without nested ifs:

// Load → map → DTO
public async Task<Result<OrderDto>> GetOrderAsync(Guid orderId, CancellationToken ct)
{
    return (await Ensure.That
        .For(orderId).NotDefault()
        .Return(() => orderId)
        .ThenAsync(id => _orders.GetByIdAsync(new OrderId(id), ct)))
        .Then(OrderDto.FromDomain);
}

// Mutate only on success
public Result Rename(string? fullName)
{
    return Ensure.That
        .For(fullName).NotNullOrWhiteSpace().MinLength(3).MaxLength(100)
        .Do(name => FullName = name.Trim())
        .Return();
}

// Fold for UI / logging / non-HTTP hosts
var message = orderResult.Match(
    order  => $"Order {order.Id} total {order.TotalAmount:C}",
    errors => errors.First().Message);

// Recover or replace on failure
return cached.OrElse(() => LoadFromDatabase());

Infrastructure returns the same shape — no nulls, no thrown “not found”:

public Task<Result<Customer>> GetByIdAsync(CustomerId id, CancellationToken ct)
    => Task.FromResult<Result<Customer>>(
        _store.TryGetValue(id.Value, out var customer)
            ? customer
            : DomainErrors.Customer.NotFound(id.Value));

The validation pipeline

Everything starts at Ensure.That. Each For(value) opens a rule chain for that value; chains share one error list:

var result = Ensure.That
    .For(user.Name)
        .NotNullOrWhiteSpace()
        .LengthBetween(3, 100)
    .For(user.Age)
        .GreaterThanOrEqual(18)
    .For(user.Email)
        .IsEmail()
    .Return(() => user);

Automatic property names

Property names are captured from the call site via [CallerArgumentExpression] — no magic strings:

Ensure.That.For(user.Email).IsEmail().Build();
// error message: "'user.Email' must be a valid email address."

Ensure.That.For(email, propertyName: "Email").IsEmail().Build();
// error message: "'Email' must be a valid email address."

Every rule also accepts an optional custom message:

.For(currency).Length(3, "Currency must be a 3-letter ISO code.")

NotNull and nullable types

NotNull is meant for nullable inputs — the common, healthy path:

User? user = GetUser();
Ensure.That.For(user).NotNull()...   // clean: narrows to RuleBuilder<User>

int? quantity = GetQuantity();
Ensure.That.For(quantity).NotNull()... // unwraps to RuleBuilder<int>

Calling NotNull on a value that is already non-nullable (User user, string name) is usually redundant. Because of how C# nullable reference types interact with invariant generics, that call site may produce compiler warning CS8620. Prefer NotNull when the source is T?; use NotDefault when you mean “not the default value” for non-nullable structs (Guid, int, …).

Built-in rules

Category Rules
Null / default NotNull, NotDefault, Default
Strings NotNullOrWhiteSpace, NotNullOrEmpty, MinLength, MaxLength, Length, LengthBetween, Contains, Matches (regex), Trimmed, StartsWith, EndsWith
String casing UpperCase, LowerCase, StartsWithUpperCase, StartsWithLowerCase
String content ContainsUpperCase, ContainsLowerCase, ContainsDigit, ContainsLetter, ContainsSpecialCharacter
String formats IsEmail, IsUri, IsGuid
Numeric IsPositive, IsNegative, IsZero, IsNotZero (generic over INumber<T>)
Comparison GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, Between, NotBetween
Equality / membership Equal, NotEqual, OneOf, NotOneOf
Enums IsDefined, OneOf, NotOneOf
Guid NotEmpty, Empty
Predicates Must, MustNot — any custom condition inline
Conditional When(condition, rules), Unless(condition, rules) — apply rules only in certain states
Runtime types IsType<T>, IsNotType<T>, IsAssignableTo<T>

Collection rules

The full rule set works on every common collection shapeIEnumerable<T>, ICollection<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, List<T>, T[], and ImmutableArray<T> — with full element-type inference (your lambdas are typed, no generic annotations needed):

Ensure.That
    .For(order.Lines)
        .NotEmpty()
        .MaxCount(20)
        .All(line => line.Quantity > 0)
        .Must(lines => lines.Select(l => l.ProductName).Distinct().Count() == lines.Count,
              "Order lines must reference distinct products.")
    .Return(() => order);
Group Rules
Shape NotNull, NotEmpty, Count, MinCount, MaxCount, HasCount, HasMinimumCount, HasMaximumCount, HasCountBetween, IsEmpty
Elements All, Any, None, Single, Exactly(n, predicate), AtLeast(n, predicate), AtMost(n, predicate)
Containment Contains, DoesNotContain (with optional IEqualityComparer<T>)
Uniqueness Unique, ContainsDuplicates
Set relations IsSubsetOf, IsSupersetOf, SetEquals
Ordering Ascending, Descending
Value-type collections ImmutableArray<T> gets NotDefault and default-safe checks for free

Custom rules

A rule is just an extension method on RuleBuilder<T> — extend the vocabulary with your own domain language:

public static RuleBuilder<string> IsIranianNationalCode(
    this RuleBuilder<string> builder,
    string? message = null)
{
    ArgumentNullException.ThrowIfNull(builder);

    if (!NationalCode.IsValid(builder.Value))
    {
        builder.AddError(
            nameof(IsIranianNationalCode),
            message ?? $"'{builder.PropertyName}' must be a valid national code.");
    }

    return builder;
}

// reads like it was always there:
Ensure.That.For(request.NationalCode).IsIranianNationalCode().Return();

Terminal operations

How a pipeline ends decides what you get back:

Terminal Returns When to use
.Return() Result / Result<T> of the validated value Validation-only endpoints, DTO Validate() methods
.Return(() => value) Result<T> — factory runs only on success Domain factories (the "guard replacement")
.Return(() => Result<T>) Result<T> from your own factory Composing with inner results
.ReturnPage(() => items) PagedResult<T> List/paging use cases
.Do(action).Return() Result after a side effect on success Mutations (Rename, AddLine)
.Build() Result When you need the raw success/failure
.Throw() void — throws EnsureValidationException on failure Classic guard behavior at trust boundaries

Ensure also converts implicitly to bool for quick checks:

if (!Ensure.That.For(id).NotDefault()) return;   // reads naturally

Result types

Three result shapes cover the whole application surface:

Type Purpose
Result An operation with no return value
Result<T> An operation returning a value
PagedResult<T> A page of items + pagination / search / sort / filter metadata

All expose IsSuccess, IsFailure, Status, Errors, FirstError.

Creating results

Prefer implicits and Ensure…Return(...) — you almost never need a factory at the call site:

Result<User> a = user;                                // success from a value
Result<User> b = Error.NotFound();                    // single error
Result<User> c = new[] { Error.Validation(), Error.Conflict() };
Result     ok = true;                                 // void success
Result     bad = DomainErrors.Order.AlreadySubmitted; // void failure
PagedResult<User> page = users.ToList();              // success page

// domain / DTO validation → Result in one step:
return Ensure.That
    .For(name).NotNullOrWhiteSpace()
    .Return(() => new User(name!));

The Error type

An Error is an immutable record with a stable code, a human message, and a status category — the status is what later drives the HTTP mapping:

public sealed record Error(string Code, string Message, ResultStatus Status);

Error.Validation("User.NameTooShort", "Name must be at least 3 characters.");
Error.NotFound("User.NotFound", "User '42' was not found.");
Error.Conflict("User.EmailTaken", "This email is already registered.");
Error.Unauthorized();  Error.Forbidden();  Error.Failure();

Organizing domain errors

Keep error definitions next to the domain that owns them:

public static class DomainErrors
{
    public static class Customer
    {
        public static Error NotFound(Guid id) =>
            Error.NotFound("Customer.NotFound", $"Customer '{id}' was not found.");

        public static Error EmailAlreadyRegistered(string email) =>
            Error.Conflict("Customer.EmailAlreadyRegistered", $"The email '{email}' is already registered.");
    }
}

// infrastructure speaks the same language — no exceptions, no nulls:
public Task<Result<Customer>> GetByIdAsync(CustomerId id, CancellationToken ct)
    => Task.FromResult<Result<Customer>>(
        _store.TryGetValue(id.Value, out var customer)
            ? customer
            : DomainErrors.Customer.NotFound(id.Value));

Functional operations

Compose whole use cases without a single if (result.IsFailure) in between — failures propagate automatically:

// Command: create → persist → DTO
return (await Customer.Create(request.FullName, request.Email)
    .ThenAsync(c => _repository.AddAsync(c, ct)))
    .Then(CustomerDto.FromDomain);

// Query: validate id → load → map
return (await Ensure.That
    .For(orderId).NotDefault()
    .Return(() => orderId)
    .ThenAsync(id => _orders.GetByIdAsync(new OrderId(id), ct)))
    .Then(OrderDto.FromDomain);

// Side effect without breaking the chain
return Email.Create(raw)
    .TapSuccess(email => _logger.LogInformation("Verified {Email}", email));
Operation What it does
Then / ThenAsync Chain to the next value or result; skipped on failure
Map / Select Project the value
Tap / TapSuccess Side effects (logging, state changes) without breaking the chain
Match Fold into a single value: result.Match(onSuccess, onFailure)
Switch Branch into actions
OrElse Fallback value / result / factory on failure
Finally Run regardless of outcome
var message = result.Match(
    user  => $"Welcome, {user.Name}!",
    errors => $"Failed: {errors.First().Message}");

PagedResult<T>

Not an afterthought — a first-class result for list endpoints, with pagination, links, search/sort/filter metadata, and its own combinators:

public async Task<PagedResult<CustomerDto>> GetPageAsync(
    int pageNumber, int pageSize, CancellationToken ct)
{
    return await Ensure.That
        .For(pageNumber).IsPositive()
        .For(pageSize).Between(1, 100)
        .Return(() => (pageNumber, pageSize))
        .MatchAsync(
            async page =>
            {
                var (items, totalCount) = await _repository.GetPageAsync(
                    page.pageNumber, page.pageSize, ct);

                PagedResult<CustomerDto> result = items
                    .Select(CustomerDto.FromDomain)
                    .ToList();

                return result
                    .WithPagination(page.pageNumber, page.pageSize, totalCount)
                    .WithSearch("sara")
                    .WithSortDescending("registeredAt");
            },
            errors => Task.FromResult<PagedResult<CustomerDto>>(errors.ToList()));
}

Over HTTP, pagination metadata is also emitted as an X-Pagination response header by default (configurable name and mode via options.Pagination), so clients that only read headers get paging info without parsing the body.

Serialized output includes everything a frontend needs:

{
  "items": [ /* ... */ ],
  "pageNumber": 1, "pageSize": 10,
  "totalCount": 42, "totalPages": 5,
  "hasPreviousPage": false, "hasNextPage": true,
  "startItem": 1, "endItem": 10,
  "query": { "search": "sara", "sorts": [ /* ... */ ], "filters": {} },
  "links": { "first": null, "previous": null, "current": null, "next": null, "last": null },
  "isSuccess": true, "errors": []
}

Fluent metadata builders: WithPagination, WithPage, WithPageSize, WithTotalCount, WithSearch, WithFilter(s), WithSort(s), WithSortAscending/Descending, WithAggregate(s), WithLinks, WithFirst/Previous/Current/Next/LastLink — plus Then, Tap, Select, Match, Switch, OrElse that preserve pagination metadata across projections.


ASP.NET Core integration

builder.Services.AddEnsureAspNetCore(options =>
{
    options.UseProblemDetails = true;        // RFC 7807 responses for failures
    options.IncludeValidationErrors = true;  // full error list in the body
});

builder.Services.AddControllers();
builder.Services.AddEnsureMvc();             // result filter for controllers

MVC controllers — return the result

With the filter registered, controller actions return library results directly — the filter intercepts and maps them:

[ApiController]
[Route("api/customers")]
public sealed class CustomersController(ICustomerService service) : ControllerBase
{
    [HttpPost]
    public async Task<Result<CustomerDto>> Register(RegisterCustomerRequest request, CancellationToken ct)
        => await service.RegisterAsync(request, ct);

    [HttpGet]
    public async Task<PagedResult<CustomerDto>> GetPage(int pageNumber = 1, int pageSize = 10, CancellationToken ct = default)
        => await service.GetPageAsync(pageNumber, pageSize, ct);
}

Prefer explicit conversion? this.ToActionResult(result) does the same thing per call.

Minimal APIs — return the result

Add .AddEnsure() to an endpoint or a whole group and handlers just return results — same experience as MVC:

var orders = app.MapGroup("/api/orders").AddEnsure();

orders.MapPost("/", async (PlaceOrderCommand command, IMediator mediator, CancellationToken ct)
    => await mediator.SendAsync(command, ct));            // Result<OrderDto> — mapped automatically

orders.MapGet("/{id:guid}", async (Guid id, IMediator mediator, CancellationToken ct)
    => await mediator.SendAsync(new GetOrderQuery(id), ct));

Prefer explicit conversion? result.ToResult(httpContext) does the same thing per call.

FastEndpoints — one call

Install Shamoradi.Ensuring.AspNetCore.FastEndpoints, register AddEnsureFastEndpoints(), and finish handlers with a single send:

public sealed class PlaceOrderEndpoint(IMediator mediator) : Endpoint<PlaceOrderCommand>
{
    public override void Configure() => Post("/api/orders");

    public override async Task HandleAsync(PlaceOrderCommand req, CancellationToken ct)
        => await this.SendEnsureAsync(await mediator.SendAsync(req, ct), ct);
}

ToFastResult(result) is also available when you want the IResult without writing the response.

201 / 202 / caching — HTTP metadata from your service

Success isn't always 200 OK. Attach HTTP intent to the result where the operation happens — the mapping layer honors it in MVC, Minimal APIs, and FastEndpoints alike:

// service layer — still no HttpContext in sight:
Result<CustomerDto> created = customer;
return created.WithCreated($"/api/customers/{customer.Id}");   // → 201 Created + Location

Result<JobDto> accepted = job;
return accepted.WithAccepted($"/api/jobs/{job.Id}");           // → 202 Accepted + Location

Result<DocDto> cached = doc;
return cached.WithETag("\"v3\"").WithCacheControl("max-age=60");

Result<ReportDto> partial = report;
return partial.WithStatusCode(206);                            // any explicit status

Available: WithCreated, WithAccepted, WithStatusCode, WithLocation, WithETag, WithCacheControl, WithLastModified. They are no-ops on failed results, so chains stay safe.

Status code mapping

ResultStatus HTTP
Success 200 OK (configurable: 204 NoContent, 201 Created + Location, 202 Accepted)
Validation 400 Bad Request + ProblemDetails with the full error list
Unauthorized 401 Unauthorized
Forbidden 403 Forbidden
NotFound 404 Not Found
Conflict 409 Conflict
Failure 500 Internal Server Error

The mapping pipeline is built from small replaceable services (IEnsureStatusCodeProvider, IEnsureProblemDetailsFactory, IEnsureHttpResponseFactory, ...) — register your own implementation to override any stage.

Options

builder.Services.AddEnsureAspNetCore(options =>
{
    options.SuccessResponse = SuccessResponse.NoContent;   // for value-less Result successes
    options.UseProblemDetails = true;
    options.IncludeValidationErrors = true;
    options.ProblemTypeFactory = code => $"https://myapi.dev/errors/{code}";
    options.ValidationResponseFactory = errors => new
    {
        errors = errors.Select(e => new { e.Code, e.Message })
    };
    options.Pagination.Mode = PaginationMode.Header;       // header, body, or both
    options.Pagination.HeaderName = "X-Pagination";        // for paged results
});

Full clean-architecture sample

The repo ships a complete, runnable reference under examples/Ensuring.Sample:

Ensuring.Sample.Domain          DDD aggregates & value objects — Ensure instead of guards
Ensuring.Sample.Application     DTO validation, classic service + in-process mediator (CQRS)
Ensuring.Sample.Infrastructure  Repositories that return Results (Conflict / NotFound as errors)
Ensuring.Sample.Api             Controllers + Minimal APIs that just return results
dotnet run --project examples/Ensuring.Sample/Ensuring.Sample.Api

Then walk through Ensuring.Sample.Api.http: successful registration, a 400 carrying every violated rule at once, a 409 on duplicate email, 404s, paged listings with metadata, and an order with five simultaneous line errors — all produced by return result;.


FAQ

How is this different from a guard clause library (e.g. Ardalis.GuardClauses)? Guard clauses throw an exception on the first invalid input, and the exception carries no structure your API can use. Ensuring collects every violation into structured errors and returns them as a result — and when you do want to throw, .Throw() gives you classic guard behavior from the exact same rules.

How is this different from a result library (e.g. ErrorOr, Ardalis.Result, FluentResults)? Result libraries model outcomes but leave two problems open: how the errors get produced (you hand-write the ifs) and how they become HTTP responses (you write mapping code or bolt on another package). Ensuring owns the whole chain: 60+ validation rules produce the errors, and the ASP.NET Core package turns them into status codes + ProblemDetails automatically.

How is this different from FluentValidation? FluentValidation validates objects via separate validator classes and stops at a ValidationResult. Ensuring validates values inline where they're used (DTOs, domain factories, method arguments), returns first-class results your services can chain (Then, Match, ...), and carries them to the HTTP layer. No validator classes, no MediatR pipeline behaviors required — though you can use it inside them.

Do I need the ASP.NET Core package? No. Shamoradi.Ensuring is dependency-free and works in any .NET project — console apps, workers, class libraries, Blazor. Add Shamoradi.Ensuring.AspNetCore only when you want automatic HTTP mapping.

Does it work with Minimal APIs and FastEndpoints, or just MVC? All three, with the same "just return the result" experience: MVC gets a result filter (AddEnsureMvc), Minimal APIs get an endpoint filter (.AddEnsure() on a group), and FastEndpoints gets SendEnsureAsync(result) via the Shamoradi.Ensuring.AspNetCore.FastEndpoints package. One mapping pipeline drives all of them, so responses are identical regardless of framework.

Which .NET versions are supported? .NET 8 (LTS), .NET 9, and .NET 10. All packages multi-target all three.

Does it throw exceptions internally? Validation never throws for invalid values — that's the point. Exceptions are reserved for programmer errors (null rule builder, negative counts) and for the opt-in .Throw() terminal.


Project structure

Shamoradi.Ensuring                        Core: validation pipeline, rules, results, errors
Shamoradi.Ensuring.AspNetCore             MVC + Minimal API integration, ProblemDetails, options
Shamoradi.Ensuring.AspNetCore.FastEndpoints   FastEndpoints adapter (SendEnsureAsync / ToFastResult)
  • Targets .NET 8, 9, and 10.
  • Zero dependencies in the core package.
  • Nullable reference types enabled throughout; XML docs and symbol packages (.snupkg with embedded sources) ship with every release.
  • 1,750+ tests — every rule, every result combinator, and the full HTTP mapping pipeline (MVC filter, Minimal API endpoint filter, FastEndpoints adapter) — run against .NET 8 and .NET 10 on CI.

Contributing

Issues and pull requests are welcome!

  • Found a missing rule? Open an issue with the use case — rules are small, self-contained extension methods and easy to add.
  • Before a PR: dotnet build Ensure.sln and dotnet test tests/EnsureTests/EnsureTests.csproj must be green.

Philosophy

Failures are data, not control flow.

  1. One error model everywhere. The Error born in a domain rule is byte-for-byte the error your API serializes. Nothing is translated, wrapped, or lost in between.
  2. All errors, always. Users shouldn't fix one field per request. Pipelines collect every violation.
  3. The edge is boring. Controllers and endpoints contain zero decision logic — they return results, the framework speaks HTTP.
  4. Escape hatches, not lock-in. Want exceptions at a boundary? .Throw(). Want manual mapping? ToActionResult / ToResult. Want different status codes or response bodies? Replace one small service.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages