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>
public string GetName(User user)
{
return user.Name; // user.Name might be null — no warning
}
After:
<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
public string GetName(User user)
{
return user.Name ?? throw new InvalidOperationException("Name was not initialized.");
}
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; }
}
After:
public record CreateInvoiceRequest(string CustomerId, decimal Amount, string Currency);
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
}
After:
public async Task<string> GetUserNameAsync(int id, CancellationToken ct)
{
var user = await _client.GetUserAsync(id, ct);
return user.Name;
}
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);
}
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);
}
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);
}
}
After:
// Program.cs
builder.Services.AddScoped<IEmailer, SmtpEmailer>();
public class OrderHandler(IEmailer emailer)
{
public Task HandleAsync(Order o, CancellationToken ct) => emailer.SendAsync(o, ct);
}
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);
}
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);
}
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);
}
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>>();
}
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}");
After:
_log.LogInformation(
"Created invoice {InvoiceId} for customer {CustomerId}",
invoiceId,
customerId);
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();
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);
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
}
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;
}
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();
}
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))
};
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) => /* ... */);
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);
});
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
}
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));
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)