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?
-
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. -
AddValidatorsFromAssemblyContaining<Program>(): Scans the assembly (the compiled project) for all classes that extendAbstractValidator<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 boilerplate —
AddFluentValidationAutoValidation()+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.