Before you reach the meat of your code, before you reach the business rules, before you get to the domain logic, it all starts with the request. Data or a command arrive at an endpoint. Before you do anything else, you need to ensure that request is valid. FastEndpoints makes it easy to do request validation.

Every FastEndpoints tutorial shows you the same validation example. You inherit Validator<TRequest>, add a couple of RuleFor lines, and get your 400 response. Boom, done.

But then you try to ship real software and the questions start to pile up. How do I inject my repository? How do I customize my responses instead of using FastEndpoints’ default? How do I implement something more complex?

This post is about the stuff that comes after the quickstart tutorials. Writing a basic validator is easy. What if I want to do something more?

We’ll stick with one running example all the way through the post. It’s a RegisterCharacterRequest endpoint for a Dungeons & Dragons campaign tool.

The Pipeline

It’s important to understand where request validation sits relative to the other parts of the request pipeline, such as model binding, pre-processors, and the handler. We need to understand what actually happens when a request comes in to the endpoint.

FastEndpoints leans heavily on FluentValidation for its validation workflow. A lot of that work is internal. You don’t need to install FluentValidation separately and you don’t need to register your validators. They get discovered automatically. Your Validator<TRequest> is a thin layer that overlays FluentValidation’s own AbstractValidator<TRequest>. So if you’re already familiar with that, all of that knowledge still applies.

Here’s our example request object and validator:

public class RegisterCharacterRequest
{
    public string CampaignId { get; set; }
    public string Name { get; set; }
    public string PlayerEmail { get; set; }
    public string Class { get; set; }
    public AbilityScores Scores { get; set; }
}

public class RegisterCharacterValidator : Validator<RegisterCharacterRequest>
{
    public RegisterCharacterValidator()
    {
        RuleFor(x => x.CampaignId).NotEmpty();
        RuleFor(x => x.Name).NotEmpty();
        RuleFor(x => x.PlayerEmail).NotEmpty().EmailAddress();
        RuleFor(x => x.Class).NotEmpty();
    }
}

The flow of operations for a request is bind -> validate -> handle. The binding piece gets handled first as the incoming data payload gets mapped to your request object, query, body, and headers. If that completes successfully, next comes the validation against the mapped DTO. By default, if that validation occurs, the flow gets short-circuited, the handler is never called, and FastEndpoints throws a ValidationFailureException and returns a 400 error to the caller. And all of that just happens out of the box with FastEndpoints. You don’t have to write any additional code to make that happen.

As mentioned, you don’t register your validations yourself. When the AddFastEndpoints() call happens at startup, your code is scanned for any validators and they get wired up automatically. FastEndpoints will also honor System.ComponentModel.DataAnnotations attributes as a fallback — if no Validator<TRequest> is found for a request type, annotations on the request class are the next tier.

One critical gotcha to remember is that you may only have one validator per request type. If you have more than one, the system will throw an exception at startup. So you need to declare what the validator for that request is. You can do this in your Configure function for that request.

public override void Configure()
{
    Post("/characters");
    Validator<RegisterCharacterValidator>(); // pick this one
}

By explicitly declaring it in this manner you can have multiple validators of the same type, but use different ones for different request endpoints.

Another gotcha to remember is that FastEndpoints will not directly use any FluentValidation AbstractValidator<T> validators you might have in your code. You can use them, but you must tell FluentValidation to add them to its collection.

bld.Services.AddFastEndpoints(o => o.IncludeAbstractValidators = true);

The Default Error

Out of the box, FastEndpoints will give you a failure message in this format:

{
  "statusCode": 400,
  "message": "One or more errors occurred!",
  "errors": {
    "playerEmail": ["'Player Email' is not a valid email address."],
    "class": ["'Class' must not be empty."]
  }
}

The errors field is a dictionary with properties as keys and an array of error messages as the values for each. The casing and naming of the keys will follow your app’s property naming policy and will match how your DTOs get serialized.

It’s fine and is a good start, but we’ll circle back and learn about how to change that up a bit later.

Sometimes A Validator Isn’t Enough

There are some validation rules you just can’t do in a validator. Asking whether an email address is valid is a validator question. Asking whether there is already a character with that name in the party is a data question. It doesn’t belong in a constructor-built validator. For something like that you have to add failures in the handler:

public class RegisterCharacterEndpoint
    : Endpoint<RegisterCharacterRequest, RegisterCharacterResponse>
{
    private readonly ICampaignRepository _campaigns;

    public RegisterCharacterEndpoint(ICampaignRepository campaigns)
        => _campaigns = campaigns;

    public override void Configure()
    {
        Post("/characters");
    }

    public override async Task HandleAsync(
        RegisterCharacterRequest req, CancellationToken ct)
    {
        if (await _campaigns.HasCharacterNamed(req.CampaignId, req.Name, ct))
            AddError(r => r.Name, "A character with that name is already in this campaign.");

        if (await _campaigns.PartyIsFull(req.CampaignId, ct))
            AddError(r => r.CampaignId, "This campaign's party is already full.");

        ThrowIfAnyErrors(); // bail with a 400 if anything got added above

        var id = await _campaigns.AddCharacter(req, ct);
        await Send.OkAsync(new RegisterCharacterResponse { CharacterId = id });
    }
}

The AddError call notes a failure against a property, but it doesn’t throw an exception just yet. This lets you collect multiple errors and get the full array of problems before sending the failure back to the caller. How many times have you called an endpoint, gotten an error, and fixed that error, only to get a different error when you call again with your updated payload? You can assemble the full list of problems for the caller instead of forcing them to play whack-a-mole one request at a time.

Once you’ve collected the full suite of problems, then you call ThrowIfAnyErrors() to throw the exception and send that 400 error back to the caller. Your clients will appreciate this approach so much more.

When it makes more sense to send an immediate failure, you can call ThrowError() instead of AddError().

Let Them Fight

Normally, a failed validator means that the handler will not get called. But there are times when you may want the handler to be called regardless. For example, if you want to accumulate both the validator errors and business logic errors into a single response (see the above note about kindness). In these situations, you can make use of DontThrowIfValidationFails(). Again, you would need to use the ThrowIfAnyErrors() call to handle it properly after.

public override void Configure()
{
    Post("/characters");
    DontThrowIfValidationFails();
}

public override async Task HandleAsync(
    RegisterCharacterRequest req, CancellationToken ct)
{
    // Validator errors are already in ValidationFailures, but no 400 was sent yet.
    // Run business-rule checks unconditionally so all errors accumulate together.
    if (await _campaigns.HasCharacterNamed(req.CampaignId, req.Name, ct))
        AddError(r => r.Name, "A character with that name is already in this campaign.");

    ThrowIfAnyErrors(); // now ship the whole pile at once

    // ... happy path
}

ValidationFailed is a bool value that reports whether or not any failures exist at that point. The current list of failures exists in the ValidationFailures variable, which is of type List<ValidationFailure>. You can also manually add to ValidationFailures if you want to add your own custom errors to the pile.

Let’s Get Deep

There is one thing about validators in FastEndpoints that really trips up a LOT of people: validators in FastEndpoints are singletons.

Let’s shout it out one more time for the people in the back:

Validators in FastEndpoints are singletons

They are a single instance, re-used for every request. This is important. What does that mean?

  1. There’s no mutable state in a validator. There are no per-request fields.
  2. You can’t inject scoped services. If you try to inject something like your DbContext, it will throw.

Since most dependencies are scoped, you have to resolve them inside of the rule instead, where you can grab the current request’s scope:

public class RegisterCharacterValidator : Validator<RegisterCharacterRequest>
{
    public RegisterCharacterValidator()
    {
        RuleFor(x => x.CampaignId)
            .MustAsync(async (campaignId, ct) =>
            {
                var campaigns = Resolve<ICampaignRepository>(); // scoped, per request
                return await campaigns.Exists(campaignId, ct);
            })
            .WithMessage("That campaign does not exist.");
    }
}

The Resolve<T> inside of a rule uses the same scope as the current HTTP request. You can’t do it inside of the constructor without spinning up your own scope instance with CreateScope(), which is far more hassle than it’s worth. If you find yourself thinking about doing that, stop and ask yourself if you really should be doing that check there. Then don’t do it. Put it in the handler instead.

Watch Out for N+1

Here’s another gotcha. Remember that each async task is a round trip. Dropping one inside a collection rule that covers a big list of data is a great way to drive your data store into meltdown. It’s an N+1 situation you don’t want to inflict on your infrastructure. Check the cheap synchronous stuff first, then use Cascade(CascadeMode.Stop) to prevent firing off database queries against data you already know is garbage:

RuleFor(x => x.CampaignId)
    .Cascade(CascadeMode.Stop)
    .NotEmpty()
    .MustAsync(async (campaignId, ct) =>
        await Resolve<ICampaignRepository>().Exists(campaignId, ct))
    .WithMessage("That campaign does not exist.");

The NotEmpty fails fast, and the database call only fires in this case if there’s actually a campaign ID to look up.

Conditional Validation

It’s easy to design validations that only trigger in certain situations using When and Unless rules. For instance, you need to know if your wizard’s intelligence score is actually high enough to cast spells worth casting.

When(x => x.Class == "Wizard", () =>
{
    RuleFor(x => x.Scores.Intelligence)
        .GreaterThanOrEqualTo(13)
        .WithMessage("Wizards need an Intelligence of at least 13 to cast spells.");
});

The FluentValidation RuleSet exists for slicing rules up further when one request model is reused across multiple endpoints with different needs. That can be handy for some use cases. However, if you find yourself reaching for these kinds of modifications frequently, it might be a good use case for splitting up the requests into different request models and endpoints.

Re-using and Composing Validators

As with many things in code, there are times when refactoring duplicated flows into a single function makes sense. And the Include functionality makes it easy to create shared rules for FastEndpoint validators.

public class AbilityScoresValidator : Validator<AbilityScores>
{
    public AbilityScoresValidator()
    {
        RuleFor(x => x.Strength).InclusiveBetween(3, 18);
        RuleFor(x => x.Dexterity).InclusiveBetween(3, 18);
        RuleFor(x => x.Constitution).InclusiveBetween(3, 18);
        RuleFor(x => x.Intelligence).InclusiveBetween(3, 18);
        RuleFor(x => x.Wisdom).InclusiveBetween(3, 18);
        RuleFor(x => x.Charisma).InclusiveBetween(3, 18);
    }
}

public class RegisterCharacterValidator : Validator<RegisterCharacterRequest>
{
    public RegisterCharacterValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(50);
        RuleFor(x => x.PlayerEmail).NotEmpty().EmailAddress();

        RuleFor(x => x.Scores)
            .Cascade(CascadeMode.Stop)
            .NotNull()
            .SetValidator(new AbilityScoresValidator());
    }
}

For our example, ability scores are great “is this well-formed” validation: a starting score outside of 3-18 isn’t a business rule problem, it’s just not a legal character creation.

Customizing the Response

For a professional API, you almost always want to have a consistent error envelope. There is a defined standard called Problem Details. But regardless of what format you want your response to take, FastEndpoints provides a global ResponseBuilder that makes it easy for you to define it once:

app.UseFastEndpoints(c =>
{
    c.Errors.ResponseBuilder = (failures, ctx, statusCode) =>
    {
        return new ValidationProblemDetails(
            failures
                .GroupBy(f => f.PropertyName)
                .ToDictionary(
                    g => g.Key,
                    g => g.Select(f => f.ErrorMessage).ToArray()))
        {
            Type = "https://www.rfc-editor.org/rfc/rfc9457",
            Title = "One or more validation errors occurred.",
            Status = statusCode,
            Instance = ctx.Request.Path,
            Extensions = { { "traceId", ctx.TraceIdentifier } }
        };
    };
});

As Ronco liked to say: “Set it! And forget it!”

The builder gets the failures, the HttpContext, and the status code, then serializes it and sends it. Just don’t forget: if you supply your own builder like this, tell FastEndpoints what type it returns so the OpenAPI “produces 400” metadata isn’t lying about your API:

app.UseFastEndpoints(c =>
{
    c.Errors.ProducesMetadataType = typeof(ValidationProblemDetails);
});

Testing

A validator is just a class, so unit testing works the same as any other class. And isn’t that just what you want to see? You can use FluentValidation’s TestValidate helpers, along with xUnit and Shouldly, among others:

public class RegisterCharacterValidatorTests
{
    private readonly RegisterCharacterValidator _validator = new();

    [Fact]
    public void Fails_when_player_email_is_missing()
    {
        var request = new RegisterCharacterRequest
        {
            CampaignId = "CMP-1",
            Name = "Tordek",
            PlayerEmail = "",
            Class = "Fighter",
            Scores = StandardArray()
        };

        var result = _validator.TestValidate(request);

        result.ShouldHaveValidationErrorFor(x => x.PlayerEmail);
    }

    [Fact]
    public void Passes_for_a_well_formed_request()
    {
        var request = new RegisterCharacterRequest
        {
            CampaignId = "CMP-1",
            Name = "Tordek",
            PlayerEmail = "player@example.com",
            Class = "Fighter",
            Scores = StandardArray()
        };

        var result = _validator.TestValidate(request);

        result.ShouldNotHaveAnyValidationErrors();
    }

    // 5e standard array, just so the fixture is a legal sheet
    private static AbilityScores StandardArray() => new()
    {
        Strength = 15, Dexterity = 14, Constitution = 13,
        Intelligence = 12, Wisdom = 10, Charisma = 8
    };
}

You can see here tests that check both the failures and successes. ShouldHaveValidationErrorFor helps you verify a specific property so the test fails for the right reason and not by accident.

One gotcha worth noting: If your validator makes use of Resolve<T>, it does make testing more difficult. And this is a frequent source of frustration for FastEndpoint users. Resolve<T> reaches into FastEndpoint’s service resolver, so it’s not something you can easily hand a fake to as you would with a lot of testing approaches.

Constructor injection is easier to test — you just pass the mock in directly:

var campaigns = Substitute.For<ICampaignRepository>();
campaigns.Exists("CMP-1", Arg.Any<CancellationToken>()).Returns(false);

var validator = new RegisterCharacterValidator(campaigns);
var result = await validator.TestValidateAsync(request);

result.ShouldHaveValidationErrorFor(x => x.CampaignId);

The catch — and this is why the earlier section steered you toward Resolve<T> — is that validators are singletons, so constructor-injected scoped services get captured for the lifetime of the app. That causes stale data and threading problems in production. The constructor approach is safe for testing because you’re supplying the dependency directly and there’s no DI container involved. In production, stick with Resolve<T> for scoped services.

There really isn’t a clean answer. Constructor injection is testable but dangerous in production for scoped dependencies. Resolve<T> is production-safe but painful to test. Honestly, for validators that call Resolve<T>, I skip the unit tests and run full integration tests against a real instance instead. It’s just easier on the brain.

Which Validation Is Which?

We use the term validation a lot, in different aspects of our software. So maybe some clarification will help.

Request Validation

This asks the question: “Is this input well-formed”? Is an email field actually email? Are these ability scores in a valid range? Are all my required fields present? That’s what FastEndpoints validators do. They guard the door, so to speak, and they’re only concerned with the request and the data in it.

Domain Validation

Once the request gets past the door, this asks the question: Is this operation allowed given the current state of the system? Does this campaign exist? Is the party already full? Is there already a character with this name? Those are all business rules. They need data or defined workflows to test against. They belong in the deeper layers of the system, in the application or domain layers. These are not questions that you should be using FastEndpoints validators to answer.

It can be a fuzzy line between the two, and sometimes it’s difficult to tell where you should be validating what. But the cleaner you keep that line, the more your validators stay the fast, lightweight “bouncer at the door” they are intended to be and the more your business rules will stay testable in the layer that owns them.

A good rule of thumb:

If the test requires the database, aggregates, or other cross-field business meaning, then it belongs in the domain & application layers. If it’s purely about the request being well-formed, then it belongs in a validator.

Conclusion

Remember the mental model that drives all of this: bind first, validate second, handle third. You can override that flow with things like DontThrowIfValidationFails, or manually add to it with AddError and ThrowError. Keep your validators stateless where possible, and singleton-aware. And remember that injecting at either the constructor or via Resolve<T> each have their own advantages and drawbacks when it comes to testing.

Keep those things in mind and validation stops being plumbing you wrestle with and turns it into a clean boundary around your endpoints, which is the whole point of the exercise. So go forth and let FastEndpoints validators be the bouncer at your API’s door.