Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.
A quote is a bounded calculation rather than a persisted order
The capstone composes real ASP.NET features around a deliberately small calculation. It does not create an order, charge money, authenticate a customer or persist data. The HTTP boundary binds a request and performs generated model validation; scoped application work then checks catalog existence and an operational quantity limit before computing a receipt. The portable companion independently demonstrates three business outcomes with no framework substitutes. This narrow scope keeps each observed result explainable while showing how routing, validation, dependency ownership, options, logging, cancellation and response metadata cooperate in a genuine external web application.
Trace a valid quote and three independent rejections
The catalog recognizes rune at a price of twelve point five. Quantity two satisfies both the annotation range and configured limit three, so the external response contains total twenty-five with status 200. Quantity zero fails model validation with 400 before application work. A validly shaped missing item reaches the catalog lookup and produces 404. Rune quantity four passes the annotation range of one through ten but violates the configured business limit and produces a 409 problem. The console prints accepted:25.00, missing and limit exceeded for its three explicitly selected domain cases.
Keep shared data immutable and request work scoped
Catalog is a singleton with a fixed lookup, while QuoteService is scoped and receives the validated options and its logger through real dependency injection. The request token reaches the service and is checked before the calculation. No asynchronous database or remote operation exists here, so do not infer a cancellation integration the project never performs. The service is called through this validated endpoint; another adapter would need to establish its own input preconditions. The portable project intentionally provides independent domain rejection checks rather than assuming that HTTP annotations automatically protect every possible direct invocation of application code.
Keep model validation and operational policy distinct
The DTO can represent quantities one through ten, while configuration currently allows only three for this quote operation. Startup options validation keeps that operational value within its intended range. This distinction lets an application policy change without pretending a structurally valid model was malformed. The endpoint declares typed result alternatives and explicitly documents validation problems and the 409 business conflict. Actual generated OpenAPI was checked for 200, 400, 404 and 409 after adding that metadata. A successful document still does not prove persistence, identity checks or business behavior beyond the independent requests that exercise those branches.
Practice: change the operational limit without changing the DTO
Set Quote MaximumQuantity to two in a separate server run, keeping the annotation range unchanged. Specify results for rune quantities one, two and three before sending requests. Then set an invalid operational maximum such as zero and verify startup fails rather than waiting for the first quote. Keep the portable policy project synchronized with independently intended rules, not with copied conditional text. The hint is to distinguish a model that can be bound and validated from an operation permitted by the current configuration. An input can satisfy the former and still receive a deliberate conflict from the latter.
Practice: add a second catalog item with an independent price
Extend the fixed catalog with one new item whose known price differs from rune. Specify exact decimal totals for quantities one and two, then check both the portable domain policy and real endpoint responses. Preserve separate tests for unknown names, case sensitivity and the configured maximum. Update OpenAPI only if the wire contract changes; a catalog fixture change is not automatically a new JSON schema. Do not add database writes as an incidental extension. A persisted order would need its own transaction, authorization, identity and retry semantics beyond this read-only quote calculation.
Review composed evidence without expanding the claim
The final project is useful because each boundary has a concrete counterexample and a real owner. Model validation rejects invalid structure, catalog lookup rejects absence, configuration limits accepted work, typed results expose outcomes and a generated document describes the supported surface. Structured logs observe accepted quotes without becoming the business result. The console keeps the calculation inspectable, while external requests exercise the actual ASP.NET pipeline. Preserve both when refactoring. Production adaptation requires explicit decisions about persistence, authentication, authorization, resilience and deployment; those additions should be tested as new capabilities rather than assumed from the presence of a modern WebSDK project.
- Check 200, validation400, missing404 and business409 independently against the generated contract.
- Keep a quote calculation distinct from persisted ordering, payment and authenticated resource ownership.
Capstone: a validated quote API — real ASP.NET Core 10 project
Set up the SDK project
- Save all files into one new empty directory, preserving their relative paths. Install the official .NET 10 SDK if it is not already available.
- From that directory run: dotnet restore WebLab.csproj --source https://api.nuget.org/v3/index.json
- Start only on loopback: ASPNETCORE_ENVIRONMENT=Development dotnet run --project WebLab.csproj --no-launch-profile --urls http://127.0.0.1:5080
- Use another terminal for the requests below, then press Ctrl+C in this project’s terminal to stop its server.
- POST each independent case to /quotes as application/json; compare 200, 400, 404 and 409, then inspect /openapi/v1.json. This is an in-memory quote calculation, not an order-processing service.
Download complete SDK project ↗
Project files
WebLab.csproj
Download file ↗<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.14" />
</ItemGroup>
</Project>
Program.cs
Download file ↗using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation(); builder.Services.AddProblemDetails(); builder.Services.AddOpenApi();
builder.Services.AddOptions<QuoteLimits>().Bind(builder.Configuration.GetSection("Quote")).Validate(x => x.MaximumQuantity is >= 1 and <= 10, "MaximumQuantity must be 1 through 10").ValidateOnStart();
builder.Services.AddSingleton<Catalog>(); builder.Services.AddScoped<QuoteService>();
var app = builder.Build(); app.UseExceptionHandler(); if (app.Environment.IsDevelopment()) app.MapOpenApi();
app.MapPost("/quotes", Results<Ok<Receipt>, NotFound, ProblemHttpResult> (QuoteRequest request, QuoteService service, CancellationToken token) => service.Create(request, token)).ProducesValidationProblem().ProducesProblem(StatusCodes.Status409Conflict).WithName("CreateQuote"); app.Run();
public sealed class QuoteRequest { [Required, MinLength(2)] public string Item { get; set; } = ""; [Range(1, 10)] public int Quantity { get; set; } }
public sealed class QuoteLimits { public int MaximumQuantity { get; set; } }
public sealed record Receipt(string Item, int Quantity, decimal Total);
public sealed class Catalog { public bool TryPrice(string name, out decimal price) { price = 12.5m; return string.Equals(name, "rune", StringComparison.Ordinal); } }
public sealed class QuoteService(Catalog catalog, IOptions<QuoteLimits> options, ILogger<QuoteService> logger) {
public Results<Ok<Receipt>, NotFound, ProblemHttpResult> Create(QuoteRequest request, CancellationToken token) {
token.ThrowIfCancellationRequested();
if (!catalog.TryPrice(request.Item, out decimal price)) return TypedResults.NotFound();
if (request.Quantity > options.Value.MaximumQuantity) return TypedResults.Problem(statusCode: 409, title: "Configured quantity limit exceeded");
decimal total = checked(price * request.Quantity); logger.LogInformation("Quoted {Quantity} units of {CatalogItem}", request.Quantity, request.Item);
return TypedResults.Ok(new Receipt(request.Item, request.Quantity, total));
}
}
Expected result
POST /quotes with rune quantity 2 returns 200 total 25; quantity 0 is 400 validation; missing item is 404; rune quantity 4 is 409 ProblemDetails. /openapi/v1.json is available in Development. Nothing is persisted or charged.
Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.
C# companion practice
using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
Console.WriteLine(Decide("rune", 2)); Console.WriteLine(Decide("missing", 2)); Console.WriteLine(Decide("rune", 4));
static string Decide(string item, int quantity) { if (quantity < 1) return "invalid"; if (item != "rune") return "missing"; if (quantity > 3) return "limit exceeded"; return "accepted:" + (12.5m * quantity).ToString("0.00", CultureInfo.InvariantCulture); }
C# practice output
accepted:25.00 missing limit exceeded
Binding, annotations, catalog existence and configured business limits establish different response branches.