Files
vigilcare-clinical/docs/guides/21-input-validation-fluentvalidation.md

13 KiB

Guide 21: Input Validation with FluentValidation

What is Input Validation?

Every time your API receives data from the outside world — a request body, a query parameter, a header — you need to check that the data is valid before processing it. Does the observation code exist? Is the value within a plausible range? Is the required field present? Without validation, invalid data can corrupt your database, crash your application, or produce nonsensical clinical alerts.

Input validation is the practice of checking incoming data at the system boundary (where your application meets the outside world) and rejecting anything that doesn't meet the rules. It's your first line of defense.

What is FluentValidation?

FluentValidation is a .NET library that lets you define validation rules using a readable, method-chain syntax (a "fluent" API). Instead of writing validation logic inside your controllers with lots of if statements, you create a dedicated validator class for each request type:

// Without FluentValidation — verbose, mixed with controller logic
if (string.IsNullOrEmpty(req.ObservationCode))
    return BadRequest("ObservationCode is required");
if (req.ObservationCode.Length > 50)
    return BadRequest("ObservationCode must be 50 chars or fewer");
if (req.RecordedAt > DateTimeOffset.UtcNow.AddMinutes(5))
    return BadRequest("RecordedAt cannot be in the future");

// With FluentValidation — clean, separate class
public class IngestObservationRequestValidator
    : AbstractValidator<IngestObservationRequest>
{
    public IngestObservationRequestValidator()
    {
        RuleFor(x => x.ObservationCode).NotEmpty().MaximumLength(50);
        RuleFor(x => x.Unit).NotEmpty().MaximumLength(20);
        RuleFor(x => x.RecordedAt)
            .LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5))
            .WithMessage("RecordedAt cannot be more than 5 minutes in the future.");
    }
}

The FluentValidation version is more readable, testable (you can unit test the validator), and separated from the controller logic.


How FluentValidation Works

HTTP Request arrives
        │
        ▼
┌───────────────┐   model binding     ┌──────────────────┐
│ ASP.NET Core  │ ──────────────────► │ Request DTO      │
│ deserializes  │   (JSON → C# obj)   │ (e.g. Ingest     │
│ request body  │                     │  ObservationReq) │
└───────────────┘                     └────────┬─────────┘
                                               │
                                               ▼
                                      ┌──────────────────┐
                                      │ FluentValidation  │
                                      │ auto-validates    │
                                      │ (finds matching   │
                                      │  validator class) │
                                      └────────┬─────────┘
                                               │
                               ┌───────────────┴──────────────┐
                               │                              │
                           Valid?                         Invalid?
                               │                              │
                               ▼                              ▼
                      ┌────────────────┐            ┌─────────────────┐
                      │ Controller     │            │ 400 Bad Request  │
                      │ action runs    │            │ with field-level │
                      │                │            │ error details    │
                      └────────────────┘            └─────────────────┘

Setup

NuGet Package

FluentValidation is already referenced through the ASP.NET Core integration package. No explicit package reference is needed if you're using the meta-package.

Registration in Program.cs

builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

What do these two lines do?

  1. AddFluentValidationAutoValidation(): Hooks FluentValidation into ASP.NET Core's model binding pipeline. When a request arrives, ASP.NET automatically finds the matching validator and runs it before your controller action executes. If validation fails, the action never runs — a 400 Bad Request response is returned immediately.

  2. AddValidatorsFromAssemblyContaining<Program>(): Scans the assembly (the compiled project) for all classes that extend AbstractValidator<T> and registers them with dependency injection. This means you don't need to register each validator manually — just create a new validator class and it's automatically discovered.

Custom Error Response

When validation fails, ASP.NET Core calls the InvalidModelStateResponseFactory to build the error response:

builder.Services.Configure<ApiBehaviorOptions>(options =>
{
    options.InvalidModelStateResponseFactory = context =>
    {
        var errors = context.ModelState
            .Where(e => e.Value!.Errors.Count > 0)
            .Select(e => new { field = e.Key, errors = e.Value!.Errors.Select(x => x.ErrorMessage) })
            .ToList();

        return new BadRequestObjectResult(
            ApiResponse<object>.Fail(400, "Validation failed.", "VALIDATION_ERROR"));
    };
});

This ensures validation errors use the same ApiResponse<T> envelope as all other API responses.


Writing Validators

Basic Rules

public class IngestObservationRequestValidator
    : AbstractValidator<IngestObservationRequest>
{
    public IngestObservationRequestValidator()
    {
        RuleFor(x => x.ObservationCode).NotEmpty().MaximumLength(50);
        RuleFor(x => x.Unit).NotEmpty().MaximumLength(20);
        RuleFor(x => x.RecordedAt)
            .LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5))
            .WithMessage("RecordedAt cannot be more than 5 minutes in the future.");
    }
}

What does each rule do?

Rule What It Checks Error If
.NotEmpty() String is not null, not empty, not whitespace "", null, " "
.MaximumLength(50) String is at most 50 characters "A" * 51
.GreaterThan(0) Number is positive 0, -1
.LessThanOrEqualTo(...) Value is at or before a limit Timestamp in the future
.WithMessage(...) Custom error message (default messages are generic)
.When(...) Only apply this rule if a condition is true

Cross-Field Validation

Sometimes a rule depends on multiple fields. Alert thresholds must be in order: CriticalLow < WarningLow < WarningHigh < CriticalHigh:

public class AlertThresholdRequestValidator
    : AbstractValidator<AlertThresholdRequest>
{
    public AlertThresholdRequestValidator()
    {
        RuleFor(x => x.ObservationCode).NotEmpty().MaximumLength(50);
        RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(200);
        RuleFor(x => x.Unit).NotEmpty().MaximumLength(20);

        // Cross-field: threshold ordering
        RuleFor(x => x)
            .Must(x => !x.CriticalLow.HasValue || !x.WarningLow.HasValue
                    || x.CriticalLow < x.WarningLow)
            .WithMessage("CriticalLow must be less than WarningLow.");

        RuleFor(x => x)
            .Must(x => !x.WarningLow.HasValue || !x.WarningHigh.HasValue
                    || x.WarningLow < x.WarningHigh)
            .WithMessage("WarningLow must be less than WarningHigh.");

        RuleFor(x => x)
            .Must(x => !x.WarningHigh.HasValue || !x.CriticalHigh.HasValue
                    || x.WarningHigh < x.CriticalHigh)
            .WithMessage("WarningHigh must be less than CriticalHigh.");
    }
}

The .Must() rule takes a lambda that returns true (valid) or false (invalid). Using RuleFor(x => x) applies the rule to the entire object rather than a single property, which is necessary for cross-field checks.

The !x.CriticalLow.HasValue || guard makes the comparison optional — if a bound is null, the ordering check is skipped.

Conditional Rules

Sometimes a rule only applies if another field has a value:

public class CreateMedicationAdministrationRequestValidator
    : AbstractValidator<CreateMedicationAdministrationRequest>
{
    public CreateMedicationAdministrationRequestValidator()
    {
        RuleFor(x => x.DrugName).NotEmpty().MaximumLength(200);
        RuleFor(x => x.Dose).GreaterThan(0);
        RuleFor(x => x.DoseUnit).NotEmpty().MaximumLength(20);
        RuleFor(x => x.Route).NotEmpty().MaximumLength(50);
        RuleFor(x => x.AdministeredBy).NotEmpty().MaximumLength(100);

        RuleFor(x => x.AdministeredAt)
            .LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5))
            .When(x => x.AdministeredAt.HasValue)
            .WithMessage("AdministeredAt cannot be in the future.");
    }
}

.When(x => x.AdministeredAt.HasValue) means: "only check if AdministeredAt is in the future when it's actually provided." If AdministeredAt is null (meaning 'use current time'), the rule is skipped entirely.


Validators in This Project

Validator Request Type Key Rules
IngestObservationRequestValidator IngestObservationRequest Code required, unit required, timestamp not in future
AlertThresholdRequestValidator AlertThresholdRequest Code+name+unit required, threshold ordering (CritLow < WarnLow < WarnHigh < CritHigh)
LoginRequestValidator LoginRequest Username and password required
CreateMedicationAdministrationRequestValidator CreateMedicationAdministrationRequest Drug name, dose > 0, unit, route, administered-by required
TransitionStatusRequestValidator TransitionStatusRequest New status required
TransitionOrderStatusRequestValidator TransitionOrderStatusRequest New status required
AcknowledgeAlertRequestValidator AcknowledgeAlertRequest Acknowledged-by required
CreateOrderRequestValidator CreateOrderRequest Description, type required
RecordOrderResultRequestValidator RecordOrderResultRequest Result summary required
UpdatePatientRequestValidator UpdatePatientRequest Name length limits
CreateSiteRequestValidator CreateSiteRequest Site code and name required
RegisterGatewayRequestValidator RegisterGatewayRequest Gateway code, site ID, department required
GatewayHeartbeatRequestValidator GatewayHeartbeatRequest Buffer depth >= 0
SubmitAlertFeedbackRequestValidator SubmitAlertFeedbackRequest Rating required, valid rating values
FhirPatientUpsertRequestValidator FhirPatientUpsertRequest First name, last name, gender required
FhirEncounterUpsertRequestValidator FhirEncounterUpsertRequest Patient ID, department required

Validation vs Domain Rules

FluentValidation handles input validation — basic format and constraint checks at the API boundary. More complex business rules are enforced in the service layer:

Check Where Example
"Is the field present?" Validator RuleFor(x => x.DrugName).NotEmpty()
"Is the value in the right range?" Validator RuleFor(x => x.Dose).GreaterThan(0)
"Is the timestamp format valid?" Validator RuleFor(x => x.RecordedAt).LessThanOrEqualTo(...)
"Does the encounter exist?" Service layer _db.Encounters.FindAsync(encounterId)
"Is the encounter active?" Service layer if (encounter.Status != Active) throw ...
"Is the observation code plausible?" Service layer PlausibilityValidator.IsPlausible(code, value)
"Would this create a duplicate?" Database Partial unique index on idempotency_key

The rule of thumb: validators check format (shape, size, type). Services check meaning (does this entity exist? is this transition valid?). The database checks integrity (uniqueness, foreign keys, check constraints). Together, these three layers form a defense-in-depth strategy.


Key Takeaways

  • Validate at the boundary — check input as soon as it enters your system, before any database queries or business logic run
  • Separate validators from controllers — each request type gets its own validator class, keeping controllers clean and validators testable
  • Auto-validation means zero boilerplateAddFluentValidationAutoValidation() + AddValidatorsFromAssemblyContaining<>() makes validation automatic. Create a validator class; it's discovered and applied without any additional wiring.
  • Cross-field rules use .Must() — when a rule depends on multiple fields (like threshold ordering), apply the rule to the entire object
  • Conditional rules use .When() — skip a rule when the field is null or when a condition isn't met
  • Validators are not the only defense — they catch format issues. Service-layer logic catches business rule violations. Database constraints catch integrity violations. All three layers matter.