Zero-Code Validations in Your .NET API
· 5 min read · Updated
- dotnet
- csharp
- strong-types
- type-safety
- api-design
- validations
On one of my previous projects, I noticed an issue. It was validations. Specifically, how many times I had to write the same rule down.
Take this example endpoint that places an order. Validation is on three places:
- Data annotations validate the request DTO automatically and expose the OpenAPI json schema.
- The controller checks rules that can't be expressed in OpenAPI, so that we fail fast before any DB queries run.
- The service with business logic has to check again, because you could call it from a different place. For example in a background job after fetching some entity from the database.
Three different places and you can easily introduce bugs just by forgetting to update one of them.
public record CreateOrderRequest(
[EmailAddress] string CustomerEmail,
string ProductCode,
[Range(1, int.MaxValue)] int Quantity);
[HttpPost]
[ProducesResponseType<OrderResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<OrderResponse>> Create(CreateOrderRequest request)
{
// We need to parse, because data annotations don't change the type.
if (!MailAddress.TryCreate(request.CustomerEmail, out _))
return ValidationProblem("Customer email is not valid.");
try
{
var order = await orders.PlaceAsync(request.CustomerEmail, request.ProductCode, request.Quantity);
return Ok(OrderResponse.From(order));
}
catch (UnknownProductException) // Could be checked explicitly via a separate method
{
return ValidationProblem("No product matches that code.");
}
catch (OutOfStockException)
{
return ValidationProblem("Not enough stock to fill the order.");
}
}Data annotations validate the email address, but you still only have a string. Similarly, the integer is validated to be positive, but you can't actually use that guarantee. Then we reach the service:
public async Task<Order> PlaceAsync(string customerEmail, string productCode, int quantity)
{
// Must be checked. Even though the controller already checked all this.
if (string.IsNullOrWhiteSpace(productCode))
throw new ArgumentException("Product code is required.", nameof(productCode));
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
if (!await catalog.ContainsAsync(productCode))
throw new UnknownProductException(productCode);
// The real work here.
}PlaceAsync is ultimately responsible for making sure everything works. It can be called from the controller, but also from a background worker or another endpoint. So it has to validate again.
Parse, don't validate
The solution is an old functional-programming idea: instead of checking a value and passing the same loose type along, convert it once into a type that cannot represent the invalid state. An int that just passed a > 0 check is still an int. The proof is discarded the moment the check succeeds, so the next method down has to check all over again. A Positive<int> carries that proof in the type itself, and the compiler enforces it everywhere downstream.
That's what I built Kalicz.StrongTypes package for. It's a small set of C# types. For example NonEmptyString, Email, NonEmptyEnumerable<T>, a few numerical types such as Positive<T>, NonNegative<T> and other useful types and helpers. Result<T, TError> is another super useful type that can represent a success or error state of your operations. And there's also a Maybe<T> that can help you represent 3 states in your API. Useful for example when you want to differentiate between updating a value to something, updating a value to nothing and not updating a value.
I haven't mentioned all the things inside the library here, if you're interested, go check it out here. I'll also keep extending the library. For example, Interval<T> is coming to make it easier to represent a start + end range, validating that the end comes after the start. I'll write a separate blog post about that one.
The zero-code part
Just one line to install:
dotnet add package Kalicz.StrongTypesAnd you can go ahead and start using the types. For example like this:
public record CreateOrderRequest(
Email CustomerEmail,
NonEmptyString ProductCode,
Positive<int> Quantity);
[HttpPost]
[ProducesResponseType<OrderResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<OrderResponse>> Create(CreateOrderRequest request)
{
// No validations needed here.
var result = await orders.PlaceAsync(request.CustomerEmail, request.ProductCode, request.Quantity);
if (result.Error is { } error)
return error switch
{
PlaceOrderError.UnknownProduct => FieldProblem(nameof(request.ProductCode), "No product matches that code."),
PlaceOrderError.OutOfStock => FieldProblem(nameof(request.Quantity), "Not enough stock to fill the order."),
};
return Ok(OrderResponse.From(result.Success!));
}
private ActionResult FieldProblem(string field, string message)
{
ModelState.AddModelError(field, message);
return ValidationProblem(ModelState);
}Data annotations are gone. Mapping to MailAddress is gone. All the information about the values now lives in the types. And because the method returns a result instead of throwing, the compiler knows exactly which errors can happen.
All the types serialize to and from JSON out of the box. And the OpenAPI schema is also properly updated. Simply add the Kalicz.StrongTypes.OpenApi.Swashbuckle package (or the Microsoft.AspNetCore.OpenApi variant) and register it:
// Program.cs - install Kalicz.StrongTypes.OpenApi.Swashbuckle, then one call
builder.Services.AddSwaggerGen(options => options.AddStrongTypes());Then the request above emits exactly the constraints a client needs:
{
"customerEmail": { "type": "string", "format": "email", "minLength": 1, "maxLength": 254 }, // Email
"productCode": { "type": "string", "minLength": 1 }, // NonEmptyString
"quantity": { "type": "integer", "format": "int32", "minimum": 0, "exclusiveMinimum": true } // Positive<int>
}Signatures become documentation
The nicest part is how it makes your code much easier to understand. Every method gets to state its real requirements:
// Before: every caller re-checks, every reader wonders.
Task<Order> PlaceAsync(string email, string code, int quantity);
// After: the signature is the contract.
Task<Result<Order, PlaceOrderError>> PlaceAsync(Email email, NonEmptyString code, Positive<int> quantity);The second signature answers questions the first one forces you to research: the code can't be empty, quantity can't be zero. Reviewers stop asking "what if the quantity is minus three?" because the question no longer type-checks.
Business rules that can legitimately fail get the same treatment with Result<T, TError>. The failure becomes a value in the signature instead of a surprise exception:
But most importantly, when you read this code in 5 months, you don't need to remember the context. You don't need to get familiar with where the values are coming from and what they could contain. Also new hires don't need to ask you questions. The code simply describes what is going on very precisely. Adding clarity and context.
public async Task<Result<Order, PlaceOrderError>> PlaceAsync(Email email, NonEmptyString code, Positive<int> quantity)
{
if (!await catalog.ContainsAsync(code))
return PlaceOrderError.UnknownProduct;
if (!await inventory.HasStockAsync(code, quantity))
return PlaceOrderError.OutOfStock;
return new Order(email, code, quantity);
}The types reach the database
When you load an entity into context via EF core, you'd have to validate again. But with the Kalicz.StrongTypes.EfCore package, the strong types become entity properties directly. One call on the options builder attaches a value converter to each type:
public sealed class Order
{
public Guid Id { get; init; }
public Email CustomerEmail { get; init; }
public NonEmptyString ProductCode { get; init; }
public Positive<int> Quantity { get; init; }
}
// Program.cs - StrongTypes registration for db context.
services.AddDbContext<ShopDbContext>(options => options
.UseNpgsql(connectionString)
.UseStrongTypes());The DB column is still a plain varchar or int, nothing changes about the storage. But EF Core now doesn't allow storing invalid values. And if you try to load invalid values from the DB, you get a loud crash instead of a silent null. So the parsed information is kept forever. You can load the entity and call PlaceAsync safely.
Fewer edge cases, fewer tests
The number of unit tests needed has dropped massively. The edge cases are simply not possible anymore. You can't pass null, "", or " " as a NonEmptyString. You can't pass -1 into quantity. What remains are tests about behavior. The ones that make sense. And the API for constructing these types is very explicit about intent:
NonEmptyString name = NonEmptyString.Create(input); // throws on invalid
NonEmptyString? safe = NonEmptyString.TryCreate(input); // null on invalid
NonEmptyString? fluent = input.AsNonEmpty(); // same, as an extensionAnd for the properties you still want to verify, property-based testing lets you define one test method and run hundreds of different values through it. The Kalicz.StrongTypes.FsCheck package generates valid strong-typed values for property-based tests, so your generators respect the same invariants your code does. Let me know in a comment if you'd like a separate blog post about property based testing.
Try it
The whole shift in one picture. Validation repeated at every layer versus parsed once at the boundary:
Even the backend behind this very site runs on StrongTypes. Request DTOs, domain events, entity state. If the approach appeals to you, the StrongTypes page has diagrams and more examples, the source lives on GitHub, and the package is on NuGet. Start with one request record. Delete the guard clauses that become redundant. You'll know within an afternoon that you want it everywhere.