C# and .NET rules for AI coding agents
The most expensive .NET mistakes an agent makes are usually about async and lifetimes: .Result or .Wait() on a task in app code, a DbContext stored in a singleton or shared across parallel tasks, a query enumerated twice. The C# and .NET rules cover modern .NET with ASP.NET Core and EF Core, starting from the <TargetFramework> and <LangVersion> in the project file.
For APIs they ask for thin endpoints that bind, validate, call a service and map the result, using TypedResults so OpenAPI shows every outcome. For data access: AsNoTracking() on read-only queries and projections into DTOs. Smaller habits matter too, such as structured log templates instead of interpolation, DateTimeOffset.UtcNow or an injected TimeProvider, and throw; instead of rethrowing ex.
Folder: rules/csharp-dotnet/ · csharp-dotnet.mdc, CLAUDE.md, AGENTS.md
What the .NET rules tell your agent
- Versions and project settingsCheck
<TargetFramework>in the .csproj (e.g.net8.0,net10.0; even numbers are LTS) and<LangVersion>. Use features the target supports: records,requiredmembers, file-scoped namespaces, collection expressions and primary constructors on C# 12+. - AsyncAsync all the way: never block on tasks with
.Result,.Wait()or.GetAwaiter().GetResult()in app code. It causes thread-pool starvation and, in some hosts, deadlocks. - Dependency injectionRegister with the right lifetime.
DbContextis scoped and not thread-safe: never share one across parallel tasks or store it in a singleton. - ASP.NET Core APIsKeep endpoints thin: bind, validate, call a service, map the result. Use
TypedResultsandResults<Ok<OrderDto>, NotFound>in minimal APIs so OpenAPI shows every outcome. - EF CoreUse
AsNoTracking()for read-only queries and project withSelectinto DTOs to fetch only needed columns.
First rule of each section in csharp-dotnet.mdc, quoted as shipped.
Mistakes these rules are written to prevent
- Enumerating an
IEnumerablefrom a query twice runs the query twice. Materialize withToListAsync()once. - Interpolated log messages. Use message templates so properties are structured:
logger.LogInformation("Order {OrderId} paid", order.Id). DateTime.Nowin domain code. UseDateTimeOffset.UtcNow, or injectTimeProvider(.NET 8+) so time is testable.
Source: “Pitfalls to avoid”.
Checks the agent runs before it says done
Example commands from the .NET file (swap in your repo's own):
- Build:
dotnet build -warnaserror - Format:
dotnet format --verify-no-changes - Tests:
dotnet test
Run dotnet build -warnaserror, dotnet format --verify-no-changes and dotnet test, and report the results.
Before and after: EF Core: query review for tracking, projection and raw SQL
Prompt card 25 · Stack: C#, EF Core 8+ · Type: Performance and security review · Best model: a frontier reasoning model
What the card is for
Review LINQ-to-EF queries for change tracking on read paths, over-fetching whole entities, client-side evaluation traps, cartesian explosions from multiple Includes, SaveChanges in loops and SQL injection through FromSqlRaw.
Worked example from the card
Before
public async Task<List<CustomerSummary>> Search(string name)
{
var customers = await _db.Customers
.FromSqlRaw($"SELECT * FROM Customers WHERE Name LIKE '%{name}%'")
.Include(c => c.Orders)
.Include(c => c.Addresses)
.ToListAsync();
foreach (var c in customers) { c.LastSearchedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); }
return customers.Select(c => new CustomerSummary(c.Id, c.Name, c.Orders.Count)).ToList();
}
Expected AI output (abridged)
Critical - FromSqlRaw with an interpolated string: the interpolation happens before EF
sees it, so `name` goes into the SQL text. Input `x' OR 1=1 --` returns every customer.
High - Two collection Includes on a single query: a customer with 50 orders and 3
addresses returns 150 rows. Only Orders.Count is used and Addresses not at all.
High - SaveChangesAsync per customer: one round trip per row. Use ExecuteUpdateAsync.
Medium - Tracking enabled on a read path. Projection removes the need.
After
public async Task<List<CustomerSummary>> Search(string name, CancellationToken ct)
{
var pattern = $"%{name}%";
var matches = _db.Customers.Where(c => EF.Functions.Like(c.Name, pattern));
var summaries = await matches
.OrderBy(c => c.Name)
.Take(100)
.Select(c => new CustomerSummary(c.Id, c.Name, c.Orders.Count))
.ToListAsync(ct);
var ids = summaries.Select(s => s.Id).ToList();
await _db.Customers.Where(c => ids.Contains(c.Id))
.ExecuteUpdateAsync(s => s.SetProperty(c => c.LastSearchedAt, DateTime.UtcNow), ct);
return summaries;
}
Excerpt from card 25; the prompt itself is in the pack.
More .NET prompt cards in the pack
- Card 24: C#: sync-over-async and missing cancellation
Using these rules in Cursor, Claude Code and AGENTS.md tools
Cursor
Put _base.mdc and csharp-dotnet.mdc in .cursor/rules/. Globs: src/**/*.cs, **/*.cs, **/*.csproj, appsettings*.json.
Claude Code
Root CLAUDE.md, base first. Path-scoped variant .claude/rules/csharp-dotnet.md for **/*.cs, **/*.csproj; PostToolUse hook: dotnet format.
AGENTS.md tools (Codex, Gemini CLI, Aider)
Base then stack in one AGENTS.md. Gemini CLI needs context.fileName; Aider needs --read AGENTS.md. Put this file in the folder with the .sln file. Agents use the AGENTS.md nearest to the file being edited, so a nested copy overrides a root one for that subtree.