Skip to main content
Version: 0.2.x (0.2.1)

From request to database

This tutorial puts value objects behind a small ASP.NET Core API that stores customers with EF Core. Every rule is declared once, on the type, and each boundary picks it up from there. The complete application is the sample API in the repository.

The packages​

dotnet add package AdCodicem.ValueObjects
dotnet add package AdCodicem.ValueObjects.AspNetCore
dotnet add package AdCodicem.ValueObjects.OpenApi
dotnet add package AdCodicem.ValueObjects.EntityFrameworkCore

The domain​

Three types, each declaring its own rules:

[ValueObject<Guid>]
public readonly partial struct CustomerId : IValueObjectValidator<Guid>
{
public static CustomerId New() => CreateUnchecked(Guid.CreateVersion7());

public static ValidationResult ValidateValue(in Guid value)
=> value == Guid.Empty
? ValidationResult.Required("A customer identifier must not be empty.")
: ValidationResult.Success;
}

[ValueObject<string>(MaxLength = 254, Pattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$", SchemaFormat = "email")]
public readonly partial struct EmailAddress : IValueObjectNormalizer<string>
{
public static string NormalizeValue(string value) => value.Trim().ToLowerInvariant();
}

[ValueObject<string>(ValueSet = ValueSetKind.Closed, MinLength = 2, MaxLength = 2)]
[KnownValue("France", "FR")]
[KnownValue("Belgium", "BE")]
[KnownValue("Luxembourg", "LU")]
public readonly partial struct CountryCode : IValueObjectNormalizer<string>
{
public static string NormalizeValue(string value) => value.Trim().ToUpperInvariant();
}

CustomerId.New() is a member of your own, next to the generated ones. CreateUnchecked is legitimate there because the application produced the value itself; it never is for input that comes from outside.

The entity and the contracts use the types directly, with no string in sight:

public sealed class Customer
{
public CustomerId Id { get; set; }
public EmailAddress Email { get; set; }
public CountryCode Country { get; set; }
}

public sealed record CreateCustomerRequest(EmailAddress Email, CountryCode Country);
public sealed record CustomerResponse(CustomerId Id, EmailAddress Email, CountryCode Country);

The API​

var builder = WebApplication.CreateBuilder(args);

// Model binding for routes, query strings and headers, and JSON for request and response bodies.
builder.Services.AddControllers().AddValueObjects();

// The error code of the violated rule is added to the automatic 400 response.
builder.Services.Configure<ApiBehaviorOptions>(options => options.AddValueObjectProblemDetails());

// Value objects are documented as their underlying type, with the rules declared on them.
builder.Services.AddOpenApi(options => options.AddValueObjects());

builder.Services.AddDbContext<ShopDbContext>(options => options.UseNpgsql(connectionString));

A controller takes the value objects as parameters, from any source:

[HttpGet("{id}")]
public async Task<ActionResult<CustomerResponse>> GetById(CustomerId id, CancellationToken cancellationToken)

[HttpGet]
public async Task<IReadOnlyList<CustomerResponse>> List([FromQuery] CountryCode? country, CancellationToken cancellationToken)

[HttpPost]
public async Task<ActionResult<CustomerResponse>> Create([FromBody] CreateCustomerRequest request, CancellationToken cancellationToken)

A minimal API needs none of the above: a generated value object implements IParsable<T>, which is what minimal API parameter binding looks for.

app.MapGet("/customers/{id}", async (CustomerId id, ShopDbContext database) => /* … */);

The database​

public sealed class ShopDbContext(DbContextOptions<ShopDbContext> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
=> configurationBuilder.ConfigureValueObjects(typeof(CustomerId).Assembly);
}

That one call maps every value object of the assembly: CustomerId to the provider's native GUID column (uuid, uniqueidentifier), and EmailAddress to text bounded at 254 characters because the type says 254 — character varying(254) on PostgreSQL, nvarchar(254) on SQL Server. A LINQ query compares value objects the way it would compare the underlying values — Where(c => c.Country == country) becomes an ordinary WHERE on the column.

What you get​

Bodies carry bare values. A request sends {"email": " Ada@Example.COM ", "country": "fr"} and the response comes back as {"id": "0193…", "email": "ada@example.com", "country": "FR"}: normalized on the way in, and never wrapped in an object.

A rejected value names its rule. GET /customers?country=ZZ fails model binding, and the problem details response carries the stable code next to the message:

{
"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" }
}

errorCodes covers values bound from the route, the query string, headers and forms. A value inside a JSON body is rejected by the serializer instead: the 400 names the member but carries no code. When a client needs codes for a payload, validate it with FluentValidation, which reports the value object's own codes.

The OpenAPI document states the rules. EmailAddress is documented as {"type": "string", "format": "email", "maxLength": 254, "pattern": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"} and CountryCode carries "enum": ["FR", "BE", "LU"]. Nothing was written for it beyond the declaration.

The column is sized by the type. Change MaxLength and the next migration resizes the column. The rule has one home.

Reading rows back​

Materializing a row does not validate the value again: it uses CreateUnchecked, because it is the hottest path in most applications and reads values this same application validated when it wrote them. For a table that another system also writes to, turn validation back on:

configurationBuilder.ConfigureValueObjects(strict: true, typeof(CustomerId).Assembly);

Next: Public identifiers, for identifiers that clients see.