How to Validate Several Fields Together with IValidatableObject in a Minimal API
Attributes check one value at a time. When a rule involves more than that, such as "no product twice in one order", or "this number can be anything except 0", put it on the request itself with IValidatableObject. AddValidation calls it, and the error comes back in the same format as the others.
Prerequisites
- Validation turned on. How to Validate Minimal API Requests in .NET 10 with AddValidation. I used ASP.NET Core 10.
Step 1: Implement It
public sealed record PlaceOrderRequest(
[Required, StringLength(200)] string CustomerName,
[Required, EmailAddress, StringLength(254)] string CustomerEmail,
[Required, MinLength(1), MaxLength(50)] IReadOnlyList<PlaceOrderLine> Lines) : IValidatableObject
{
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// The order_lines table allows a product once per order.
if (Lines is not null && Lines.Select(l => l.ProductId).Distinct().Count() != Lines.Count)
{
yield return new ValidationResult(
"Each product can appear only once in an order.",
[nameof(Lines)]);
}
}
}

yield returnadds an error, and you can add several.- The second argument names the field, which becomes the key in the response.
- The null check is needed in case a client sends no list.
Step 2: Break the Rule
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Lines": [
"Each product can appear only once in an order."
]
},
"traceId": "00-a94e0e0740000cf4049369242667b451-2c623c09b41eaa14-00"
}

Step 3: Use It for One Field, Too
A range can't say "anything except 0", so a one-line rule goes here as well:
public sealed record AdjustStockRequest(
[Range(-1_000_000, 1_000_000)] int QuantityChange,
[Required, StringLength(200)] string Reason,
[StringLength(64)] string? Reference) : IValidatableObject
{
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (QuantityChange == 0)
{
yield return new ValidationResult(
"The field QuantityChange must not be 0.",
[nameof(QuantityChange)]);
}
}
}
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"QuantityChange": [
"The field QuantityChange must not be 0."
]
},
"traceId": "00-a5e22c0f3bacb5c8e84d42e722a4a381-e412768039e2fa39-00"
}
Step 4: Know the Order
Validate runs only after every attribute has passed. This request has an empty name and a duplicate product:
POST http://localhost:5190/api/v1/orders
Content-Type: application/json
{"customerName":"","customerEmail":"ana@example.com","lines":[{"productId":1,"quantity":1},{"productId":1,"quantity":1}]}
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"CustomerName": [
"The CustomerName field is required."
]
},
"traceId": "00-80f46b2ed7e1ca0ea3755c3a50fd6c9f-bc286bd2e749df9a-00"
}
Only the name is reported. So a client that fixes it can then get a second error, about the duplicate. That also means Validate can trust its attributes, apart from a null list that's [Required], where it's safer to check anyway.
Common Mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
Going to the database in Validate | Validation gets slow, and races anyway | Keep it about the request |
| Leaving out the member name | The error has an empty key | Pass [nameof(Lines)] |
| Expecting all errors at once | Attributes come first | Tell clients to expect two rounds |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Validate isn't called | An attribute failed, or AddValidation() is missing | Check both |
A NullReferenceException in Validate | A missing list | Check for null |
Conclusion
Implement IValidatableObject on the request for any rule that attributes can't express. It runs after the attributes pass, and its errors look like all the others.
Additional Resources
- IValidatableObject — the interface.
- Validation in minimal APIs —
AddValidation.
