Your agent's minimal API accepts anything: turn on .NET 10 validation and test the 400s
Annotating a request record does nothing until AddValidation is called, and nothing tells you so. Four tests catch it, in about 15 minutes.

Ask an agent for a POST /orders endpoint in ASP.NET Core and the handler it writes will compile, run and return 201 for a body with no customer, a quantity of minus five and a misspelt field. Nothing in the request type says otherwise, so nothing pushes back. The agent has no reason to add checks the prompt never asked for, and the happy-path test it writes alongside passes.
.NET 10 has built-in validation for minimal APIs, and it’s close to free: data annotations on the request types plus one line in Program.cs. The catch is in that second half. Annotate the types and forget the line, and every attribute is silently ignored. The code looks protected. It isn’t.
This how-to builds the endpoint, writes the tests that prove the 400s, and shows the one-line difference between failing and passing. It works with any coding agent that can read files and run dotnet test, because the instruction, the tests and the check all live in the repo.
The endpoint an agent writes first
Here’s the shape a first draft usually takes (.NET 10, net10.0, the web template):
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/orders", (CreateOrder order) =>
TypedResults.Created($"/orders/{Guid.NewGuid()}", order));
app.Run();
public record OrderLine(string Sku, int Quantity);
public record CreateOrder(string CustomerId, List<OrderLine> Lines);
public partial class Program;
The empty partial class Program is there so WebApplicationFactory<Program> can see the entry point from a test project. Nothing about the request records rejects bad input. Nullable annotations don’t help either: a missing customerId binds as null into a non-nullable string, and the handler runs anyway.
Write the failing tests first
The test project needs xUnit and Microsoft.AspNetCore.Mvc.Testing (10.0.14 restored here, alongside xunit 2.9.3). These four tests describe what the endpoint should do:
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
public class OrderValidationTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task Valid_order_is_created()
{
var response = await _client.PostAsJsonAsync("/orders", new
{
customerId = "c-1",
lines = new[] { new { sku = "ABC-1", quantity = 2 } }
});
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
[Fact]
public async Task Missing_customer_returns_400_naming_the_field()
{
var response = await _client.PostAsJsonAsync("/orders", new
{
lines = new[] { new { sku = "ABC-1", quantity = 2 } }
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var errors = await ReadErrors(response);
Assert.True(errors.ContainsKey("CustomerId"), string.Join(",", errors.Keys));
}
[Fact]
public async Task Nested_line_quantity_out_of_range_returns_400_with_indexed_path()
{
var response = await _client.PostAsJsonAsync("/orders", new
{
customerId = "c-1",
lines = new[] { new { sku = "ABC-1", quantity = -5 } }
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var errors = await ReadErrors(response);
Assert.True(errors.ContainsKey("Lines[0].Quantity"), string.Join(",", errors.Keys));
}
[Fact]
public async Task Empty_order_returns_400()
{
var response = await _client.PostAsJsonAsync("/orders", new
{
customerId = "c-1",
lines = Array.Empty<object>()
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private static async Task<Dictionary<string, string[]>> ReadErrors(HttpResponseMessage response)
{
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return doc.RootElement.GetProperty("errors").EnumerateObject()
.ToDictionary(p => p.Name, p => p.Value.EnumerateArray().Select(e => e.GetString()!).ToArray());
}
}
Two of those tests check the error keys and not just the status code. That matters: a 400 from a body that failed to deserialise is not the same as a 400 from a rule firing, and the key names the field the client has to fix. Run dotnet test against the first draft:
Failed OrderValidationTests.Empty_order_returns_400 [284 ms]
Expected: BadRequest
Actual: Created
Failed OrderValidationTests.Missing_customer_returns_400_naming_the_field [14 ms]
Expected: BadRequest
Actual: Created
Failed OrderValidationTests.Nested_line_quantity_out_of_range_returns_400_with_indexed_path [4 ms]
Expected: BadRequest
Actual: Created
Failed! - Failed: 3, Passed: 1, Skipped: 0, Total: 4
Three red, one green. The green one is the happy path, which is the only test an agent writes unprompted.
Annotate the types and see nothing change
Now the step an agent will take when told to “add validation”: put attributes on the records. Because these are positional records, the attributes are targeted at the property.
using System.ComponentModel.DataAnnotations;
public record OrderLine(
[property: Required, StringLength(32)] string Sku,
[property: Range(1, 1000)] int Quantity);
public record CreateOrder(
[property: Required] string CustomerId,
[property: Required, MinLength(1)] List<OrderLine> Lines);
Run the tests again:
Failed OrderValidationTests.Empty_order_returns_400 [332 ms]
Failed OrderValidationTests.Missing_customer_returns_400_naming_the_field [15 ms]
Failed OrderValidationTests.Nested_line_quantity_out_of_range_returns_400_with_indexed_path [6 ms]
Failed! - Failed: 3, Passed: 1, Skipped: 0, Total: 4
Identical. No warning, no log line, no exception. The documentation says so directly: without generated validation metadata, no automatic validation runs, invalid input reaches the handler, and missing metadata produces neither an exception nor a log entry. A code reviewer reading this diff sees attributes and assumes protection. Only a test that sends bad input can tell the difference.
Add the one line
Register the validation services before Build():
builder.Services.AddValidation();
AddValidation comes from Microsoft.Extensions.Validation (it’s part of the ASP.NET Core shared framework in a web project, so no package reference was needed here). A source generator builds the validation metadata at compile time for the types it finds from endpoint handler signatures, in the assembly where AddValidation is called. Run the tests:
Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4
And here’s what a client sees, from a body with no customer and two bad lines:
HTTP/1.1 400 Bad Request
{"title":"One or more validation errors occurred.","errors":{"CustomerId":["The CustomerId field is required."],"Lines[0].Quantity":["The field Quantity must be between 1 and 1000."],"Lines[1].Sku":["The Sku field is required."],"Lines[1].Quantity":["The field Quantity must be between 1 and 1000."]}}
The handler never ran. Nested rules fire too, with indexed paths (Lines[1].Sku), because validation recurses into nested objects and collection items. That’s the behaviour the second and third tests pin down.
The prompt that keeps it fixed
Put the rule where the agent reads it on every task, in whatever context file your tool uses:
Every minimal API endpoint that takes a request body must have:
1. Data annotations on the request type (Required, Range, StringLength, MinLength).
2. A WebApplicationFactory test that posts an invalid body and asserts both
the 400 status and the error key for the offending field, including
nested paths such as Lines[0].Quantity.
3. builder.Services.AddValidation() present in Program.cs. If it is missing,
add it and say so.
Run `dotnet test` and paste the output before saying the task is done.
Point 2 is the one doing the work. A status-code check alone can pass for the wrong reason, and a test that never sends invalid input proves nothing. The check that matters is “invalid input produces a 400 naming the field”, and any agent that can run a shell can confirm that.
What validation still lets through
Validation attributes cover the shape of a value, not the whole contract. Four gaps are worth testing for.
Unknown fields are accepted. AddValidation doesn’t reject unmapped JSON properties, and System.Text.Json skips them by default. A client that sends qty instead of quantity gets a 201 and a default quantity of zero or a missing line. The first request below succeeds when it shouldn’t:
{"customerId":"c","lines":[{"sku":"A","quantity":1,"qty":9}]} -> 201
The documented fix is to disallow unmapped members in the JSON options:
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow);
With that in place a test that posts qty gets a 400 (it did here: five tests, five passing). It does change behaviour for every client that sends extra fields today, so it deserves a release note and not a silent rollout.
Cross-field rules need a different tool. Anything like “end date on or after start date” belongs in IValidatableObject. It runs only after property-level validation succeeds, so a request with a bad property won’t also report the cross-field error. Test the cross-field rule with otherwise valid data.
Nullable value types are a known hole on .NET 10. The documentation notes that, before .NET 11, nullable value types declared directly as minimal API parameters (an int? query parameter, say) aren’t validated; the tracking issue is dotnet/aspnetcore 67033. Put the value inside a request record instead, or check it by hand.
Attributes on types in another assembly won’t be picked up by the generator unless AddValidation is called from that assembly too. The docs describe an extension method per library project. Root types the generator can’t find from a handler signature need [ValidatableType], which is marked experimental on .NET 10 and raises ASP0029 in plain class libraries, so read that section of the docs before using it.
Variations
For endpoints where validation really is wrong (a webhook that must accept whatever a third party sends), DisableValidation() on the endpoint opts out explicitly. That’s a better outcome than a missing registration, because the opt-out is visible in the diff. [SkipValidation] does the same at property, parameter or type level.
For a Web API that already uses MVC controllers, none of this applies: the docs state the API isn’t supported for MVC or Razor Pages, which keep their own model validation.
For a different test style, a single parameterised [Theory] with a table of bad bodies and the expected error key keeps the file short as the request types grow. Either way, the assertion on the key is what stops the test passing for the wrong reason.
When not to bother
A prototype with no real clients doesn’t need this. Neither does an internal endpoint behind a gateway that already enforces a schema, though even then a test that proves the gateway’s rule reaches the handler is cheap. The risk grows with the number of agent-written endpoints, because each one is another place the registration or the attributes can be left out and the happy-path test still passes.
First 15 minutes
Pick the newest agent-written endpoint that takes a body. Write one test that posts an invalid body and asserts the 400 and the error key. Run it. If it fails, check Program.cs for AddValidation() before touching the attributes.