Skip to main content
Version: Preview (0.3.0-preview.205)

Use with ASP.NET Core

Minimal APIs​

Nothing to install. A generated value object implements IParsable<T> and ISpanParsable<T>, which is exactly what minimal API parameter binding looks for, and its [JsonConverter] covers request and response bodies:

app.MapGet("/accounts/{iban}", (Iban iban) => /* … */);

A value that fails to parse is answered with a 400 before the handler runs.

MVC controllers​

dotnet add package AdCodicem.ValueObjects.AspNetCore
builder.Services.AddControllers().AddValueObjects();

That registers a model binder for value objects — routes, query strings, headers, forms — and the JSON options for bodies. The binder is closed over each concrete type, so binding costs one TryParse. An application that configures MVC directly can call AddValueObjects() on MvcOptions instead; that one adds the binder only.

Nullable value objects bind as you would expect: [FromQuery] CountryCode? country is null when the parameter is absent, and a 400 when it is present and rejected. Empty or white-space text, ?country= or ?country=%20, binds as absent, as MVC binds an int? or a Guid?. A value object that cannot be null, a route segment such as CustomerId id, refuses blank text as MVC refuses it for an int: a 400 with "The value ' ' is invalid." and the code value_object.required, rather than an instance no rule has checked.

Problem details carrying the rule​

builder.Services.Configure<ApiBehaviorOptions>(options => options.AddValueObjectProblemDetails());

When model binding rejects a value, the automatic 400 response gains an errorCodes member mapping each rejected parameter to the stable code of the rule it violated:

{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": { "country": ["The value is not one of the accepted values."] },
"errorCodes": { "country": "value_object.not_a_known_value" }
}

A client branches on value_object.not_a_known_value, not on English. The member name is available as ValueObjectProblemDetails.ExtensionName.

This covers what the model binder rejects: route values, query strings, headers and forms. A value inside a JSON body is rejected by the serializer, and the 400 carries its message but no code.

Codes for a payload you validate yourself​

For a payload that carries raw text — an inbound message from another system, say — validate it with FluentValidation and put the codes under the same member, so every 400 of the API has the same shape:

var result = validator.Validate(request);

return result.IsValid
? Results.Accepted()
: Results.ValidationProblem(
result.ToDictionary(),
extensions: new Dictionary<string, object?>
{
[ValueObjectProblemDetails.ExtensionName] = result.Errors
.GroupBy(failure => failure.PropertyName)
.ToDictionary(member => member.Key, member => member.First().ErrorCode),
});

The validator of the FluentValidation guide stops each member at its first failure with Cascade(CascadeMode.Stop), so a member is reported once: empty text fails NotEmpty() and never reaches MustParseAs, and errors holds one message for it. A validator that does not stop, or that states several rules for one member, can fail a member more than once, which is why the failures are grouped by member: each member reports the code of the first rule it failed, and the dictionary never meets the same member twice.