Skip to content

.NET and C# recipes

.NET and C# recipes for AI coding agents are a repository setup plus prompts that make Claude Code, Codex and Cursor ship trustworthy ASP.NET Core and EF Core changes: one AGENTS.md with the exact dotnet commands, xUnit v3 tests written before the code and run against real PostgreSQL, and Roslyn analyzers configured so a bad pattern fails the build.

This page is for C# developers who already let an agent write most of a service. The situation it fixes: the agent’s pull request compiles and its tests are green, but the tests use the EF Core in-memory provider, the new migration drops a column it meant to rename, a .Result blocks a thread in the middle of a request, and a fresh #pragma warning disable hides the one analyzer warning that would have caught it. C# gives you a compiler and an analyzer pipeline that most languages lack. The recipes below make the agent run into them on every turn, so you review evidence instead of diffs. If the repository has no AGENTS.md or CI gate yet, start with Agent-ready codebase.

What you’ll walk away with from these .NET recipes

Section titled “What you’ll walk away with from these .NET recipes”
  • A Directory.Build.props and global.json that turn nullable warnings, code-quality rules and code style into build errors, and pin the SDK and test runner the agent uses.
  • An AGENTS.md for a .NET solution, wired into Claude Code, Codex and Cursor.
  • A test-first loop for an ASP.NET Core endpoint: acceptance criteria, failing xUnit v3 tests on a Testcontainers PostgreSQL database, then the implementation.
  • A Claude Code Stop hook that refuses “done” until dotnet build and dotnet test pass, and the Codex and Cursor fallbacks: a Codex Stop hook or a scripted codex exec run, and the CI gate.
  • An EF Core migration recipe with a drift check and a human sign-off on destructive changes.
  • A banned-API list, an architecture test and a mutation-testing run that catch what agents get wrong in C#.
  • The failure modes specific to .NET agent work, with the prompt that recovers from each.

Which .NET, EF Core and xUnit versions do these recipes assume?

Section titled “Which .NET, EF Core and xUnit versions do these recipes assume?”

Agents are trained on years of .NET samples, so they mix APIs from different major versions. Tell the agent the versions, and pin them in the repository so it cannot drift. These were current on 2026-09-26:

ComponentVersionSource (checked 2026-09-26)
.NET (LTS).NET 10, runtime 10.0.12, SDK 10.0.401; supported until 2028-11-14dotnet/core releases index
.NET 11Release candidate 1 (2026-09-08), standard-term supportdotnet/core releases index
.NET 8 and .NET 9Both reach end of support on 2026-11-10dotnet/core releases index
EF Core, dotnet-ef, Microsoft.AspNetCore.Mvc.Testing10.0.12NuGet
Npgsql.EntityFrameworkCore.PostgreSQL10.0.3NuGet
xUnit.net v3 core framework (xunit.v3)4.0.1 (published 2026-09-12)NuGet
Testcontainers.PostgreSql4.15.0NuGet

The recipes target .NET 10. If you are still on .NET 8 or 9, the upgrade is the first agent task worth running, because both lose support in November.

How do you make a .NET solution agent-ready?

Section titled “How do you make a .NET solution agent-ready?”

Put the gates in MSBuild, not in a prompt. An instruction such as “write clean code” is forgotten by the next session. A property in Directory.Build.props applies to every project, every session and every CI run. Three files at the solution root do most of the work.

Directory.Build.props turns warnings into errors and raises the analyzer bar:

<!-- Directory.Build.props: applies to every project under this folder -->
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>

AnalysisLevel set to latest-recommended enables the recommended set of CA code-quality rules. EnforceCodeStyleInBuild makes the IDE code-style rules that your .editorconfig sets to warning or error run on dotnet build, so they reach the agent’s terminal and not only the IDE. RestorePackagesWithLockFile writes packages.lock.json, which lets CI restore with --locked-mode and fail when the agent changes a dependency without committing the lock file.

global.json pins the SDK and puts dotnet test on Microsoft Testing Platform (MTP), the mode xUnit v3’s documentation describes for .NET SDK 10 and later:

{
"sdk": { "version": "10.0.401", "rollForward": "latestFeature" },
"test": { "runner": "Microsoft.Testing.Platform" }
}

A local tool manifest gives the agent the same dotnet ef version as CI, instead of whatever is installed globally:

Terminal window
# terminal, at the solution root
dotnet new tool-manifest
dotnet tool install dotnet-ef
dotnet new install xunit.v3.templates # adds the xunit3 project template

In MTP mode, dotnet test takes a project with --project and a solution with --solution instead of a bare path, and xUnit v3 filters become options such as --filter-class. Agents trained on VSTest-era samples write dotnet test MyProject.csproj --filter …, so put the new spelling in AGENTS.md.

What goes in AGENTS.md for a .NET repository?

Section titled “What goes in AGENTS.md for a .NET repository?”

Write the commands the agent must run, in the order it must run them, and the few rules a generic C# model gets wrong in your codebase. Keep it short; everything that MSBuild already enforces stays out.

AGENTS.md
# Orders service (.NET 10, ASP.NET Core minimal APIs, EF Core 10, PostgreSQL)
## Commands (run from the repo root)
- Restore: `dotnet restore --locked-mode` (commit packages.lock.json if you add a package)
- Build: `dotnet build --no-restore` (warnings are errors; fix the code, never add NoWarn or #pragma)
- Test all: `dotnet test --no-build` (needs Docker: integration tests start PostgreSQL with Testcontainers)
- Test one class: `dotnet test --project tests/Orders.Api.Tests --filter-class Orders.Api.Tests.CreateOrderTests`
- Format: `dotnet format --verify-no-changes`
- Migration drift: `dotnet ef migrations has-pending-model-changes --project src/Orders.Infrastructure --startup-project src/Orders.Api`
## Definition of done
Build, tests, format and the drift check all pass. Report the commands you ran and their result.
## Rules
- Tests are the specification. If a test fails, fix src/, not tests/. Ask before changing an assertion.
- Integration tests use the ApiFactory fixture (real PostgreSQL). Never use the EF Core in-memory provider.
- Package versions live in Directory.Packages.props. Never put Version= on a PackageReference.
- Time comes from TimeProvider, never DateTime.Now or DateTime.UtcNow.
- async all the way down: no .Result, .Wait() or GetAwaiter().GetResult().
- Read-only queries use AsNoTracking(). Queries that Include two or more collections use AsSplitQuery().
- Never edit an existing migration. Add a new one.

Each tool loads the same file differently:

Create a CLAUDE.md whose first line imports the shared file, then add any Claude-only lines below it:

CLAUDE.md
@AGENTS.md

Claude Code 2.1.277 and later also read AGENTS.md directly when a project has no CLAUDE.md (v2.1.281 on Bedrock, Google Cloud, Foundry and LLM gateways; both on the latest release channel on 2026-09-26). The import works on every version and channel. For code navigation, add the C# language server plugin described in How do you give the agent current .NET docs and code navigation?.

Shared agent rules explains how to keep one source of truth when a team uses all three tools.

How do you run a test-first loop for an ASP.NET Core endpoint?

Section titled “How do you run a test-first loop for an ASP.NET Core endpoint?”

Specify the behaviour, have the agent write failing tests, check that they fail for the right reason, and only then let it write the implementation. The tests become the oracle you trust, so they must hit the same database engine as production. EF Core’s in-memory provider does not enforce unique indexes, foreign keys or transactions, so a test on it proves nothing about the behaviour you care about most.

  1. Write the acceptance criteria. Three to six observable behaviours, each one a sentence a test can check. Acceptance criteria an agent can verify shows the format.

  2. Ask for failing tests only. Use the first prompt below. Stop the agent after the tests compile and fail.

  3. Read the failures, not the code. Each test should fail on its assertion (for example, 404 instead of 201), not on a missing class or a container error. A test that fails for the wrong reason will pass for the wrong reason too.

  4. Ask for the implementation. Use the second prompt. The loop ends when the full gate passes, not when the agent says it is done.

  5. Check test strength. Run mutation testing on the changed code (see Which analyzers and tests turn review comments into build failures?).

Check the fixture the agent writes against this shape. xUnit v3 changed IAsyncLifetime to ValueTask, and Testcontainers 4.x marks the parameterless builder constructor obsolete, so older patterns fail the build here. Set the image tag to the PostgreSQL major version you run in production; postgres:17-alpine is only the example:

tests/Orders.Api.Tests/ApiFactory.cs
public sealed class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder("postgres:17-alpine").Build();
public async ValueTask InitializeAsync()
{
await _db.StartAsync();
using var scope = Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<OrdersDbContext>().Database.MigrateAsync();
}
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.UseSetting("ConnectionStrings:Orders", _db.GetConnectionString());
public override async ValueTask DisposeAsync()
{
await _db.DisposeAsync();
await base.DisposeAsync();
}
}

The fixture applies your real migrations, so every integration run also proves that the migrations apply to an empty database. If the test project cannot see Program, the agent may add public partial class Program; at the end of Program.cs; that is the only change to src/ the test step should make.

Protect the oracle explains why the “do not modify tests” instruction needs a mechanical backstop as well, which the next section adds.

How do you stop the agent from declaring done before the build is green?

Section titled “How do you stop the agent from declaring done before the build is green?”

An instruction in AGENTS.md is advice. A hook or a CI check is a gate. Each tool gives you a different way to make the gate run on every turn.

A Stop hook runs when Claude finishes responding. Exit code 2 prevents the stop and shows the hook’s stderr to Claude, which then keeps working. Claude Code overrides the block after eight consecutive continuations, so a loop cannot run forever; the CLAUDE_CODE_STOP_HOOK_BLOCK_CAP environment variable sets that cap (default 8; checked against Claude Code 2.1.283, still present in 2.1.286).

.claude/hooks/dotnet-gate.sh
#!/usr/bin/env bash
# Refuse "done" until the solution builds without warnings and the tests pass.
cd "$CLAUDE_PROJECT_DIR" || exit 0
# Skip turns that changed no C#, project or gate files (a question, a plan).
[ -z "$(git status --porcelain -- '*.cs' '*.csproj' '*.props' '*.targets' '*.sln' '*.slnx' '.editorconfig' 'BannedSymbols.txt' 'global.json' 'packages.lock.json')" ] && exit 0
if ! out=$(dotnet build --no-restore 2>&1); then
echo "dotnet build failed. Fix the code; do not add NoWarn or #pragma." >&2
echo "If the error is NETSDK1004 or NU1xxx, run 'dotnet restore --locked-mode' (or update packages.lock.json) first." >&2
echo "$out" | grep -E ": (error|warning) " | sort -u | head -30 >&2
exit 2
fi
if ! out=$(dotnet test --no-build 2>&1); then
echo "dotnet test failed. Fix src/, never tests/:" >&2
echo "$out" | tail -40 >&2
exit 2
fi
exit 0

The skip test looks at uncommitted changes only: if the agent commits during the turn the gate does not run, so tell it not to commit (or compare against the session’s starting HEAD). A tree that was already dirty runs the full gate on every turn, including pure questions.

Register it in the project settings and make the script executable (chmod +x):

.claude/settings.json
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/dotnet-gate.sh", "timeout": 600 }
]
}
]
}
}

The script deliberately ignores stop_hook_active, the field Claude Code sets in the hook input once a stop has already been blocked. Exiting 0 whenever it is true would let the second attempt through with the build still red; the eight-block cap is the loop guard instead, so a stubborn failure can cost about nine build and test runs before Claude Code stops anyway.

The hook costs a full build and test run on every turn that changed C# or project files, including Testcontainers, so keep Docker running during the session. If that is too slow locally, run only the tests that need no Docker and leave the full suite to CI. Under MTP the VSTest --filter spelling does not apply; use an xUnit v3 trait filter instead. Tag each integration test class with [Trait("Category", "Integration")], then change the test line in dotnet-gate.sh:

Terminal window
if ! out=$(dotnet test --no-build --filter-not-trait "Category=Integration" 2>&1); then

Hooks automation covers the other events.

Whatever runs locally, CI is the gate that decides. Run the same commands there with a read-only token:

.github/workflows/dotnet-gates.yml
name: dotnet-gates
on: [pull_request]
permissions:
contents: read
jobs:
gates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- run: dotnet restore --locked-mode
- run: dotnet tool restore
- run: dotnet format --verify-no-changes --no-restore
- run: dotnet build --no-restore
- run: dotnet test --no-build
- run: dotnet ef migrations has-pending-model-changes --project src/Orders.Infrastructure --startup-project src/Orders.Api

How do you handle EF Core migrations with an agent?

Section titled “How do you handle EF Core migrations with an agent?”

Migrations are the one place where a green test suite is not enough evidence. The agent can generate a migration that applies cleanly to an empty test database and still destroys data in production. The classic case: the agent renames a property, EF Core generates a DropColumn plus an AddColumn, the tests pass on a fresh database, and the column’s data is gone on the first deploy.

Split the work between gates and a person:

CheckHowWho decides
Model and migrations agreedotnet ef migrations has-pending-model-changes (EF Core 8 and later) fails when the model changed without a migrationCI
Migrations apply from zeroThe ApiFactory fixture runs MigrateAsync() on an empty PostgreSQLCI
The SQL is what you expectdotnet ef migrations script --idempotent output attached to the pull requestA human reads the SQL, not the C#
Destructive operationsDropColumn, DropTable, AlterColumn that narrows a typeA human, through CODEOWNERS on **/Migrations/

For the wider rules on expand-and-contract changes and rollbacks, see database migration patterns. EF Core 11 adds a .config/dotnet-ef.json file for default --project and --startup-project values; on EF Core 10, keep the flags in AGENTS.md.

Which analyzers and tests turn review comments into build failures?

Section titled “Which analyzers and tests turn review comments into build failures?”

Every comment you type twice on an agent’s pull request is a rule you have not automated yet. In C#, most of them can become a compiler diagnostic or a test. Each gate below goes red for a specific bug:

GateSetupGoes red when
Nullable reference types<Nullable>enable</Nullable> plus TreatWarningsAsErrorsThe agent dereferences a value that can be null
CA code-quality rules<AnalysisLevel>latest-recommended</AnalysisLevel>Code breaks a rule in the recommended CA set (design, reliability, performance, security and usage rules)
Code style.editorconfig severities plus EnforceCodeStyleInBuildThe agent ignores the team’s naming or var conventions
Banned APIsMicrosoft.CodeAnalysis.BannedApiAnalyzers (5.6.0) and a BannedSymbols.txtCode calls DateTime.Now, Task.Result or anything else you list (diagnostic RS0030)
ArchitectureNetArchTest.Rules (1.3.2, last release May 2021; TngTech.ArchUnitNET 0.13.4 is the actively maintained alternative) in a testThe domain project references infrastructure or ASP.NET Core
Test strengthdotnet-stryker (5.0.0)Tests still pass after Stryker mutates the code under test

The banned-API list is the most direct way to encode “we never do that here”. Add the package to the projects under src/, add <AdditionalFiles Include="BannedSymbols.txt" />, and list documentation IDs with a message the agent will read:

BannedSymbols.txt
P:System.DateTime.Now;Inject TimeProvider and call GetUtcNow()
P:System.DateTime.UtcNow;Inject TimeProvider and call GetUtcNow()
P:System.Threading.Tasks.Task`1.Result;Await the task instead of blocking
M:System.Threading.Tasks.Task.Wait;Await the task instead of blocking
M:System.Threading.Tasks.Task.Wait(System.Int32);Await the task instead of blocking
M:System.Threading.Tasks.Task.Wait(System.TimeSpan);Await the task instead of blocking
M:System.Threading.Tasks.Task.Wait(System.Threading.CancellationToken);Await the task instead of blocking
M:System.Threading.Tasks.Task.Wait(System.Int32,System.Threading.CancellationToken);Await the task instead of blocking
M:System.Runtime.CompilerServices.TaskAwaiter.GetResult;Await the task instead of blocking
M:System.Runtime.CompilerServices.TaskAwaiter`1.GetResult;Await the task instead of blocking

An architecture test keeps a layered solution layered after 50 agent sessions:

[Fact]
public void Domain_does_not_depend_on_infrastructure_or_web()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.ShouldNot().HaveDependencyOnAny("Orders.Infrastructure", "Microsoft.AspNetCore")
.GetResult();
Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames ?? []));
}

Mutation testing answers the question a green run cannot: would these tests notice if the code were wrong? Run dotnet tool install dotnet-stryker, then dotnet stryker in the test project’s folder, and read the surviving mutants. Oracle strength explains how to turn survivors into missing test cases.

Changes to Directory.Build.props, .editorconfig, BannedSymbols.txt and any NoWarn entry weaken the gates themselves, so give those paths a human owner in CODEOWNERS. The evidence bundle page shows how to attach gate results and the migration SQL to the pull request so the reviewer checks proof, not code.

How do you give the agent current .NET docs and code navigation?

Section titled “How do you give the agent current .NET docs and code navigation?”

Two additions help most in .NET: current Microsoft documentation, because APIs change between major versions, and a language server, because text search is a poor way to find every caller in a large solution.

Microsoft Learn MCP server. A remote server at https://learn.microsoft.com/api/mcp, no authentication, with three tools: microsoft_docs_search, microsoft_docs_fetch and microsoft_code_sample_search. The ?maxTokenBudget=2000 query parameter caps how much each answer adds to context; the Codex and Cursor snippets below include it. Popularity: about 1.9k GitHub stars on MicrosoftDocs/mcp (GitHub, read 2026-09-26).

Install Microsoft’s plugin, which brings the MCP server and its skills together (run inside a session):

/plugin install microsoft-docs@claude-plugins-official

The plugin registers the uncapped URL. To add only the server, with the cap, run this in your shell instead:

Terminal window
claude mcp add --transport http microsoft-learn "https://learn.microsoft.com/api/mcp?maxTokenBudget=2000"

For code navigation, install the C# language server and the plugin that registers it. The plugin does not bundle the binary:

Terminal window
dotnet tool install --global csharp-ls
claude plugin install csharp-lsp@claude-plugins-official

csharp-lsp had 43,741 installs in the claude.com plugin directory (read 2026-09-26). LSP plugins add no always-on context, because the server runs out of process.

Before the server, the agent fills gaps from training data and writes, for example, the parameterless Testcontainers builder or VSTest-style dotnet test filters. With it, one prompt checks the current API first:

The microsoft/skills README catalogues 175 SDK skills in language plugins for Python, .NET, TypeScript, Java and Rust. They sit below the repository root, so npx skills add microsoft/skills --list shows only 13 root skills; with --full-depth it finds 192, the SDK set plus the root and other nested skills (checked 2026-09-26). The README also warns that loading all of them causes context rot, so install only the Azure SDK skills your service uses. The best backend and platform skills page compares them with the other vendor sets.