Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Module 8 · 8. Testing, Web APIs, Security, and Production Engineering · Lesson 23 of 24

    Building ASP.NET Core APIs with Validation and Problem Details

    What you will build

    An order endpoint is a translation boundary. The caller supplies an item and quantity; the application either creates an order or explains why it cannot. A useful implementation makes that decision visible in the response status, headers, and body together. This lesson builds that boundary, executes real ASP.NET Core result objects, and inspects their serialized output without opening a server.

    You will implement manual validation, preserve a creation contract, distinguish missing resources from state conflicts, and return safe Problem Details. You will also learn what an in-memory framework test establishes and what it leaves untested. The application is deliberately small: one process, one in-memory store, four operations, and deterministic identifiers. There are no external packages, database calls, network requests, files, or background services in the example.

    1. Write the contract before the handler

    Use these rules as the specification for this exercise. They are application decisions, so a test should check them rather than merely repeat whatever the implementation happens to return.

    • Create, conceptually POST /orders: accept an item whose trimmed length is 1 through 40 and a quantity from 1 through 10. Return 201, a Location of /orders/{id}, and an OrderView containing the assigned identifier and normalized values. This keeps the original creation contract.
    • Get, conceptually GET /orders/{id}: return 200 and the current view when present; return a 404 problem with code order_missing when absent. Cancellation changes a resource's state; it does not delete that resource.
    • Cancel, conceptually POST /orders/{id}/cancel: change an open order to cancelled and return 204 with an empty body. A missing identifier produces 404. A second cancellation produces 409 with code order_closed.
    • List, conceptually GET /orders: return 200 and an array ordered by identifier. An empty store returns an empty array, because the collection exists even when it has no members.

    The word “conceptually” matters: the runner records these methods and paths but never routes them. Each test supplies the handler directly. The contract is HTTP-shaped; only the response side and application decisions are executed here.

    Do not silently turn creation into 200 just because a JSON body was produced. A client needs the newly assigned address as well as the representation. Conversely, a successful cancellation intentionally has no representation to deserialize. The same client should branch on status before trying to parse a body.

    2. Separate three kinds of failure

    Input validation asks whether a command is acceptable before work begins. For a blank item and zero quantity, Create collects both errors and returns before resolving the store. Nothing is inserted and no identifier is consumed. A caller can fix both fields in one attempt. Null input follows the same two-field correction policy in this exercise.

    State validation asks whether an otherwise meaningful operation is allowed now. Identifier 1 can refer to a real order that is already cancelled. The request is understandable and the resource exists, but the requested transition is unavailable. OrderStore.Cancel makes that decision under the same lock as the update; the endpoint translates its outcome into 409. Checking state outside the update would create a gap between the decision and the mutation.

    An unexpected defect is different again. The demonstration fault throws an exception containing a private diagnostic marker. ApiBoundary converts that handler exception into a fixed 500 problem. It must not pretend the caller supplied bad input, and the marker must never appear in the client body. Avoid classifying every exception as a validation failure merely to keep the application running.

    Validation here is explicit C# code. No validation package or automatic validation registration is assumed. Create checks the local item.Length after trimming request.Item. This counts UTF-16 code units, not user-perceived characters. That is sufficient for this exercise's plain-text item labels; a multilingual product should choose and document its own length rule.

    3. Framework facts to connect to the exercise

    ASP.NET Core result helpers create IResult implementations. Executing the result writes the response; inspecting a return value alone does not check serialized bytes or headers. Results exposes the interface, while TypedResults returns concrete result types and can supply endpoint metadata. Microsoft: Minimal API responses

    Production error responses should not reveal sensitive diagnostics. ASP.NET Core offers exception handling middleware and a Problem Details service; configuring services is distinct from arranging middleware. Responses that have already started cannot be freely replaced with a new error status. Microsoft: error handling

    Minimal API binding can obtain values from routes, query strings, headers, request bodies, and dependency injection. Binding is a separate stage from the handler's business decision. The runner below skips that stage by supplying an already constructed request object and integer identifier. Microsoft: parameter binding

    4. Complete runnable project

    Create a folder named ApiWalkthrough and put the following four files in it. Use a .NET 10 SDK with the Microsoft.AspNetCore.App shared framework and targeting pack. The framework reference supplies ASP.NET Core APIs without adding a NuGet package. Run dotnet run --project ApiWalkthrough.csproj from that folder. The expected output appears after the listing.

    Read Orders.cs in two passes. First follow Create through its early validation return and successful insertion. Then follow CancelOutcome from the store to the response switch. StoredOrder remains private; OrderView is a deliberate public representation. Its status text is mapped explicitly instead of exposing the store's internal boolean.

    ApiWalkthrough.csproj

    XML
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
      </PropertyGroup>
      <ItemGroup>
        <FrameworkReference Include="Microsoft.AspNetCore.App" />
      </ItemGroup>
    </Project>

    Orders.cs

    C#
    using Microsoft.AspNetCore.Http;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Logging;
    
    namespace ApiLesson;
    
    public sealed record CreateOrderRequest(string? Item, int Quantity);
    public sealed record OrderView(int Id, string Item, int Quantity, string Status);
    public enum CancelOutcome { Cancelled, Missing, AlreadyCancelled }
    
    public sealed class OrderStore
    {
        private sealed record StoredOrder(int Id, string Item, int Quantity,
            bool IsCancelled);
        private readonly Dictionary<int, StoredOrder> _orders = new();
        private readonly object _gate = new();
        private int _nextId;
    
        public int Count { get { lock (_gate) return _orders.Count; } }
        private static OrderView View(StoredOrder x) =>
            new(x.Id, x.Item, x.Quantity, x.IsCancelled ? "cancelled" : "open");
    
        public OrderView Create(string item, int quantity)
        {
            lock (_gate)
            {
                var order = new StoredOrder(++_nextId, item, quantity, false);
                _orders.Add(order.Id, order);
                return View(order);
            }
        }
    
        public OrderView? Get(int id)
        {
            lock (_gate)
                return _orders.TryGetValue(id, out var x) ? View(x) : null;
        }
    
        public OrderView[] List()
        {
            lock (_gate) return _orders.Values.OrderBy(x => x.Id).Select(View).ToArray();
        }
    
        public CancelOutcome Cancel(int id)
        {
            lock (_gate)
            {
                if (!_orders.TryGetValue(id, out var x)) return CancelOutcome.Missing;
                if (x.IsCancelled) return CancelOutcome.AlreadyCancelled;
                _orders[id] = x with { IsCancelled = true };
                return CancelOutcome.Cancelled;
            }
        }
    }
    
    public static class OrdersApi
    {
        private static OrderStore Store(HttpContext c) =>
            c.RequestServices.GetRequiredService<OrderStore>();
    
        public static IResult Create(HttpContext c, CreateOrderRequest? request)
        {
            var errors = new Dictionary<string, string[]>();
            string item = request?.Item?.Trim() ?? "";
            if (item.Length is < 1 or > 40)
                errors["item"] = ["Use 1 to 40 non-padding characters."];
            if (request is null || request.Quantity is < 1 or > 10)
                errors["quantity"] = ["Choose a quantity from 1 to 10."];
            if (errors.Count != 0)
                return Results.ValidationProblem(errors,
                    title: "Order input is invalid.",
                    extensions: new Dictionary<string, object?>
                    { ["traceId"] = c.TraceIdentifier, ["code"] = "invalid_order" });
    
            var order = Store(c).Create(item, request!.Quantity);
            return Results.Created($"/orders/{order.Id}", order);
        }
    
        public static IResult Get(HttpContext c, int id)
        {
            var order = Store(c).Get(id);
            return order is null
                ? Problem(c, 404, "Order not found.", "order_missing")
                : Results.Ok(order);
        }
    
        public static IResult List(HttpContext c) => Results.Ok(Store(c).List());
    
        public static IResult Cancel(HttpContext c, int id) => Store(c).Cancel(id) switch
        {
            CancelOutcome.Cancelled => Results.NoContent(),
            CancelOutcome.Missing => Problem(c, 404, "Order not found.", "order_missing"),
            _ => Problem(c, 409, "Order is already cancelled.", "order_closed")
        };
    
        public static IResult Problem(HttpContext c, int status, string title,
            string code, string? detail = null) =>
            Results.Problem(statusCode: status, title: title, detail: detail,
                extensions: new Dictionary<string, object?>
                { ["traceId"] = c.TraceIdentifier, ["code"] = code });
    }
    
    public static class ApiBoundary
    {
        // This is our adapter, not ASP.NET Core's ExceptionHandlerMiddleware.
        public static RequestDelegate Wrap(Func<HttpContext, IResult> handler) =>
            async context =>
            {
                IResult result;
                try { result = handler(context); }
                catch (Exception error) when (error is not OperationCanceledException
                    && !context.Response.HasStarted)
                {
                    context.RequestServices.GetRequiredService<ILoggerFactory>()
                        .CreateLogger("ApiLesson").LogError(error,
                            "Request {TraceId} failed", context.TraceIdentifier);
                    result = OrdersApi.Problem(context, 500, "Request failed.",
                        "unexpected_error", "An unexpected error occurred.");
                }
                // Execution/serialization faults deliberately escape this boundary.
                await result.ExecuteAsync(context);
            };
    }

    5. Execute the response, then observe it

    RequestRunner builds a real service provider, creates a request scope, supplies DefaultHttpContext, and captures the response in MemoryStream. OrdersApi resolves the same singleton OrderStore through RequestServices on each invocation. Scope validation is enabled. Disposing the scope, stream, and provider makes their lifetimes visible instead of leaving cleanup to process exit.

    ApiBoundary is our small adapter around a supplied delegate. It is not ASP.NET Core's ExceptionHandlerMiddleware. Its try block covers handler invocation only. IResult.ExecuteAsync is outside that block, so a serialization or result-execution exception escapes. The semantic harness includes an intentionally failing result to lock down that limitation. This is preferable to claiming comprehensive exception coverage from one happy-path serialization test.

    The adapter also excludes OperationCanceledException from its 500 mapping. That exclusion does not implement request cancellation: there is no aborted connection, cancellable database operation, or downstream token propagation in this program. The harness proves only that this exception category escapes the adapter. A cancellation check cannot establish rollback of an order that was already created.

    Logging is registered without providers to keep output deterministic and avoid external effects. The LogError call therefore is not evidence that an operator will receive a diagnostic. A deployed application needs a separately configured, protected diagnostic destination and tests of that configuration.

    RequestRunner.cs

    C#
    using System.Text;
    using System.Text.Json;
    using Microsoft.AspNetCore.Http;
    using Microsoft.Extensions.DependencyInjection;
    
    namespace ApiLesson;
    
    public sealed record CapturedResponse(int Status, string? ContentType,
        string Location, string Body)
    {
        public JsonElement Json => JsonSerializer.Deserialize<JsonElement>(Body);
    }
    
    public sealed class RequestRunner : IDisposable
    {
        private readonly ServiceProvider _services;
        public OrderStore Store => _services.GetRequiredService<OrderStore>();
    
        public RequestRunner()
        {
            var services = new ServiceCollection();
            services.AddLogging(); // No provider: no console or external log output.
            services.AddOptions();
            services.AddSingleton<OrderStore>();
            _services = services.BuildServiceProvider(new ServiceProviderOptions
            { ValidateScopes = true, ValidateOnBuild = true });
        }
    
        public async Task<CapturedResponse> Send(string method, string path,
            string trace, Func<HttpContext, IResult> handler)
        {
            using var scope = _services.CreateScope();
            using var body = new MemoryStream();
            var context = new DefaultHttpContext
            {
                RequestServices = scope.ServiceProvider,
                TraceIdentifier = trace
            };
            context.Request.Method = method;
            context.Request.Path = path;
            context.Response.Body = body;
            await ApiBoundary.Wrap(handler)(context);
            return new CapturedResponse(context.Response.StatusCode,
                context.Response.ContentType,
                context.Response.Headers.Location.ToString(),
                Encoding.UTF8.GetString(body.ToArray()));
        }
    
        public void Dispose() => _services.Dispose();
    }

    Program.cs

    C#
    using ApiLesson;
    
    using var runner = new RequestRunner();
    var created = await runner.Send("POST", "/orders", "create",
        c => OrdersApi.Create(c, new(" paper ", 2)));
    var order = created.Json;
    Console.WriteLine($"create={created.Status} location={created.Location} " +
        $"item={order.GetProperty("item").GetString()} " +
        $"quantity={order.GetProperty("quantity").GetInt32()} " +
        $"status={order.GetProperty("status").GetString()}");
    
    var invalid = await runner.Send("POST", "/orders", "bad-input",
        c => OrdersApi.Create(c, new(" ", 0)));
    var keys = invalid.Json.GetProperty("errors").EnumerateObject()
        .Select(x => x.Name).OrderBy(x => x, StringComparer.Ordinal);
    Console.WriteLine($"invalid={invalid.Status} keys={string.Join(",", keys)} " +
        $"trace={invalid.Json.GetProperty("traceId").GetString()} count={runner.Store.Count}");
    
    var missing = await runner.Send("GET", "/orders/99", "missing",
        c => OrdersApi.Get(c, 99));
    Console.WriteLine($"missing={missing.Status} " +
        $"code={missing.Json.GetProperty("code").GetString()} " +
        $"trace={missing.Json.GetProperty("traceId").GetString()}");
    var cancelled = await runner.Send("POST", "/orders/1/cancel", "cancel",
        c => OrdersApi.Cancel(c, 1));
    Console.WriteLine($"cancel={cancelled.Status} bytes={cancelled.Body.Length}");
    var repeated = await runner.Send("POST", "/orders/1/cancel", "repeat",
        c => OrdersApi.Cancel(c, 1));
    Console.WriteLine($"repeat={repeated.Status} " +
        $"code={repeated.Json.GetProperty("code").GetString()} " +
        $"trace={repeated.Json.GetProperty("traceId").GetString()}");
    var found = await runner.Send("GET", "/orders/1", "get", c => OrdersApi.Get(c, 1));
    Console.WriteLine($"get={found.Status} status={found.Json.GetProperty("status").GetString()}");
    var listed = await runner.Send("GET", "/orders", "list", OrdersApi.List);
    Console.WriteLine($"list={listed.Status} count={listed.Json.GetArrayLength()}");
    var fault = await runner.Send("GET", "/demo-fault", "fault",
        _ => throw new InvalidOperationException("private diagnostic marker"));
    Console.WriteLine($"fault={fault.Status} " +
        $"detail={fault.Json.GetProperty("detail").GetString()} " +
        $"trace={fault.Json.GetProperty("traceId").GetString()}");

    Expected output

    Code
    create=201 location=/orders/1 item=paper quantity=2 status=open
    invalid=400 keys=item,quantity trace=bad-input count=1
    missing=404 code=order_missing trace=missing
    cancel=204 bytes=0
    repeat=409 code=order_closed trace=repeat
    get=200 status=cancelled
    list=200 count=1
    fault=500 detail=An unexpected error occurred. trace=fault

    6. Work through the output

    The first command inserts paper with quantity 2. Trimming happens before the store call, so the response and later reads agree on paper rather than retaining padding. Identifier 1 determines both the Location and the body identifier. Testing only one of those would miss a mismatch that sends clients to the wrong resource.

    The invalid command reports both item and quantity. Its printed count stays 1 because only the earlier valid order exists. Change the runner to issue the invalid command first: the next successful creation should still receive identifier 1. This is a useful behavioral check for moving validation below insertion by mistake.

    The missing read returns order_missing. The first cancellation returns no bytes; the repeated cancellation returns order_closed. Finally, a read still finds the cancelled order. These three observations show why “anything unsuccessful is 404” is wrong for this contract: missing and disallowed transition are different facts.

    The fault line contains a safe fixed detail and the supplied trace identifier. The actual private marker is deliberately absent. A trace value links a report to diagnostics; it is not authorization to reveal those diagnostics. In this runner traces are test labels. Do not generalize that into reflecting arbitrary client-provided text into production logs or responses without a deliberate policy.

    7. Worked practice: improve the checks

    Exercise A: boundaries before examples

    Predict the result for item lengths 0, 1, 40, and 41 after trimming, combined with quantities 0, 1, 10, and 11. Then choose the smallest set that exercises every boundary without writing all sixteen combinations.

    Worked answer: use blank/0 and length-41/11 to collect both failures, then length-1/1 and length-40/10 to prove the inclusive valid limits. Add a null request because it takes a different expression path. Assert zero writes after the invalid cases, not only status 400. For a null item with a valid quantity, only item should be rejected; this is an additional focused test you can add.

    Exercise B: retries and cancellation policy

    A client sends cancellation twice because it never saw the first response. Should the second response be 204 or 409? Explain what the current implementation does and how to change the policy without hiding missing resources.

    Worked answer: this contract intentionally returns 409 on the second call, while leaving the order cancelled. Repetition does not produce another state transition. If the product chooses “ensure cancelled” semantics, map AlreadyCancelled to NoContent as well; retain Missing as 404. Update the repeated-call assertion and the client documentation together. Do not change Cancelled to delete the order, because that would change subsequent reads and erase the distinction.

    Create has a different retry risk: two identical accepted commands create two identifiers. Trimming an item is not deduplication. A reliable create-retry design needs an explicit operation identity and a rule for reusing an identity with different input. Neither exists in this sample, so never describe its creation path as idempotent.

    Exercise C: catch a misleading integration test

    Change the method and path passed to RequestRunner.Send while leaving the supplied Create delegate unchanged. Predict whether routing will reject the request.

    Worked answer: nothing in this runner dispatches on method or path, so the supplied handler still runs. A passing 201 assertion cannot establish that a real POST route exists or that GET is rejected. Label this a framework response integration test: real DI, result execution, and JSON output. Add a separate hosted routing test when building a deployed application. Renaming this runner “HTTP server” would not expand its coverage.

    8. What the companion harness proves

    ApiSemanticTests links the exact Orders.cs and RequestRunner.cs files instead of maintaining a second implementation. It checks validation without writes, inclusive boundaries, normalized creation, Location, response media types, the public DTO shape, stable list order, missing reads and cancellations, repeated cancellation, and unchanged unrelated orders. For problems it compares the HTTP status with the body status and checks code and trace fields.

    The harness checks JSON properties semantically rather than demanding a particular property order or a framework-generated type URI. Those incidental formatting choices are not this exercise's business contract. The walkthrough's short printed summary remains deterministic, while the harness catches omitted fields and inconsistent values that a status-only test misses.

    The companion PDF appendix contains the full ApiSemanticTests project, source, expected output, and folder layout. Keep its project folder beside ApiWalkthrough so the source links resolve. Run the separate ApiSemanticTests project to evaluate those assertions. No sockets, host startup, request JSON binding, route constraints, middleware ordering, authentication, authorization, database transactions, or deployment behavior are exercised. The synthetic failing result tests the adapter boundary; it is not a replacement for real result serialization, which the other cases execute.

    9. Take the design into a real application deliberately

    Before publishing these operations, define who may read or cancel each order, how ownership is established, what information a missing response is allowed to reveal, and how list results are scoped and paginated. The sample has no caller identity and makes no security claim. Validation of item and quantity cannot answer any of those authorization questions.

    OrderStore is an in-memory teaching fixture, not durable persistence or an independently validating aggregate. Its Create method trusts the boundary's validation, so another caller could bypass those input rules. A reusable application service must enforce its own invariants or accept a command that can only represent valid input. The singleton dictionary lasts only for the runner's lifetime. Its lock makes the demonstrated transition indivisible inside one process; it provides neither persistence nor coordination across servers. Move the same state rule into an appropriate persistent transaction or concurrency check when storage changes. Keep response DTOs separate so that a storage refactor does not accidentally publish new fields.

    Interview check: explain the difference between rejecting an invalid command, finding no resource, and refusing a transition on an existing resource. Then name one assertion for each layer you actually exercised and one risk you still need another test to address. A precise coverage boundary is part of a strong answer.

    Analogy

    Everyday picture

    At a rehearsal-room desk, a form missing both a room and a group size is returned with both corrections marked, before a booking number is assigned. Later, two cancellation requests arrive. One number is absent from the ledger; the other has a retained entry marked cancelled. Those requests deserve different explanations, although neither can cancel an active booking.

    Mapping. The desk's correction slip models Results.ValidationProblem: invalid input returns 400 before insertion. The absent ledger entry maps to 404 with order_missing; the retained cancelled entry maps to 409 with order_closed. Cancellation changes state without erasing the record. A later read can therefore find that cancelled order.

    Where it stops. This is the exercise's cancellation policy, not a rule that every API must return 409 on repetition. Acceptable form fields do not establish caller authorization. The in-memory runner exercises responses, not routing or request binding; its local lock supplies no cross-server coordination.

    Cheat sheet (PDF)

    aspnet-core-validation-problem-details-companion.pdf17 pages · 97 KB
    Every page, in this page.

    Practice

    Sign in to mark lessons done and keep your place in the course.Sign in