This page follows one small orders service from the request to the database and back. Each guide covers one boundary. Here you see one e-mail address cross all of them, and at each step what happens to it, or that nothing does.
The service is a minimal API on SQLite. It uses the generators, the EF Core package, the ASP.NET Core package and the Serilog package. Its code is a sample application. CI runs it on every change, and builds it from the packed packages too, so every answer below is one it really gives: Orders.cs, OrderEndpoints.cs, Program.cs, and the requests in Tour.cs.
| Step | What happens to the e-mail address |
|---|---|
| Declare the values | Create trims it, composes it and checks it, once |
| Store them | it becomes a plain TEXT column |
| Place an order | JSON reads it through Create and writes it back as a string. The log masks it |
| Reject what doesn't fit | a rejected value gets a 400 that never quotes it |
| Find an order | nothing: an Option becomes a 404 |
Query on .Value |
nothing: .Value is the column in SQL |
| Change an order | a Patch<T> reads each field through its own Create |
| Read the OpenAPI document | it is described as the string it is |
public readonly partial record struct OrderId : IValue<Guid>;
public enum EmailFault { Blank, Hidden, NoAt }
public readonly partial record struct Email : IValidatedValue<Email, string, EmailFault>
{
public static Result<Email, EmailFault> Create(string value) =>
value.AsText() // trimmed, composed, no hidden characters
.MapError(fault => fault == TextFault.Blank ? EmailFault.Blank : EmailFault.Hidden)
.Ensure(text => text.IndexOf('@') > 0, EmailFault.NoAt)
.Map(text => new Email(text));
public override string ToString() => $"{Value[0]}***{Value[Value.IndexOf('@')..]}"; // what a log shows
}
public enum QuantityFault { NotPositive }
public readonly partial record struct Quantity : IValidatedValue<Quantity, int, QuantityFault>
{
public static Result<Quantity, QuantityFault> Create(int value) =>
value > 0 ? new Quantity(value) : Result.Error(QuantityFault.NotPositive);
}
public sealed partial class OrderStatus : IClosedSet<OrderStatus>
{
public static readonly OrderStatus Placed = new();
public static readonly OrderStatus Shipped = new();
public static readonly OrderStatus Cancelled = new();
}These are the service's whole domain:
OrderIdwraps aGuid.OrderId.New()makes a version 7 one.Emailis validated. ItsCreateruns the text guardAsText(), which trims the address, composes it to one spelling and rejects hidden characters. Then it checks for an@. Every way anEmailgets created runs thisCreate: JSON, a route or query value,TryFrom.Email.ToString()masks the address. The generators keep aToString()you write, and every log line prints it.Quantityrejects zero and less.OrderStatusis a closed set: an enum whose members are instances, written as their names.
The Value objects, Written text and Closed sets guides cover each kind.
public sealed class Order
{
public required OrderId Id { get; init; }
public required Email Email { get; set; }
public required Quantity Quantity { get; set; }
public required OrderStatus Status { get; set; }
}
public sealed class OrdersDb(DbContextOptions<OrdersDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
}The entity uses the value objects as its properties, with nothing to register per type.
Program.cs sets up the host. UseTypeKit() maps each value object to a column of the type inside
it, and EnsureCreated() shows the table that comes of it. MapOrders() maps the endpoints the next
sections show.
using CodoMetis.TypeKit.AspNetCore;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Serilog;
using Walkthrough;var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(options => options.AddTypeKit());
builder.Services.AddFaultResponses<OrderFault>(fault => fault switch
{
OrderFault.NotFound => StatusCodes.Status404NotFound,
OrderFault.AlreadyShipped or OrderFault.Cancelled => StatusCodes.Status409Conflict,
OrderFault.Cleared => StatusCodes.Status422UnprocessableEntity,
_ => throw new ArgumentOutOfRangeException(nameof(fault)),
});
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.RespectRequiredConstructorParameters = true);
var connection = new SqliteConnection("Data Source=:memory:"); // the database lives as long as this connection is open
connection.Open();
builder.Services.AddDbContext<OrdersDb>(options => options.UseSqlite(connection).UseTypeKit()
.LogTo(Console.WriteLine, [RelationalEventId.CommandExecuted]));
builder.Services.AddSingleton<Serilog.ILogger>(new LoggerConfiguration()
.Destructure.WithTypeKit()
.WriteTo.Console(outputTemplate: "[{Level:u3}] {Message:lj}{NewLine}")
.CreateLogger());var app = builder.Build();
app.UseStatusCodePages();
app.MapOpenApi();
app.MapOrders();
using (var scope = app.Services.CreateScope())
{
scope.ServiceProvider.GetRequiredService<OrdersDb>().Database.EnsureCreated();
// CREATE TABLE "Orders" (
// "Id" TEXT NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY,
// "Email" TEXT NOT NULL,
// "Quantity" INTEGER NOT NULL,
// "Status" TEXT NOT NULL
// );
}What changes here: a value object becomes its underlying type. The id is a TEXT key, as a Guid is
on SQLite, and the quantity an INTEGER. The status is its member's name. Reading a row back skips
Create, because the service wrote that value itself (EF Core).
Good to know
- The database lives in one open connection. SQLite in memory keeps a database as long as its connection is open, so the service opens one and keeps it. Your own service passes a connection string instead.
LogToprints the SQL that EF Core runs, which is how this page shows it.RespectRequiredConstructorParametersmakes a body that leaves outemaila 400. Without it, theEmailwould bedefault, an address that never went throughCreate(ASP.NET Core).
public sealed record PlaceOrder(Email Email, Quantity Quantity);
public sealed record OrderView(OrderId Id, Email Email, Quantity Quantity, OrderStatus Status)
{
public static OrderView Of(Order order) => new(order.Id, order.Email, order.Quantity, order.Status);
}
public enum OrderFault { NotFound, AlreadyShipped, Cancelled, Cleared }app.MapPost("/orders", async (PlaceOrder request, OrdersDb db, Serilog.ILogger log) =>
{
var order = new Order { Id = OrderId.New(), Email = request.Email, Quantity = request.Quantity, Status = OrderStatus.Placed };
db.Orders.Add(order);
await db.SaveChangesAsync();
var view = OrderView.Of(order);
log.Information("Placed {@Order}", view); // Placed {"Id": "…", "Email": "a***@example.com", "Quantity": "2", "Status": "Placed", "$type": "OrderView"}
return TypedResults.Created($"/orders/{order.Id}", view);
});The requests are in Tour.cs. Send
sends one and returns an Answer: its status and its body. The comment after each request is what it
gets back, with … for an id. The client sends the address with spaces around it:
Answer placed = await Send("POST", "/orders", """{"email":" ann@example.com ","quantity":2}"""); // 201 {"id":"…","email":"ann@example.com","quantity":2,"status":"Placed"}
OrderId id = JsonSerializer.Deserialize<OrderView>(placed.Body, JsonSerializerOptions.Web)!.Id;Here the address crosses three boundaries:
- On the way in, the JSON converter calls
Create. So the handler receivesann@example.com, trimmed, and never sees the spaces. - On the way out, the JSON is the underlying type's:
"email":"ann@example.com", a plain string, and the closed set is its name (JSON). - In the log, the address is masked. Serilog destructures
{@Order}by its properties, andDestructure.WithTypeKit()logs each value object as what itsToString()prints. Without it, Serilog would readValueand log the address (Logging and secrets).
The client reads the answer back into the same types. Its id is an OrderId again.
Answer noAt = await Send("POST", "/orders", """{"email":"ann","quantity":2}"""); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"}
Answer noEmail = await Send("POST", "/orders", """{"quantity":2}"""); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"}
Answer notAnId = await Send("GET", "/orders/42"); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"}
Answer noneAtAll = await Send("GET", "/orders?minimum=0"); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"}Each of these is a 400 with problem details, and none of them quotes what the client sent:
- A body that
Createrejects throws aJsonExceptionthat namesEmailandNoAt, never the text. The handler never runs. - A body without
emailis rejected because ofRespectRequiredConstructorParameters. - A route value that isn't an
OrderId, and a query value thatCreaterejects, fail the generatedTryParse.
UseStatusCodePages() gives these empty 400s their problem body. The body is ASP.NET's own and
names neither the field nor the rule. ASP.NET logs the reason.
app.MapGet("/orders/{id}", async (OrderId id, OrdersDb db) =>
(await db.Orders.FirstOrNoneAsync(order => order.Id == id)) // an Option: the order, or none
.ToResult(OrderFault.NotFound)
.Map(OrderView.Of)
.ToHttpResult())
.WithoutFaults(OrderFault.AlreadyShipped, OrderFault.Cancelled, OrderFault.Cleared); // finding one reports only NotFoundAnswer found = await Send("GET", $"/orders/{id}"); // 200 {"id":"…","email":"ann@example.com","quantity":2,"status":"Placed"}
Answer missing = await Send("GET", $"/orders/{OrderId.New()}"); // 404 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"fault":"NotFound","traceId":"…"}FirstOrNoneAsync asks the database for one row and returns an Option, so there is no null to
forget. ToResult turns a missing order into the error NotFound, and ToHttpResult() answers it
with the status the setup maps it to, as problem details. The fault member names the error, so a
client can tell a missing order from any other 404 (Result,
ASP.NET Core).
WithoutFaults lists the errors this endpoint never reports. The OpenAPI document then shows only
the 404 for it, where it would otherwise list every status OrderFault maps to.
Nothing changes for the id here. The route value becomes an OrderId through TryParse, and the
query compares it with the key column.
Good to know
- ASP0020 is a false alarm here. The service declares its value objects in the web project, so
ASP.NET's route analyzer reads
OrderIdbefore the generators make it parsable.OrderEndpoints.cssuppresses the warning. Value objects in a domain project of their own need nothing (ASP.NET Core).
app.MapGet("/orders", async (Quantity minimum, OrdersDb db) =>
await db.Orders.Where(order => order.Quantity.Value >= minimum.Value) // WHERE "o"."Quantity" >= @minimum_Value
.Select(order => OrderView.Of(order))
.ToListAsync());Answer atLeastTwo = await Send("GET", "/orders?minimum=2"); // 200 [{"id":"…","email":"ann@example.com","quantity":2,"status":"Placed"}]
Answer atLeastFive = await Send("GET", "/orders?minimum=5"); // 200 []minimum comes from the query string, through Create. In the query, order.Quantity.Value is the
Quantity column itself, so the comparison runs in the database. .Value works the same way in memory
and in SQL (EF Core).
public sealed record OrderPatch
{
public Patch<Quantity> Quantity { get; init; }
public Patch<OrderStatus> Status { get; init; }
}
public static class OrderPatching
{
public static Result<Order, OrderFault> Apply(this Order order, OrderPatch patch)
{
if (order.Status == OrderStatus.Shipped) return Result.Error(OrderFault.AlreadyShipped);
if (order.Status == OrderStatus.Cancelled) return Result.Error(OrderFault.Cancelled);
if (patch.Quantity.State == PatchState.Null || patch.Status.State == PatchState.Null) return Result.Error(OrderFault.Cleared); // neither field can be cleared
patch.Quantity.Tap(quantity => order.Quantity = quantity);
patch.Status.Tap(status => order.Status = status);
return order;
}
}app.MapPatch("/orders/{id}", async (OrderId id, OrderPatch patch, OrdersDb db) =>
{
var changed = (await db.Orders.FirstOrNoneAsync(order => order.Id == id))
.ToResult(OrderFault.NotFound)
.Bind(order => order.Apply(patch));
await db.SaveChangesAsync();
return changed.Map(OrderView.Of).ToHttpResult();
});Answer three = await Send("PATCH", $"/orders/{id}", """{"quantity":3}"""); // 200 {"id":"…","email":"ann@example.com","quantity":3,"status":"Placed"}
Answer zero = await Send("PATCH", $"/orders/{id}", """{"quantity":0}"""); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"}
Answer cleared = await Send("PATCH", $"/orders/{id}", """{"quantity":null}"""); // 422 {"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Unprocessable Entity","status":422,"fault":"Cleared","traceId":"…"}
Answer shipped = await Send("PATCH", $"/orders/{id}", """{"status":"Shipped"}"""); // 200 {"id":"…","email":"ann@example.com","quantity":3,"status":"Shipped"}
Answer tooLate = await Send("PATCH", $"/orders/{id}", """{"quantity":4}"""); // 409 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"fault":"AlreadyShipped","traceId":"…"}A Patch<T> tells apart a field the request left out, a field set to null, and a new value. Each
field reads its value through its own Create, so {"quantity":0} is a 400 like any other body.
Apply decides what a null means. Here neither field can be cleared, so a clear gets the error
Cleared. A shipped order changes no more, and gets AlreadyShipped
(Merge patches).
Bind runs Apply only on an order that was found, so one ToHttpResult() answers every error.
JsonNode document = JsonNode.Parse(await http.GetStringAsync("/openapi/v1.json"))!;
JsonNode schemas = document["components"]!["schemas"]!;
JsonNode find = document["paths"]!["/orders/{id}"]!["get"]!;
string idParameter = find["parameters"]![0]!["schema"]!.ToJsonString(); // {"type":"string","format":"uuid"}
string findAnswers = string.Join(",", find["responses"]!.AsObject().Select(answer => answer.Key)); // 200,404
string emailSchema = schemas["Email"]!.ToJsonString(); // {"type":"string"}
string statusSchema = schemas["OrderStatus"]!.ToJsonString(); // {"enum":["Placed","Shipped","Cancelled"],"type":"string"}
string patchType = schemas["OrderPatch"]!["properties"]!["quantity"]!["type"]!.ToJsonString(); // ["null","integer","string"]AddTypeKit() describes every value object as the schema of the type inside it:
- The route's
idis auuidstring, as aGuidparameter would be. - The errors are listed beside the success:
GET /orders/{id}answers 200 or 404. Emailis a string in a body, and keeps a component of its own.- The closed set is a string enum of its names.
- A patch field is its underlying type's schema, and may also be
null. Thestringis ASP.NET's own: a minimal API reads a number from a string too.
So a client generated from the document sends and receives plain strings and numbers (ASP.NET Core).
| Boundary | The e-mail address there |
|---|---|
Create |
trimmed and composed, or rejected |
| JSON, in a request or a response | the string itself |
| A route or query value | read by the generated TryParse, which calls Create |
| The database | a TEXT column, read back without Create |
| A LINQ query | .Value is the column |
| A rejected request | a 400 that names neither the value nor the rule |
| A log line | what ToString() prints: masked |
| The OpenAPI document | a string |