Sharpyard
← Back to the blog

June 21, 2026

How to add a feature to a .NET 10 minimal API with Claude Code, test-first (a 20-minute worked example)

A complete, runnable walkthrough: start a .NET 10 minimal API that Claude Code understands, then add a real feature test-first - prompt, red contract test, thin endpoint, green - in about 20 minutes.

The fastest way to get real work out of Claude Code on a .NET 10 project is to give it a small, well-shaped codebase, a CLAUDE.md that states the rules, and contract tests it can trust - then drive it test-first. This is a complete worked example of exactly that: we take a .NET 10 minimal API and add a real feature (a priority field on notes plus a filter), test-first, in about 20 minutes. Every command and code block below is runnable against the free reference repo github.com/Khavel/dotnet-claude-starter, which is the .NET 10 minimal API this walkthrough uses.

How do you start a .NET 10 project that Claude Code actually understands?

You give the agent three things up front: a CLAUDE.md at the repo root with the architecture map and the rules, a curated .mcp.json so it uses real tools instead of guessing, and contract tests that pin behavior. With those in place, the agent reads the rules before it writes a line, and a focused diff against a green test suite is what you review.

Start from the free starter, which already ships all three wired to a tiny .NET 10 API:

git clone https://github.com/Khavel/dotnet-claude-starter.git
cd dotnet-claude-starter
dotnet build      # warnings are errors here, on purpose
dotnet test       # the contract tests should be green

The repo pins the SDK in global.json (10.0.108, rolling forward to the latest 10.0.1xx feature band), so everyone - you and the agent - builds against the same .NET 10 toolchain. Then launch Claude Code from the repo root so it picks up CLAUDE.md and .mcp.json automatically:

claude

What goes in CLAUDE.md for a .NET 10 minimal API?

CLAUDE.md is the file Claude Code reads automatically, so it is where you put the architecture map, the conventions, the exact commands, and a hard definition of done. Keep it to a few hundred tokens - an agent only follows what it can actually read in context. The starter’s file says, in effect: this is an in-memory Notes API; Program.cs is the composition root, read it first; Notes/ is the one feature to copy; tests are contract tests that boot the real app. The convention block is the load-bearing part:

## Conventions
- C#: nullable reference types on, file-scoped namespaces, `records` for data,
  `sealed` classes by default, latest language version.
- Endpoints are thin: validate -> call the store/service -> map a result.
  No business logic in the endpoint lambda.
- Persistence stays behind `INoteStore`. Endpoints never touch storage directly.
- No new NuGet package without a one-line reason. This stays lean on purpose.

## Definition of done (check every box)
- [ ] `dotnet build` and `dotnet test` are green.
- [ ] New/changed behavior is covered by a contract test.
- [ ] No endpoint holds business logic or touches storage directly.
- [ ] `dotnet format` reports no changes.

Those rules are not decoration. “Endpoints are thin” and “persistence stays behind INoteStore” are what stop the agent from inventing a layered architecture you never asked for, and the definition-of-done checklist is what it self-checks against before it tells you a change is done.

What does the .NET 10 starter look like before the change?

The whole API is one feature folder plus a composition root, so the agent (and you) can hold it in context. The Notes/ folder is the template to copy: a model, a persistence interface with one in-memory implementation, and thin endpoints.

The model is a pair of records:

namespace Api.Notes;

/// <summary>A single note. Immutable; the store is the only thing that creates these.</summary>
public record Note(Guid Id, string Title, string Body, DateTimeOffset CreatedAt);

/// <summary>Inbound payload for creating a note. Fields are nullable because JSON may omit them.</summary>
public record CreateNoteRequest(string? Title, string? Body);

Persistence sits behind an interface so the implementation can be swapped without touching the routes:

namespace Api.Notes;

public interface INoteStore
{
    IReadOnlyList<Note> All();
    Note? Find(Guid id);
    Note Add(string title, string body);
}

The endpoints are thin - validate, call the store, map a result - and registered in Program.cs:

group.MapGet("/", (INoteStore store) => Results.Ok(store.All()));

group.MapGet("/{id:guid}", (Guid id, INoteStore store) =>
    store.Find(id) is { } note ? Results.Ok(note) : Results.NotFound());

Program.cs is the composition root that wires it together with .NET 10 minimal hosting:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddSingleton<INoteStore, InMemoryNoteStore>();

var app = builder.Build();
if (app.Environment.IsDevelopment())
    app.MapOpenApi(); // OpenAPI document at /openapi/v1.json

app.MapNotesEndpoints();
app.Run();

// Exposed so the test project can boot the app in-memory via WebApplicationFactory<Program>.
public partial class Program { }

That last public partial class Program { } is the small but important detail that makes the whole test-first loop possible, which the next section uses.

How do you write a contract test that boots the real .NET 10 app?

You boot the actual application in-memory with WebApplicationFactory<Program> and drive it over HTTP, so the test pins observable behavior rather than internals. That is what makes it a safety net the agent can refactor against: if the contract tests stay green, the API still does what callers expect. The starter’s tests use xUnit with a primary constructor and IClassFixture:

using System.Net;
using System.Net.Http.Json;
using Api.Notes;
using Microsoft.AspNetCore.Mvc.Testing;

namespace Api.Tests;

public class NotesApiTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Posting_a_note_then_fetching_it_round_trips()
    {
        var client = factory.CreateClient();

        var created = await client.PostAsJsonAsync(
            "/api/notes", new { title = "Ship the lead magnet", body = "Built by driving the agent." });
        Assert.Equal(HttpStatusCode.Created, created.StatusCode);

        var note = await created.Content.ReadFromJsonAsync<Note>();
        Assert.NotNull(note);

        var fetched = await client.GetFromJsonAsync<Note>($"/api/notes/{note!.Id}");
        Assert.Equal(note.Id, fetched!.Id);
    }
}

WebApplicationFactory<Program> (from Microsoft.AspNetCore.Mvc.Testing) starts the app with its real dependency injection and routing, just without a network socket. No mocks, no test doubles for the store - the in-memory store is the real one. That is exactly the kind of test an agent should write first.

The worked example: add a priority field, test-first

Here is the real change, the one from the repo’s own docs/add-a-feature-in-20-min.md. The feature is small on purpose; the point is the loop. You give Claude Code one prompt:

Add an optional priority field to notes (low | normal | high, default normal) and a GET /api/notes?priority=high filter. Write the contract test first. Keep endpoints thin and validation out of the store.

Step 1 - the agent writes a failing contract test (red)

Following the CLAUDE.md rule “contract test first”, the agent adds a test that asserts the new behavior before any production code exists:

[Fact]
public async Task Notes_can_be_filtered_by_priority()
{
    var client = factory.CreateClient();
    await client.PostAsJsonAsync("/api/notes", new { title = "Urgent", body = "", priority = "high" });
    await client.PostAsJsonAsync("/api/notes", new { title = "Whenever", body = "" });

    var high = await client.GetFromJsonAsync<List<Note>>("/api/notes?priority=high");

    Assert.NotNull(high);
    Assert.All(high!, n => Assert.Equal("high", n.Priority));
}

dotnet test now fails - it does not even compile yet, because Note has no Priority. That is the point: the red test is the precise, machine-checkable definition of “done” for this change.

Step 2 - touch the model

Records make the model change a one-liner. The agent adds the field and the optional request field:

public record Note(Guid Id, string Title, string Body, string Priority, DateTimeOffset CreatedAt);
public record CreateNoteRequest(string? Title, string? Body, string? Priority);

Step 3 - validation stays out of the store, filtering stays behind the interface

The CLAUDE.md guardrails decide where each piece of logic lives. Normalizing and validating the priority is a rule, so it sits next to the other validation in the POST handler; filtering is a storage concern, so it goes behind INoteStore. First the interface gains the filter and the store implements it:

public interface INoteStore
{
    IReadOnlyList<Note> All(string? priority = null);
    Note? Find(Guid id);
    Note Add(string title, string body, string priority);
}
public IReadOnlyList<Note> All(string? priority = null) =>
    _notes.Values
        .Where(n => priority is null || n.Priority == priority)
        .OrderByDescending(n => n.CreatedAt)
        .ToList();

Then the endpoints stay thin. The GET passes the filter straight through; the POST normalizes and validates the allowed values before it ever reaches the store:

group.MapGet("/", (string? priority, INoteStore store) => Results.Ok(store.All(priority)));

group.MapPost("/", (CreateNoteRequest request, INoteStore store) =>
{
    var title = request.Title?.Trim() ?? string.Empty;
    var body = request.Body?.Trim() ?? string.Empty;
    var priority = request.Priority?.Trim().ToLowerInvariant() ?? "normal";

    var errors = new Dictionary<string, string[]>();
    if (title.Length == 0)
        errors["title"] = ["Title is required."];
    if (priority is not ("low" or "normal" or "high"))
        errors["priority"] = ["Priority must be low, normal, or high."];

    if (errors.Count > 0)
        return Results.ValidationProblem(errors);

    var note = store.Add(title, body, priority);
    return Results.Created($"/api/notes/{note.Id}", note);
});

Notice what did not happen: no business logic leaked into the route, and the endpoint never touched the dictionary directly. That is the convention holding, because the agent read it before it wrote.

Step 4 - green, format, done

dotnet test       # all green, including the new filter test
dotnet format     # reports no changes

The agent runs the Definition of done checklist from CLAUDE.md and ticks every box: build and tests green, new behavior covered by a contract test, no business logic in the endpoint, no new package, formatter clean. You review one focused diff against a passing suite - not 200 lines of speculative scaffolding. That is the whole 20 minutes.

Why is .NET 10 a good fit for this agent-first loop?

A .NET 10 minimal API gives an agent unusually strong guardrails: nullable reference types and TreatWarningsAsErrors turn whole classes of mistakes into build failures the agent must fix before it can claim done, and WebApplicationFactory<Program> makes fast, real contract tests trivial. The agent does not get to hand-wave a green build. The compiler and the test suite are two independent checks it has to satisfy, and both are cheap to run in the loop. That is the strongly typed, test-friendly nature of modern .NET working in your favor instead of being a chore.

The starter leans into this deliberately: warnings are errors (Directory.Build.props), the SDK is pinned, and the contract tests boot the real app. None of it is exotic - it is the same discipline a senior .NET developer already values, arranged so an agent can lean on it.

FAQ

Do I need the full starter repo, or just a CLAUDE.md? You can add a CLAUDE.md to any .NET 10 solution and get most of the value immediately. The starter just saves you the setup: it ships the CLAUDE.md, a curated .mcp.json, and WebApplicationFactory<Program> contract tests already wired to a working .NET 10 minimal API at github.com/Khavel/dotnet-claude-starter. Clone it, read the shape, copy it into your own repo.

Why test-first instead of letting the agent write code then tests? A red contract test is an exact, machine-checkable specification of the change. The agent writes against that target and you get a green suite to review, instead of trusting a diff and hoping the after-the-fact tests actually exercise the new behavior.

Does this work on Windows with .NET 10? Yes. Claude Code runs natively in PowerShell, and the dotnet CLI, dotnet test, and dotnet format are the same commands the agent uses. The global.json pin keeps your machine and the agent on the same .NET 10 SDK.

Is 20 minutes realistic? For a change this size against a clean, well-tested codebase, yes - most of the wall-clock time is the agent running dotnet test and dotnet format, not you typing. The number scales with the codebase, but the loop (prompt, red test, thin implementation, green) is the same on a large solution; it is just more iterations.

What MCP servers does the starter use? A deliberately small set - filesystem and git in .mcp.json, plus notes on adding a Roslyn-aware C# server when you want the agent to reason about the solution semantically rather than as text. Add servers when you have a concrete need, not speculatively.

About

Written by Khavel, who builds AI-agent-native .NET tooling. The free, open-source reference for this workflow is github.com/Khavel/dotnet-claude-starter - a minimal .NET 10 API wrapped in the operating layer (CLAUDE.md, .mcp.json, contract tests) this article walks through. Clone it and drive your agent through the same loop.


Building a production .NET 10 SaaS this way, agent-first? The starter is the free taste of the workflow; the full kit scales it to auth, multi-tenancy, and billing with the same test-first, thin-endpoints discipline. Join the founding waitlist if that is the foundation you want.

Related: Claude Code for .NET developers and MCP for .NET.

The full AI-native .NET SaaS starter kit.

Join the waitlist