Claude Code for C# and .NET: Does a CLAUDE.md Help?

Last updated November 9, 2026 · Claude Code 2.1.319 (model claude-opus-5-5, effort medium) · .NET SDK 10.0.401 · Windows 11

Short answer: a short CLAUDE.md didn’t make Claude Code write better C#. It changed how Claude Code works. I ran six C#/.NET 10 tasks 36 times: prompt only, prompt plus a 30-line CLAUDE.md, and CLAUDE.md plus a Stop hook that runs dotnet build and dotnet test. With the CLAUDE.md, servers left running went from 3 of 12 runs to 0 of 24. The xUnit v3 test run that dotnet test refused (2 of 2 runs) never happened again (0 of 4). Every run listed the policy choices it had made, against none without the file. What didn’t change: the code. All three conditions picked HybridCache, the standard resilience handler and IServiceScopeFactory on their own. An EF Core query still failed to translate on its first run in 3 of 4 runs with the file, against 1 of 2 without it. My review found 2, 3 and 1 bugs per condition. The hook added about 30% to the cost of the CLAUDE.md runs and caught nothing they had missed.

So write a CLAUDE.md for the process (versions, test runner, “run it”, “stop what you start”, “tell me what you decided”), not in the hope of better code. Then run the result yourself, under load. The biggest bug in this test was in 5 of 6 runs of one task, and it only showed up when I sent 200 requests.

This post is about setting Claude Code up for C#. If you want to know how it compares with Copilot, Cursor and the rest, see Best AI tools for C# coding, where I ran it on five tasks with no configuration at all. Two of those tasks come back here.

How I Tested It

Six tasks, each in a fresh copy of a small starting project. Three are reused from the Best AI post. Three are new and were picked because a convention decides whether the result is right:

  1. xUnit v3 tests for an OrderPricing class with three planted bugs. The prompt says not to change the class.
  2. An EF Core 10 + SQLite query (top 5 customers in 30 days) that must translate to one SQL statement.
  3. Fix a minimal API that doesn’t build: a missing partial, a singleton that takes a scoped service, a nullable warning. Then run it and call the endpoint.
  4. A background job that expires old orders every 10 seconds through the existing scoped AppDbContext.
  5. An endpoint that calls a flaky service. A local stub returns 503 on every third request and takes 10 seconds on every fifth. The prompt asks for retries and says a slow upstream must not hang the endpoint.
  6. Cache a slow endpoint (a 500 ms repository, called with .Result), with products allowed to be up to a minute old.

Every task ran twice in each of three conditions:

  • A, prompt only. No CLAUDE.md, no hooks.
  • B, prompt + CLAUDE.md. The same prompt, with the file below at the root of the folder.
  • C, B + a Stop hook. Before Claude Code can finish, a script builds every project, runs any test project and checks for processes still running from the folder.

The prompts were identical in all three conditions. I ran Claude Code headless, the same way as in the Best AI post, except that I swapped --safe-mode for --setting-sources project. Safe mode turns off CLAUDE.md, and that file is what I was measuring. This is the exact command, run in each task folder:

claude -p "$(cat prompts/<task>.txt)" --output-format stream-json --verbose --setting-sources project --strict-mcp-config --disable-slash-commands --no-session-persistence --permission-mode acceptEdits --max-budget-usd 3 --model claude-opus-5-5 --effort medium --tools Bash,Read,Edit,Write,Glob,Grep --allowedTools Read Edit Write Glob Grep "Bash(dotnet *)" "Bash(curl *)" "Bash(sleep *)" "Bash(ls *)" "Bash(cat *)" "Bash(cd *)" "Bash(kill *)" "Bash(taskkill *)" "Bash(timeout *)"

That means my user settings were off, there were no MCP servers and no skills, and the shell was limited to dotnet, curl and a few file commands. Before the real runs, I did a pilot with a one-line CLAUDE.md (“end your reply with the word KUMQUAT”) and a hook that blocks on a build error. Both worked in headless mode.

A Python script started each run. Eight seconds after Claude Code reported its result, the script recorded every process whose command line pointed into the run folder, killed it, and saved the full event stream. A second script works out the table below from those logs. Then I checked every result myself: built and ran it, called the endpoints, ran the tests, and compared the EF Core numbers with an in-memory calculation. For task 5 I also sent 200 requests to it, 8 at a time. Cost is the total_cost_usd the CLI reports at API list prices. On a Claude subscription you don’t pay per run.

The CLAUDE.md I Used (Full File)

It’s 30 lines, the kind of thing a team writes after an AI assistant has burned it a few times. Two lines come straight from the failures in the Best AI post: the global.json test runner line, and “run the query and check ToQueryString()“. Keep that in mind when you read the results for tasks 1 and 2.

# C# / .NET conventions for this repo

## Stack
- Target `net10.0` with `<Nullable>enable</Nullable>` and `<ImplicitUsings>enable</ImplicitUsings>`. New nullable warnings are bugs.
- Tests: xUnit v3 (`xunit.v3` package) on Microsoft.Testing.Platform. The root `global.json` must contain
  `{ "test": { "runner": "Microsoft.Testing.Platform" } }`. Don't add `Microsoft.NET.Test.Sdk` or `xunit.runner.visualstudio`.
- EF Core 10: every query must translate to one SQL statement. Run it and check `ToQueryString()` before you finish.

## Use the framework before writing your own
- Caching: `HybridCache` or `IMemoryCache`; output caching for whole responses. No hand-written cache classes.
- HTTP: typed clients from `IHttpClientFactory` (`AddHttpClient<T>`) plus `Microsoft.Extensions.Http.Resilience`
  (`AddStandardResilienceHandler`). Never `new HttpClient()` per call, no hand-written retry loops.
- Background work: `BackgroundService` + `PeriodicTimer`. Singletons and hosted services never take scoped services
  (DbContext, repositories) in the constructor: inject `IServiceScopeFactory` and create a scope per run.
- Use `TimeProvider` when logic depends on the current time.

## Async
- Async all the way: never `.Result`, `.Wait()` or `GetAwaiter().GetResult()`. Fix the ones in code you touch.
- Pass the `CancellationToken` to every async call that takes one.

## Before you finish
1. `dotnet build`: 0 errors, no new warnings.
2. `dotnet test` when there is a test project.
3. Stop every process you started (`dotnet run`, servers). Never leave a server running.
4. End with a short list of the policy choices you made that I didn't specify (retry counts, timeouts,
   cache durations, error handling), so I can change them.

## Don't
- Don't change production code to make a test pass unless I ask; report the bug instead.
- Don't touch files outside this folder.

Results: Prompt Only vs CLAUDE.md vs CLAUDE.md + Hook

Measured over 12 runs per conditionA: prompt onlyB: + CLAUDE.mdC: + CLAUDE.md + Stop hook
dotnet test refused to run on the first try (task 1, 2 runs each)2 of 20 of 20 of 2
First build after its edits failed012
EF Core query “could not be translated” on the first run (task 2, 2 runs each)1 of 21 of 22 of 2
Runs with no failed dotnet command9108
Left a server running when it finished300
Ended with a list of the choices it made01212
Used the framework built-in (tasks 4 to 6)6 of 66 of 66 of 6
Bugs I found in review231
Final result correct after my own run121212
Median agent time per run82 s78 s88 s
Cost, 12 runs (API list price)$3.06$2.80$3.66

The 36 runs cost $9.53 in total, and the pilot and the MCP check below added about $0.20. “Correct” means the result did what the prompt asked when I ran it myself. All six task 1 runs found the three planted bugs, every endpoint returned the right data, and every version of the query returned the same three rows as my check. “Bugs I found” means problems that survive that check: one money total stored as double, and a circuit-breaker setting that only fails under load. Both are explained below. Two runs per condition per task is still a small sample, so read each cell as “it happened” or “it didn’t”, not as a rate.

Task 1: xUnit v3 Tests (Where CLAUDE.md Paid Off)

Without the file, both runs did exactly what the run in the Best AI post did. They added xunit.v3 plus the two old VSTest packages, and the first dotnet test stopped with:

Microsoft.Testing.Platform.MSBuild.targets(395,5): error : Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later.

Both fixed it on their own: they added a global.json and removed the VSTest packages. One of them put the global.json inside src/Pricing.Tests rather than at the root, so dotnet test only works from that folder, and it said so in its summary. With the CLAUDE.md, all four runs wrote this at the root before creating the test project, and their first test run worked:

{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

That’s the clearest win in the test, and also the least surprising one. I wrote the answer into the file. The side effect was new. Because the file says not to add the VSTest packages, the B and C runs wrote the test .csproj by hand. Three of those four first builds failed:

OrderPricingTests.cs(35,6): error CS0246: The type or namespace name 'Fact' could not be found (are you missing a using directive or an assembly reference?)

Each run fixed it in one step by adding using Xunit as a global using. That cost one build, not a wrong result. It’s why the “first build failed” row goes up with the CLAUDE.md. If you put a test-runner rule in your file, add “global using Xunit” next to it, or tell it to start from dotnet new xunit3.

Test quality was the same in all six runs. They wrote 20 to 23 tests and found all three planted bugs (< 0 instead of < 1, > 10 instead of >= 10, banker’s rounding), without changing OrderPricing. If you want the TDD side of xUnit v3, see TDD in C# with xUnit v3.

Task 2: The EF Core Query Still Failed First

The CLAUDE.md says to run every query and check ToQueryString(). All runs did run it, and that’s exactly why they caught this. But writing the rule down didn’t prevent the mistake. Four of the six runs first projected into a positional record and then ordered by one of its properties. One was a prompt-only run, three had the file:

).TotalSpent)' could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'.

Every one fixed it on the second run the same way: project to an anonymous type, sort, Take(5), and build the record last. All six final versions produced one SELECT and the same three rows as my in-memory check (Litware 921.96, Adatum 705.44, Proseware 454.07).

One B run made a different mistake. It “knew” that EF Core can’t sum decimals on SQLite, so it cast the money to double up front:

// SQLite can't SUM or ORDER BY decimal (EF stores it as TEXT), so line totals are computed as REAL in SQL.
...
TotalSpent = x.Orders.SelectMany(o => o.Lines).Sum(l => (double)l.UnitPrice * l.Quantity),
...
record TopCustomer(string Name, int OrderCount, double TotalSpent, DateTime LastOrderAt);

That’s out of date. Every other run summed the decimal directly, and EF Core 10 translated it with its own ef_sum and ef_multiply functions. The numbers happen to match here because they’re rounded to two decimals. But it’s money in a double, and nothing in the file stopped it. I count it as a bug. To its credit, it listed the change under “Choices I made that you didn’t specify”, which is how I spotted it in seconds.

Tasks 3 to 6: The Code Was the Same Either Way

I expected the “use the framework” lines to matter. They didn’t, because the prompt-only runs already used the framework. On the background job, all six runs injected IServiceScopeFactory, used a PeriodicTimer and expired orders with one ExecuteUpdateAsync. Here’s the core of a prompt-only run:

private async Task ExpireOldOrdersAsync(CancellationToken ct)
{
    // AppDbContext is scoped; a BackgroundService is a singleton, so create a scope per run.
    await using var scope = scopeFactory.CreateAsyncScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

    var cutoff = timeProvider.GetUtcNow().UtcDateTime - MaxPendingAge;

    var expired = await db.Orders
        .Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < cutoff)
        .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, OrderStatus.Expired), ct);

That’s the pattern from “Cannot consume scoped service from singleton”, written without being asked. In my own run of every version, after 15 seconds Contoso and Fabrikam were Expired, Northwind was still Pending and Tailspin was still Paid. All six versions of the broken minimal API got the same three fixes (partial, AddScoped<OrderStats>, a null check) and returned 3 orders, first customer: Contoso.

On caching, all six runs, with and without the file, added HybridCache with a one-minute expiry and replaced the blocking .Result with await. The prompt-only version:

app.MapGet("/products/{id:int}", async (int id, IProductRepository repo, HybridCache cache, CancellationToken ct) =>
{
    // HybridCache coalesces concurrent misses for the same key, so only one
    // request hits the repository while others await the same result.
    var product = await cache.GetOrCreateAsync(
        $"product:{id}",
        (repo, id),
        static async (state, token) => await state.repo.GetAsync(state.id, token),
        cancellationToken: ct);

    return product is null ? Results.NotFound() : Results.Ok(product);
});

In my runs, the first request took about 550 ms and repeats took 1 to 27 ms. All six also cache the 404 for a missing product for a minute. The prompt-only runs mentioned that under “Things to know”. If you only need to cache whole responses, output caching is the simpler tool.

On the flaky service, all six added Microsoft.Extensions.Http.Resilience. Five of the six used a typed client (one prompt-only run used a named one), and all six picked almost the same numbers:

.AddStandardResilienceHandler(options =>
{
    // Each try gets 2s; a slow upstream is abandoned and retried instead of hanging us.
    options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
    // Hard cap for the whole call including all retries and back-off delays.
    options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(8);
    // Retries 5xx/408/429, HttpRequestException and per-attempt timeouts.
    options.Retry.MaxRetryAttempts = 3;
    options.Retry.Delay = TimeSpan.FromMilliseconds(200);

With 15 requests in a row, every version returned 15 of 15 with status 200, with a worst case around 2.6 seconds against the stub’s 10. With the stub stopped, they returned 504 after 8 to 9 seconds. The HttpClient and IHttpClientFactory guide covers the handler itself.

The Bug Neither Setup Caught: The Circuit Breaker Under Load

Every run tested its endpoint with 8 to 12 requests, one at a time. I sent 200, 8 at a time, to each version against a fresh stub:

# t5-resilience_A_r1: 200 requests to /weather/london, 8 concurrent, fresh FlakyForecast
elapsed 6.1s  status codes: {200: 58, 503: 142}
# t5-resilience_B_r1: 200 requests to /weather/london, 8 concurrent, fresh FlakyForecast
elapsed 6.2s  status codes: {200: 58, 503: 142}
# t5-resilience_C_r1: 200 requests to /weather/london, 8 concurrent, fresh FlakyForecast
elapsed 20.3s  status codes: {200: 199, 502: 1}

Five of six versions kept the standard handler’s default circuit breaker. With this upstream, almost half of individual attempts fail (503s plus attempts cut off at 2 seconds), so the breaker opened and about 70% of requests (140 to 142 of 200) failed fast with 503. The upstream itself only fails one request in three. Whether opening the breaker is right is a policy question. But none of those five runs mentioned the breaker in their list of choices, and that includes the four runs with a CLAUDE.md asking for exactly that list.

The one exception was a C run. It saw the breaker open during its own testing and moved the thresholds into configuration:

"CircuitBreaker": {
  "SamplingDuration": "00:00:30",
  "FailureRatio": 0.8,
  "MinimumThroughput": 20,
  "BreakDuration": "00:00:05"
}

That wasn’t the hook. The hook only checks build, tests and processes. It was luck in how that run tested. The other C run kept the defaults and failed the same way.

Servers Left Running

This is the other clear difference. Two of the prompt-only runs on the resilience task said so in plain words and left both services up:

- **Still running:** both services are still up in the background, so stop them when you're done.

A third prompt-only run, on the caching task, tried to stop its server with Stop-Process, which my allowlist didn’t include, and it stopped trying. kill and taskkill were allowed. With the CLAUDE.md, all 24 runs stopped what they had started, and several checked that the port no longer answered. On your machine, a leftover server means a locked bin folder and an “address already in use” error on the next run, so this matters more than it looks.

The Stop Hook: What It Added

Condition C adds this project setting (.claude/settings.json):

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/verify.py\"",
            "timeout": 300
          }
        ]
      }
    ]
  }
}

The script looks for processes from the folder, builds every .csproj, and runs dotnet test in test projects. If something fails it exits with code 2, and its message goes back to Claude, which then has to keep working. The hooks docs say a Stop hook can do that at most 8 times in a row. I capped it at 3. A short excerpt of the script:

left = leftover_processes()
if left:
    block("These processes you started are still running: " + ", ".join(left) +
          ". Stop them before you finish (Bash: kill <pid> or taskkill //PID <pid> //F).")

In 12 runs it blocked three times. Two were the intended test failures on task 1. The script lets those through on the second stop, after Claude confirms the failures expose the planted bugs:

BLOCK 1: dotnet test reports failing tests in src\Pricing.Tests\Pricing.Tests.csproj:

The third was a false alarm: a “build failed” with no error lines, most likely because the hook built while a server the run had just killed was still shutting down. The hook never caught a problem the B runs had missed, because the B runs already built, tested and stopped their servers. It cost more: $3.66 against $2.80, median 20 turns against 15. There’s also a side effect in headless output. When the hook blocks, the final result text is just the reply to the hook (“Yes, these 6 failures are intended…”). The full report is in the message before it.

When would I still use it? When I’m not reading every transcript, or the agent runs unattended, for example in CI. A CLAUDE.md is context Claude chooses to follow. The memory docs call it “context, not enforced configuration”. A hook is enforced. In these runs Claude followed the context every time, so enforcing it changed nothing.

Setting Up Claude Code for a C# Project

What I ran on this machine is marked tested. Everything else is from the official docs at code.claude.com.

Editor: Visual Studio, VS Code or Rider

Claude Code is a CLI, and that’s how every run here worked (tested). The docs list a VS Code extension, a JetBrains plugin for “IntelliJ, PyCharm, WebStorm, or other JetBrains IDEs” (so Rider), a desktop app and a web version. There’s no Visual Studio extension in the docs (from the docs). If you live in Visual Studio, open a terminal in the solution folder and run claude there. It works on files and dotnet commands, not on the IDE.

CLAUDE.md

Put it at the repo root next to the .sln or .slnx. Claude Code loads CLAUDE.md from the folder you start in and every folder above it at launch. Files in subfolders load when Claude touches files there. The docs suggest staying under 200 lines (from the docs; my 30-line file loaded in every run, tested). /init writes a first version from your code. What I’d put in it for .NET, based on these runs:

  • The versions and the test runner, with the exact global.json plus “global using Xunit”.
  • “Run it, don’t just build it”: queries, endpoints, tests.
  • “Stop every process you start”.
  • “List the choices I didn’t specify”. This was the cheapest line in the file, and it surfaced the double bug.
  • Your own conventions: Claude can’t guess these. The framework defaults it already knew without being told.

Permissions

For the runs I passed permissions on the command line (tested). For daily work you’d put them in .claude/settings.json so the whole team gets the same rules. Bash rules match the full command, * is a wildcard, and in a compound command like cd x && dotnet build every part has to be allowed. Deny rules win over allow rules. This is the shape the permissions docs describe. I didn’t verify this exact file:

{
  "permissions": {
    "allow": [
      "Bash(dotnet build *)",
      "Bash(dotnet test *)",
      "Bash(dotnet run *)",
      "Bash(dotnet add package *)",
      "Bash(curl http://localhost:*)"
    ],
    "deny": [
      "Bash(dotnet nuget push *)",
      "Read(./**/appsettings.Production.json)"
    ]
  }
}

Allow the command your agent needs to stop a server (kill, taskkill). One of my runs left a server up because the command it chose wasn’t allowed.

C# language server (csharp-lsp)

From the docs, not tested here. Anthropic’s plugin marketplace has a csharp-lsp plugin. It connects Claude Code to csharp-ls, so Claude sees compiler errors after each edit and can look up definitions and references by symbol. The plugin doesn’t include the server, so you install that first:

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

The second line goes at the Claude Code prompt, not in your shell. I didn’t install it for this test, so every run found its compile errors by running dotnet build. Most C# errors in these runs were caught on the next build anyway. The one that cost a round trip was the missing using Xunit.

MCP: Microsoft Learn docs

Tested. Microsoft’s Learn MCP server answers questions from current .NET docs, which helps with exactly the kind of stale knowledge behind the double cast. I loaded it for one session with --mcp-config:

{"mcpServers":{"microsoft-learn":{"type":"http","url":"https://learn.microsoft.com/api/mcp"}}}

I asked for the defaults of AddStandardResilienceHandler. It called microsoft_docs_search once and answered from Learn ($0.13, 10 seconds):

- **Total request timeout:** 30 seconds, and that covers all retries.
- **Retry count:** 3 retries, with exponential backoff and jitter starting at a 2-second delay.
- **Attempt timeout:** 10 seconds for each attempt.

To keep it permanently, the docs give claude mcp add --transport http microsoft-learn https://learn.microsoft.com/api/mcp. Add --scope project to put it in .mcp.json for the team. To write your own MCP server in C#, see MCP server in C# with .NET 11.

What Didn’t Work

  • Writing down the EF Core lesson. The query failed to translate on its first run in 3 of 4 runs with the file. The file only made sure the run happened.
  • “Use the framework” lines. No effect, because the model already used HybridCache, the resilience handler and scope factories without them.
  • “List your policy choices” as a safety net. It caught the double, but not the circuit breaker, because the runs didn’t know the breaker would matter.
  • The Stop hook. No problems caught that the file didn’t already prevent, one false alarm, about 30% more cost than the file alone, and a shorter final message in headless mode.

What I Didn’t Test

  • The csharp-lsp plugin, skills, subagents, and the VS Code and JetBrains integrations. Everything ran in the CLI.
  • A real solution. These were 1- or 2-project folders with no Git history. In a large repo, a CLAUDE.md also has to say where things are, and that could matter more than anything here.
  • Other models and effort levels. Every run used claude-opus-5-5 at medium effort. A weaker model might need the “use the framework” lines that this one didn’t.
  • Interactive use. With a person approving each step, you’d stop a leftover server or a refused test run yourself. My numbers are for hands-off runs.
  • More than two runs per cell. The differences that matter here are 2 of 2 vs 0 of 4 and 3 of 12 vs 0 of 24. They’re large but still small samples.
  • The permissions file above. I didn’t verify it, and it’s from the docs.

FAQ

Does Claude Code work with C# and .NET? Yes. It reads and edits your .cs and .csproj files and runs dotnet build, dotnet test and dotnet run in a terminal. In 36 runs on .NET 10, every final result was correct when I ran it myself. Most of the trouble was version details (test runner, LINQ translation), not C#.

Is there a C# LSP for Claude Code? Yes, the official csharp-lsp plugin, which uses csharp-ls (dotnet tool install --global csharp-ls). It gives Claude compiler diagnostics after each edit. I didn’t test it here.

Does Claude Code work in Visual Studio? There’s no Visual Studio extension in the docs. Run claude in a terminal in your solution folder next to Visual Studio, or use the VS Code extension or the JetBrains plugin (Rider).

Which MCP server is useful for .NET? Microsoft Learn’s (https://learn.microsoft.com/api/mcp). It answered a Microsoft.Extensions.Http.Resilience question from the current docs in one call.

What should a CLAUDE.md for a .NET project contain? Versions, the test runner setup, “run it”, “stop what you start”, and “list the choices you made”. Skip what the model already does. Mine is above in full.

All code and output here comes from the runs described above. The prompts, the projects Claude Code wrote, raw logs and my checks are kept with the test files. I used an AI assistant to help run the tests and write this post.

Related: AI in .NET tutorials · Best AI tools for C# coding · MCP server in C# · Cannot consume scoped service from singleton · C# async/await guide

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *