Not just guard clauses. Not just a result pattern. The entire failure-handling story of your application, unified — in MVC, Minimal APIs, and FastEndpoints.
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);If Shamoradi.Ensuring saves you from writing one more if (result.IsFailure) return BadRequest(...), a star helps other developers find it. Thanks!
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.
Same product, two coding styles — pick what fits the team. Controllers stay identical either way.
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);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.
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=10 → 200 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." }
]
}
}- Show me
- Getting started
- The validation pipeline
- Result types
- Functional operations
- PagedResult<T>
- ASP.NET Core integration
- Full clean-architecture sample
- FAQ
- Project structure
- Contributing
- License
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.
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).
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 errorsSame rules, same messages — Return(...) for expected failures, Throw() for exceptional ones.
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));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);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 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, …).
| 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> |
The full rule set works on every common collection shape — IEnumerable<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 |
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();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 naturallyThree 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.
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!));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();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));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}");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.
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 controllersWith 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.
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.
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.
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 statusAvailable: WithCreated, WithAccepted, WithStatusCode, WithLocation, WithETag, WithCacheControl, WithLastModified. They are no-ops on failed results, so chains stay safe.
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.
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
});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.ApiThen 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;.
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.
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 (
.snupkgwith 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.
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.slnanddotnet test tests/EnsureTests/EnsureTests.csprojmust be green.
Failures are data, not control flow.
- One error model everywhere. The
Errorborn in a domain rule is byte-for-byte the error your API serializes. Nothing is translated, wrapped, or lost in between. - All errors, always. Users shouldn't fix one field per request. Pipelines collect every violation.
- The edge is boring. Controllers and endpoints contain zero decision logic — they return results, the framework speaks HTTP.
- 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.
MIT