DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for C#: 13 Rules That Make AI Write Safe, Idiomatic .NET Code

When AI assistants write C#, they often produce code that compiles but smells off. They block on .Result like it's still WCF. They invent mutable DTO classes when a one-line record would do. They reach for IConfiguration["..."] when typed IOptions<T> exists, leak IQueryable<T> past the repository boundary, and new HttpClient() per request like socket exhaustion is somebody else's problem. The result is code that ships, technically works, and quietly burns threads, memory, and half your CI budget.

The fix isn't a smarter model. It's giving the model the same conventions every senior .NET developer internalized after their first three production incidents. A CLAUDE.md (or .cursorrules, or AGENTS.md — same idea) at the root of your project teaches the AI your house rules before it writes a single line.

Below are the 13 rules I keep in mine. Each one closes a specific failure mode I've seen AI repeat across real C# 12 / .NET 8 projects.


Rule 1: Enable nullable reference types and treat warnings as errors

AI defaults to suppressing nullability warnings instead of fixing the underlying contract. That defeats the entire point — string and string? only mean something if the compiler enforces them.

Before:

<PropertyGroup>
  <Nullable>disable</Nullable>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode
public string GetName(User user)
{
    return user.Name; // user.Name might be null — no warning
}
Enter fullscreen mode Exit fullscreen mode

After:

<PropertyGroup>
  <Nullable>enable</Nullable>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode
public string GetName(User user)
{
    return user.Name ?? throw new InvalidOperationException("Name was not initialized.");
}
Enter fullscreen mode Exit fullscreen mode

Use required (C# 11+) so partial construction can't ship.


Rule 2: Records for DTOs, classes for entities and services

AI generates mutable DTO classes by reflex. A request DTO should be a one-liner.

Before:

public class CreateInvoiceRequest
{
    public string CustomerId { get; set; }
    public decimal Amount { get; set; }
    public string Currency { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

After:

public record CreateInvoiceRequest(string CustomerId, decimal Amount, string Currency);
Enter fullscreen mode Exit fullscreen mode

EF-tracked entities stay as class — value equality confuses the change tracker. Records are for data shapes; classes are for things with identity and lifetime.


Rule 3: async all the way — never .Result or .Wait()

.Result deadlocks the moment any sync context is in play and burns a thread on every call in modern ASP.NET Core. AI reaches for it whenever the surrounding method isn't already async.

Before:

public string GetUserName(int id)
{
    return _client.GetUserAsync(id).Result.Name; // deadlock waiting to happen
}
Enter fullscreen mode Exit fullscreen mode

After:

public async Task<string> GetUserNameAsync(int id, CancellationToken ct)
{
    var user = await _client.GetUserAsync(id, ct);
    return user.Name;
}
Enter fullscreen mode Exit fullscreen mode

Library code adds .ConfigureAwait(false). Application code (controllers, Razor) leaves it off.


Rule 4: IQueryable<T> stays inside the repository

Once IQueryable<T> leaks out of the data layer, every consumer is one Where away from running an unbounded scan in production.

Before:

// Controller takes IQueryable directly
public IActionResult Get([FromServices] IQueryable<Invoice> invoices)
{
    var rows = invoices.Where(i => i.Total > 0).ToList(); // sync ToList!
    return Ok(rows);
}
Enter fullscreen mode Exit fullscreen mode

After:

public class InvoiceRepository
{
    public Task<List<InvoiceDto>> ListPositiveAsync(CancellationToken ct) =>
        _db.Invoices
            .AsNoTracking()
            .Where(i => i.Total > 0)
            .Select(i => new InvoiceDto(i.Id, i.Total))
            .ToListAsync(ct);
}
Enter fullscreen mode Exit fullscreen mode

Materialize once at the boundary. Project to a DTO. Never .AsEnumerable() mid-query — that silently switches the rest of the pipeline to LINQ-to-Objects.


Rule 5: Register dependencies; never use the service locator

AI reaches for serviceProvider.GetService<T>() whenever wiring feels inconvenient. That hides the dependency graph and makes tests miserable.

Before:

public class OrderHandler
{
    public void Handle(Order o)
    {
        var emailer = ServiceLocator.Provider.GetService<IEmailer>();
        emailer!.Send(o);
    }
}
Enter fullscreen mode Exit fullscreen mode

After:

// Program.cs
builder.Services.AddScoped<IEmailer, SmtpEmailer>();

public class OrderHandler(IEmailer emailer)
{
    public Task HandleAsync(Order o, CancellationToken ct) => emailer.SendAsync(o, ct);
}
Enter fullscreen mode Exit fullscreen mode

Pick the right lifetime: AddSingleton for stateless and thread-safe, AddScoped for per-request, AddTransient for cheap helpers.


Rule 6: Use IHttpClientFactory — never new HttpClient() per request

A fresh HttpClient per request leaks sockets and produces SocketException storms under load. AI loves the one-liner.

Before:

public async Task<string> FetchAsync(string url)
{
    using var client = new HttpClient();           // socket exhaustion
    return await client.GetStringAsync(url);
}
Enter fullscreen mode Exit fullscreen mode

After:

// Program.cs
builder.Services.AddHttpClient<GitHubClient>(c =>
{
    c.BaseAddress = new Uri("https://api.github.com/");
});

public class GitHubClient(HttpClient http)
{
    public Task<string> GetUserAsync(string login, CancellationToken ct) =>
        http.GetStringAsync($"users/{login}", ct);
}
Enter fullscreen mode Exit fullscreen mode

The factory pools and rotates handlers. You stop leaking sockets without thinking about it.


Rule 7: xUnit naming + Arrange-Act-Assert layout

AI flattens tests into vague names like TestUserCreate. The first time it fails in CI, nobody knows what broke.

Before:

[Fact]
public void Test1()
{
    var s = new InvoiceService();
    var r = s.Create(new(){ Amount = -10 });
    Assert.False(r.IsSuccess);
}
Enter fullscreen mode Exit fullscreen mode

After:

[Fact]
public async Task CreateInvoice_ReturnsBadRequest_WhenAmountIsNegative()
{
    // Arrange
    var sut = new InvoiceService(_repo, _clock);
    var request = new CreateInvoiceRequest("cust_1", -10m, "USD");

    // Act
    var result = await sut.CreateAsync(request, CancellationToken.None);

    // Assert
    result.Should().BeOfType<BadRequest<string>>();
}
Enter fullscreen mode Exit fullscreen mode

Use [Theory] with [InlineData] for variants. Use WebApplicationFactory<TEntryPoint> for in-memory integration tests against the real pipeline.


Rule 8: Structured logging with named placeholders — never interpolated strings

String interpolation forces every property into the message text, so it cannot be queried in Seq, Elasticsearch, or Datadog.

Before:

_log.LogInformation($"Created invoice {invoiceId} for customer {customerId}");
Enter fullscreen mode Exit fullscreen mode

After:

_log.LogInformation(
    "Created invoice {InvoiceId} for customer {CustomerId}",
    invoiceId,
    customerId);
Enter fullscreen mode Exit fullscreen mode

Now InvoiceId and CustomerId are first-class properties you can filter on. Never log secrets, raw request bodies, or full PII payloads.


Rule 9: AsNoTracking() for reads, project to DTOs

The change tracker allocates snapshots for every entity it sees. If you're not going to mutate them, opt out.

Before:

public Task<List<Invoice>> RecentAsync() =>
    _db.Invoices
        .Include(i => i.Lines)
        .OrderByDescending(i => i.CreatedAt)
        .Take(50)
        .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

After:

public Task<List<InvoiceListItemDto>> RecentAsync(CancellationToken ct) =>
    _db.Invoices
        .AsNoTracking()
        .OrderByDescending(i => i.CreatedAt)
        .Take(50)
        .Select(i => new InvoiceListItemDto(i.Id, i.CustomerId, i.Total))
        .ToListAsync(ct);
Enter fullscreen mode Exit fullscreen mode

Use AsSplitQuery() when an Include chain causes cartesian explosion. Use IDbContextFactory<T> if you need a context outside a request scope.


Rule 10: Bind configuration to typed IOptions<T> with validation

AI peppers business code with _config["Some:Key"]. Typos become runtime errors and there is no validation.

Before:

public class StripeClient(IConfiguration config)
{
    private readonly string _key = config["Stripe:ApiKey"]!; // typo? hope not
}
Enter fullscreen mode Exit fullscreen mode

After:

public class StripeOptions
{
    [Required] public string ApiKey { get; init; } = "";
    [Range(1, 60)] public int TimeoutSeconds { get; init; } = 30;
}

// Program.cs
builder.Services
    .AddOptions<StripeOptions>()
    .Bind(builder.Configuration.GetSection("Stripe"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

public class StripeClient(IOptions<StripeOptions> options)
{
    private readonly StripeOptions _opt = options.Value;
}
Enter fullscreen mode Exit fullscreen mode

The host refuses to boot when configuration is broken. Silent fallback to defaults is how stale staging URLs end up calling production.


Rule 11: Pattern matching with switch expressions — not nested if/else

C# 8+ gave us proper pattern matching. AI still writes Java-shaped if/else ladders.

Before:

public string Describe(Shape s)
{
    if (s is Circle c) return $"circle r={c.Radius}";
    else if (s is Square sq) return $"square s={sq.Side}";
    else if (s is Rectangle r) return $"rect {r.Width}x{r.Height}";
    else throw new ArgumentException();
}
Enter fullscreen mode Exit fullscreen mode

After:

public string Describe(Shape s) => s switch
{
    Circle    { Radius: var r }                     => $"circle r={r}",
    Square    { Side: var x }                       => $"square s={x}",
    Rectangle { Width: var w, Height: var h }       => $"rect {w}x{h}",
    _ => throw new ArgumentException(null, nameof(s))
};
Enter fullscreen mode Exit fullscreen mode

Exhaustiveness reads top-to-bottom. Property patterns destructure inline. Use when clauses for guarded arms.


Rule 12: Pick Minimal API or Controllers per service — don't mix

Mixing styles in one service means contributors guess where to add the next endpoint and OpenAPI gets two ways to say the same thing.

Before — same project mixes both:

[ApiController, Route("api/v1/invoices")]
public class InvoicesController : ControllerBase { /* ... */ }

// And then in Program.cs:
app.MapGet("/api/v1/customers/{id}", async (int id, IRepo r) => /* ... */);
Enter fullscreen mode Exit fullscreen mode

After — Minimal API for a small focused service:

var invoices = app.MapGroup("/api/v1/invoices")
    .WithTags("Invoices")
    .RequireAuthorization();

invoices.MapGet("/{id:int}", async Task<Results<Ok<InvoiceDto>, NotFound>>
    (int id, IInvoiceRepository repo, CancellationToken ct) =>
{
    var invoice = await repo.FindAsync(id, ct);
    return invoice is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(invoice);
});
Enter fullscreen mode Exit fullscreen mode

Typed Results<...> give OpenAPI every status code without hand-written [ProducesResponseType]. Versioning lives in the route prefix.


Rule 13: Always pass and respect CancellationToken

AI writes async methods that swallow cancellation. The first time a client disconnects mid-request, you keep doing the work anyway.

Before:

public async Task<List<Order>> GetOrdersAsync(int userId)
{
    return await _db.Orders
        .Where(o => o.UserId == userId)
        .ToListAsync(); // ignores client disconnect
}
Enter fullscreen mode Exit fullscreen mode

After:

public async Task<List<Order>> GetOrdersAsync(
    int userId,
    CancellationToken ct)
{
    return await _db.Orders
        .Where(o => o.UserId == userId)
        .ToListAsync(ct);
}

// In a Minimal API endpoint:
app.MapGet("/orders", async (int userId, IOrderRepo repo, CancellationToken ct) =>
    await repo.GetOrdersAsync(userId, ct));
Enter fullscreen mode Exit fullscreen mode

ASP.NET Core threads HttpContext.RequestAborted into the endpoint as CancellationToken. Pass it down to every async call — DB, HTTP, file IO. Never default to CancellationToken.None.


Wrapping up

These 13 rules don't make AI write C# for you — they make AI stop fighting the framework. Async-all-the-way, typed configuration, scoped DI, structured logging, and AsNoTracking() aren't style choices; they're how .NET services earn their reliability under load. Drop a CLAUDE.md at the project root with these rules, and the next prompt produces code your future self won't have to rewrite.

I keep a maintained version of this file as a public GitHub Gist — fork it, prune what doesn't fit your stack, and commit it next to your .sln:

https://gist.github.com/oliviacraft/cbe88e7851bd80c06b9d48d74ba591b2

If you want the full CLAUDE.md Pack — covering C#/.NET, Go, Rust, TypeScript, Next.js, React, Node.js, Vue 3, Django, FastAPI, Java/Spring Boot, Postgres, Docker, Kubernetes, and more — it's $27 here:

Get the CLAUDE.md Pack — oliviacraftlat.gumroad.com/l/skdgt

One file. Thirteen rules per language. Production-grade AI output, every prompt.

Top comments (0)