DEV Community

Pavel Kalandra
Pavel Kalandra

Posted on • Originally published at kalandra.tech

Zero-Code Validations in Your .NET API

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.

One validation rule written three times — data annotations on the DTO, a manual re-check in the controller, and guard clauses in the service. The value stays a plain string between layers, so no layer can trust the one before it.

Take this example endpoint that places an order. Validation is on three places:

  1. Data annotations validate the request DTO automatically and expose the OpenAPI json schema.
  2. The controller checks rules that can't be expressed in OpenAPI, so that we fail fast before any DB queries run.
  3. 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.");
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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.StrongTypes
Enter fullscreen mode Exit fullscreen mode

And 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);
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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 extension
Enter fullscreen mode Exit fullscreen mode

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

Two-panel diagram: without StrongTypes, every layer re-validates the same plain string; with StrongTypes, the value is parsed once at the JSON boundary and NonEmptyString carries the guarantee through controller, services, and database

Even the backend behind kalandra.tech 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.


Originally published at kalandra.tech.

Top comments (3)

Collapse
 
topstar_ai profile image
Luis

The idea of "parse, don't validate" resonates with me, as I've encountered similar issues with repeated validations in my own .NET projects. I appreciate how you've applied functional programming principles to create a solution with strong types, such as Positive<int> and Email, which can carry proof of validation throughout the code. This approach can significantly reduce bugs and make the code more maintainable. Have you considered integrating Kalicz.StrongTypes with popular validation frameworks, such as FluentValidation, to provide an even more comprehensive validation solution?

Collapse
 
kalicz profile image
Pavel Kalandra

Thanks for the question. But I don't think that's applicable. FluentValidation first creates an instance of something and then calls validate on it to verify.

My package prevents creation outright. So I can't say that I have found anything to integrate to.

Let me know if I misunderstood your comment

Collapse
 
topstar_ai profile image
Luis

The "parse, don't validate" idea is one of the most valuable concepts from functional programming that fits really well into application architecture. Validation often becomes scattered because we keep passing around primitive types that carry no information about the guarantees already checked.

I like the distinction between validation frameworks and strong types here. Tools like FluentValidation are great for expressing rules, but they still operate after the object exists. Strong types move the guarantee earlier by making invalid states impossible to represent.

One interesting area I’d be curious to see explored is how this approach handles evolving business rules. For example, if a rule changes from Positive<int> to something more domain-specific like OrderQuantity with inventory constraints, pricing rules, or customer-specific limits, would you recommend creating more domain-focused types rather than extending generic primitives?

The direction is compelling because the best documentation is often the code itself — a method signature that describes what values are allowed before execution even begins.