.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.propsandglobal.jsonthat turn nullable warnings, code-quality rules and code style into build errors, and pin the SDK and test runner the agent uses. - An
AGENTS.mdfor 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
Stophook that refuses “done” untildotnet buildanddotnet testpass, and the Codex and Cursor fallbacks: a CodexStophook or a scriptedcodex execrun, 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:
| Component | Version | Source (checked 2026-09-26) |
|---|---|---|
| .NET (LTS) | .NET 10, runtime 10.0.12, SDK 10.0.401; supported until 2028-11-14 | dotnet/core releases index |
| .NET 11 | Release candidate 1 (2026-09-08), standard-term support | dotnet/core releases index |
| .NET 8 and .NET 9 | Both reach end of support on 2026-11-10 | dotnet/core releases index |
EF Core, dotnet-ef, Microsoft.AspNetCore.Mvc.Testing | 10.0.12 | NuGet |
Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.3 | NuGet |
xUnit.net v3 core framework (xunit.v3) | 4.0.1 (published 2026-09-12) | NuGet |
Testcontainers.PostgreSql | 4.15.0 | NuGet |
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, at the solution rootdotnet new tool-manifestdotnet tool install dotnet-efdotnet new install xunit.v3.templates # adds the xunit3 project templateIn 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.
# 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 doneBuild, 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:
@AGENTS.mdClaude 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?.
Codex reads AGENTS.md before it starts work. Run /init in the TUI to draft one, then replace the draft with the file above. Since codex-cli 0.150.0, a project Codex does not trust does not supply its AGENTS.md, so trust the repository when Codex asks.
Add a project rule (Cursor’s Rules feature, stored under .cursor/rules/) that applies to every agent request and tells the agent to follow AGENTS.md, or paste the Commands and Rules sections into it. Commit the rule with the repository. Cursor project rules covers the file format and how to make a rule apply to every request.
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.
-
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.
-
Ask for failing tests only. Use the first prompt below. Stop the agent after the tests compile and fail.
-
Read the failures, not the code. Each test should fail on its assertion (for example,
404instead of201), not on a missing class or a container error. A test that fails for the wrong reason will pass for the wrong reason too. -
Ask for the implementation. Use the second prompt. The loop ends when the full gate passes, not when the agent says it is done.
-
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:
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).
#!/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 0if ! 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 2fiif ! out=$(dotnet test --no-build 2>&1); then echo "dotnet test failed. Fix src/, never tests/:" >&2 echo "$out" | tail -40 >&2 exit 2fiexit 0The 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):
{ "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:
if ! out=$(dotnet test --no-build --filter-not-trait "Category=Integration" 2>&1); thenHooks automation covers the other events.
NuGet restore needs the network, and the workspace-write sandbox has no network access unless you enable it. Restore first, outside the agent, then run the loop without network:
dotnet restore --locked-modecodex exec --sandbox workspace-write "Implement POST /orders so every test in CreateOrderTests passes. Do not modify tests/. Finish only when 'dotnet build --no-restore' and 'dotnet test --no-build' both pass; print the last lines of each."If the agent must add a package, enable network for that run only with -c sandbox_workspace_write.network_access=true. This uses the legacy --sandbox system; if your config sets default_permissions, the two do not compose, so pick one (checked against codex-cli 0.157.1). Testcontainers also needs access to the Docker socket; if the sandbox blocks it, run the integration tests in CI and give the local loop the unit tests only.
For an interactive session, Codex 0.157.1 also has a Stop hook event; Codex automation covers hook registration and trust.
Paste the implementation prompt into the agent, in Plan Mode first if the change spans several projects. Put the Definition of done in the project rule so the agent runs the commands before it reports back. Cursor also has Hooks, scripts that exchange JSON with the agent over stdio, so a hook can run the same dotnet build and dotnet test commands locally; Use hooks as deterministic guardrails shows how to adapt the Claude Code script and keep it advisory until it is tested in a real Cursor session. Cursor’s own gate runs after the pull request opens: the CI job below, plus Bugbot if your team uses it for review.
Whatever runs locally, CI is the gate that decides. Run the same commands there with a read-only token:
name: dotnet-gateson: [pull_request]permissions: contents: readjobs: 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.ApiHow 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:
| Check | How | Who decides |
|---|---|---|
| Model and migrations agree | dotnet ef migrations has-pending-model-changes (EF Core 8 and later) fails when the model changed without a migration | CI |
| Migrations apply from zero | The ApiFactory fixture runs MigrateAsync() on an empty PostgreSQL | CI |
| The SQL is what you expect | dotnet ef migrations script --idempotent output attached to the pull request | A human reads the SQL, not the C# |
| Destructive operations | DropColumn, DropTable, AlterColumn that narrows a type | A 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:
| Gate | Setup | Goes red when |
|---|---|---|
| Nullable reference types | <Nullable>enable</Nullable> plus TreatWarningsAsErrors | The 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 EnforceCodeStyleInBuild | The agent ignores the team’s naming or var conventions |
| Banned APIs | Microsoft.CodeAnalysis.BannedApiAnalyzers (5.6.0) and a BannedSymbols.txt | Code calls DateTime.Now, Task.Result or anything else you list (diagnostic RS0030) |
| Architecture | NetArchTest.Rules (1.3.2, last release May 2021; TngTech.ArchUnitNET 0.13.4 is the actively maintained alternative) in a test | The domain project references infrastructure or ASP.NET Core |
| Test strength | dotnet-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:
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 blockingM:System.Threading.Tasks.Task.Wait;Await the task instead of blockingM:System.Threading.Tasks.Task.Wait(System.Int32);Await the task instead of blockingM:System.Threading.Tasks.Task.Wait(System.TimeSpan);Await the task instead of blockingM:System.Threading.Tasks.Task.Wait(System.Threading.CancellationToken);Await the task instead of blockingM:System.Threading.Tasks.Task.Wait(System.Int32,System.Threading.CancellationToken);Await the task instead of blockingM:System.Runtime.CompilerServices.TaskAwaiter.GetResult;Await the task instead of blockingM:System.Runtime.CompilerServices.TaskAwaiter`1.GetResult;Await the task instead of blockingAn 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-officialThe plugin registers the uncapped URL. To add only the server, with the cap, run this in your shell instead:
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:
dotnet tool install --global csharp-lsclaude plugin install csharp-lsp@claude-plugins-officialcsharp-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.
codex mcp add microsoft-learn --url "https://learn.microsoft.com/api/mcp?maxTokenBudget=2000"Add the server to .cursor/mcp.json:
{ "mcpServers": { "microsoft-learn": { "url": "https://learn.microsoft.com/api/mcp?maxTokenBudget=2000" } } }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.