Cursor Rules for C# (.NET): The Complete Guide to AI-Assisted C# Development
C# is the language where you can ship a clean Minimal API in an afternoon and a thread-starved production outage by the end of the quarter. The first regression is almost always an async misuse: somebody calls httpClient.GetStringAsync(url).Result from inside an ASP.NET request handler, the SynchronizationContext deadlocks under load, and the threadpool grows until Kestrel stops accepting new connections. The second is an Entity Framework query that loads context.Orders.Include(o => o.Items).Include(o => o.Customer).ToList() and then filters in memory because a .Where(...) clause used a method EF couldn't translate. The third is a public constructor that takes an IMyService registered as Singleton in Program.cs while the service itself injects a DbContext that is Scoped — the captive dependency hangs onto a disposed connection until the second request crashes.
Then you add an AI assistant.
Cursor and Claude Code were trained on C# code that spans two decades — .NET Framework 2.0 ThreadPool.QueueUserWorkItem, pre-async IAsyncResult / Begin/End patterns, ConfigureAwait(false) cargo-culted everywhere (or nowhere), using statements instead of using declarations, async void for fire-and-forget that silently swallows exceptions, try { ... } catch (Exception) { throw; } that wrecks the stack trace, Task.Run(() => ...) wrapping already-async code, and a Program.cs with fifty services.AddSingleton<...>() calls that never checks whether the lifetime makes sense. Ask for "an endpoint that returns a user's orders," and you get a controller with a synchronous List<Order> Get(int id), _db.Orders.Where(o => o.UserId == id).Include(o => o.Items).ToList() inside, a try/catch (Exception ex) { return BadRequest(ex.Message); } that leaks stack traces, and not a single CancellationToken in sight. It compiles. It is not the .NET you should ship in 2026.
The fix is .cursorrules — one file in the repo that tells the AI what idiomatic modern C# looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.
How Cursor Rules Work for C# Projects
Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended). For .NET I recommend modular rules so the Web API layer's conventions don't bleed into your domain project, and so per-bounded-context teams can own their own slice:
.cursor/rules/
csharp-async.mdc # async/await, CancellationToken, ConfigureAwait
csharp-efcore.mdc # EF Core query discipline, AsNoTracking, projections
csharp-nullability.mdc # NRT, null checks, ArgumentNullException.ThrowIfNull
csharp-di.mdc # lifetimes, captive deps, IOptions<T>
csharp-records.mdc # records, pattern matching, immutability
csharp-testing.mdc # xUnit, FluentAssertions, Testcontainers
csharp-errors.mdc # ProblemDetails, global handlers, Serilog
Frontmatter controls activation: globs: ["**/*.cs", "**/*.csproj", "**/appsettings*.json"] with alwaysApply: false. Now the rules.
Rule 1: Async All The Way — No Blocking, CancellationToken on Every I/O Boundary
The most common AI failure in modern C# is partial async: an async Task<IActionResult> that calls .Result or .Wait() on an inner task "to keep the method signature simple," or a chain where half the methods accept CancellationToken and half don't. The ASP.NET Core request pipeline flows cancellation automatically if you thread it through; break the chain and a cancelled request keeps hammering the database and the external API until it finishes on its own. The second failure is async void for anything that isn't an event handler — exceptions thrown from async void crash the process instead of propagating to a catch block.
The rule:
Every method that performs I/O (DB, HTTP, file, queue) is async and takes
a CancellationToken as its last parameter. Named `ct` or `cancellationToken`.
Never block on async code:
- No `.Result`, no `.Wait()`, no `.GetAwaiter().GetResult()` in
application code.
- No `Task.Run(() => SomeAsyncMethod())` to "make something async" —
it already is.
- `async void` is reserved for event handlers. Fire-and-forget work
goes through `IHostedService`, `Channel<T>`, or an out-of-proc queue.
ConfigureAwait:
- Libraries (anything in a class library, not an app): always
`.ConfigureAwait(false)` on awaits.
- ASP.NET Core apps (no SynchronizationContext): omit ConfigureAwait
entirely — the default is already correct.
CancellationToken threading:
- Every controller action, Minimal API handler, gRPC method, and
background worker loop accepts `CancellationToken ct` from the
framework.
- Every downstream call forwards it: `await _db.SaveChangesAsync(ct)`,
`await _http.GetAsync(url, ct)`.
- Never swallow `OperationCanceledException` — let it propagate so
the caller (and the framework) knows the work was aborted.
ValueTask<T> is used only when the hot path genuinely has a synchronous
fast path (cached value, already-completed IO). Otherwise Task<T>.
Before — blocking call inside an async method, no token, swallowed cancellation:
[HttpGet("{id}")]
public ActionResult<OrderDto> Get(int id)
{
try
{
var order = _http.GetStringAsync($"https://api.example.com/orders/{id}").Result;
var parsed = JsonSerializer.Deserialize<OrderDto>(order);
_db.AuditLogs.Add(new AuditLog { OrderId = id });
_db.SaveChanges();
return Ok(parsed);
}
catch (Exception ex)
{
return StatusCode(500, ex.Message);
}
}
.Result inside a request handler deadlocks under contention; if the client disconnects mid-request, the HTTP call and the DB write still run to completion.
After — fully async, cancellation threaded, framework-handled errors:
app.MapGet("/orders/{id:int}", async (
int id,
OrdersDbContext db,
IOrderApiClient api,
CancellationToken ct) =>
{
var order = await api.GetByIdAsync(id, ct);
db.AuditLogs.Add(new AuditLog(id, DateTimeOffset.UtcNow));
await db.SaveChangesAsync(ct);
return Results.Ok(order);
});
public sealed class OrderApiClient(HttpClient http) : IOrderApiClient
{
public async Task<OrderDto> GetByIdAsync(int id, CancellationToken ct)
{
using var response = await http.GetAsync($"orders/{id}", ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<OrderDto>(cancellationToken: ct).ConfigureAwait(false))!;
}
}
Client disconnect cancels the outbound HTTP call and the SaveChangesAsync. The threadpool is never blocked. Exceptions surface to the global handler (Rule 8), not a hand-rolled string response.
Rule 2: EF Core Query Discipline — AsNoTracking, Projections, Include, and Never Client-Side Evaluation
Cursor writes EF Core queries like it writes LINQ-to-Objects: _db.Orders.Where(...).ToList().Select(...) with the ToList in the middle, forcing every row into memory before the projection runs. It will happily add .Include(o => o.Items).Include(o => o.Customer).ThenInclude(c => c.Addresses) to a list endpoint, generating a Cartesian explosion that returns a gigabyte of duplicated rows over the wire. And it will write .Where(o => _somePredicate(o)) calling a regular C# method that EF can't translate — which in EF Core 3+ is a runtime exception, but the AI's training data often still assumes the silent client-side fallback from EF Core 2.
The rule:
Every read-only EF query uses `.AsNoTracking()`. Tracking is for writes
only. A queryable that feeds a Read-DTO must have AsNoTracking.
Use `.Select(...)` projections to shape the result set, not `.Include(...)`
chains for read paths. Include is for write paths where you actually
mutate the navigation.
.Include / .ThenInclude chains on collection navigations produce
Cartesian joins. Use `AsSplitQuery()` when you need to Include multiple
collections, or prefer projection.
Method calls inside LINQ expressions must be EF-translatable:
- Built-in functions: EF.Functions.Like, EF.Functions.DateDiffDay
- Compiled-into-SQL operators
- NEVER a custom method that takes the entity (EF cannot translate it)
Pagination is always `(page - 1) * size).Take(size)` with an `OrderBy` —
never `.ToList()` then paginate in memory. Results include the total via
a separate `.CountAsync(ct)` (or a windowed count if the DB supports it).
Bulk writes: `ExecuteUpdateAsync` / `ExecuteDeleteAsync` (EF 7+) for
update-by-predicate. Never loop and `SaveChanges` per entity for >20 rows.
Compiled queries (`EF.CompileAsyncQuery`) for hot-path reads that run
hundreds of times per second.
Never call `.ToList()` / `.ToArray()` in the middle of a query — it
materializes. If you need a list of IDs to pass in a `Contains`, do it
once, outside the query.
`SaveChangesAsync` is always awaited with the CancellationToken.
`context.Database.BeginTransactionAsync(ct)` for multi-statement writes.
Before — full-entity load, in-memory filter, Cartesian Include:
public async Task<List<OrderDto>> GetRecentOrdersAsync(int customerId)
{
var orders = await _db.Orders
.Include(o => o.Items)
.Include(o => o.Customer)
.ThenInclude(c => c.Addresses)
.ToListAsync();
return orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.Take(20)
.Select(o => new OrderDto(o.Id, o.Total, o.Items.Count))
.ToList();
}
Loads every order in the database into memory, joins to every item, every customer, every address, and then filters in C#.
After — projected, tracked only where needed, server-side paging:
public async Task<IReadOnlyList<OrderDto>> GetRecentOrdersAsync(
int customerId,
int page,
int size,
CancellationToken ct)
{
return await _db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.Skip((page - 1) * size)
.Take(size)
.Select(o => new OrderDto(
o.Id,
o.Total,
o.Items.Count,
o.Customer.Name))
.ToListAsync(ct);
}
One SQL statement with a TOP/LIMIT, only the columns the DTO needs, no tracking overhead, no Cartesian. A future .Include added by a well-meaning refactor gets caught in code review because it serves no purpose over the projection.
Rule 3: Nullable Reference Types Enabled — No ! Operator Without Comment, Guard At Boundaries
<Nullable>enable</Nullable> in the csproj is the compiler asking the question "which references can actually be null?" and AI assistants answer it by spraying ! (the null-forgiving operator) wherever the warning appears. That is the C# equivalent of # type: ignore in Python: the warning is silenced but the null still shows up at runtime. The rule below is: NRT stays on, warnings are errors, ! is reserved for situations where YOU can prove the value is non-null and the compiler can't, and public API boundaries are guarded with ArgumentNullException.ThrowIfNull.
The rule:
Every csproj has:
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors>nullable</WarningsAsErrors> (minimum)
The `!` (null-forgiving) operator is allowed only:
- After you have just checked the value with a pattern or null-check
that the compiler can't see through (e.g., after a helper method).
- With a one-line `// NRT: [reason]` comment explaining why.
A `!` without a comment is a code-review reject.
Public API entry points (controllers, library methods) validate
non-nullable reference parameters:
ArgumentNullException.ThrowIfNull(input);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
Properties that can be null are declared nullable: `string? Email`, not
`string Email` with a constructor that assigns null.
Collection return types are NEVER null — an empty collection is the
correct "nothing found" sentinel. `List<T>?` as a return type is a
smell; return an empty `IReadOnlyList<T>` instead.
`NotNullWhen`, `MaybeNullWhen`, `MemberNotNull` attributes are used on
helpers so callers get accurate flow analysis.
Nullable struct fields (`int?`) are for "value is optional." Don't use
them as a cheap "uninitialized" marker — use the record's constructor
to require the field.
Dictionary access: `TryGetValue(key, out var value)` pattern, never
`dict[key]` followed by a null check on a reference-typed value.
Before — ! everywhere, no guards, nullable leaks into the domain:
public class CustomerService
{
public CustomerDto GetCustomer(string email)
{
var customer = _db.Customers.FirstOrDefault(c => c.Email == email)!;
return new CustomerDto
{
Name = customer.Name!,
Email = customer.Email!,
PrimaryAddress = customer.Addresses.First()!
};
}
}
Every ! is a loaded gun pointing at production. FirstOrDefault returns null for "not found," and this method will NullReferenceException on the first cache miss.
After — guarded input, nullable-aware output, TryGet for "maybe":
public sealed class CustomerService(CustomersDbContext db) : ICustomerService
{
public async Task<CustomerDto?> FindByEmailAsync(string email, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(email);
return await db.Customers
.AsNoTracking()
.Where(c => c.Email == email)
.Select(c => new CustomerDto(
c.Id,
c.Name,
c.Email,
c.Addresses.Select(a => new AddressDto(a.Line1, a.City)).FirstOrDefault()))
.FirstOrDefaultAsync(ct);
}
}
The CustomerDto? return type tells every caller "this might be null." The DTO's primary-address field is nullable. Callers that want the non-null case use pattern matching (if (dto is { PrimaryAddress: { } addr })).
Rule 4: Dependency Injection Lifetimes — Scoped by Default, No Captive Dependencies, No Service Locator
AddSingleton<IUserService, UserService>() where UserService injects a DbContext is the most common .NET DI bug Cursor writes. The DbContext is registered Scoped, but the singleton captures one instance forever — the captive dependency. The first request works. The second request tries to use the disposed connection from the first. The stack trace is unhelpful. The fix is a discipline about which lifetime is the default and a prohibition on IServiceProvider.GetService as a runtime escape hatch.
The rule:
Default lifetime for application services is `Scoped`. A service is
Singleton ONLY IF:
- It holds no mutable state, OR
- Its state is thread-safe and intentionally shared across the app
(in-memory cache, connection pool, config snapshot).
Services that depend on EF Core DbContext are Scoped. Period.
`Transient` is for stateless factories and stateless helpers. Choosing
Transient to "avoid lifetime issues" is a smell — pick the right lifetime.
Never inject `IServiceProvider` into a class just to resolve other
services at runtime. If you need factory-of-T, register `Func<T>` or
`IServiceScopeFactory` and resolve in an explicit scope. Service locator
is forbidden.
Captive dependencies are forbidden:
- Singleton must not depend on Scoped or Transient directly. If it
must, it opens an `IServiceScope` via `IServiceScopeFactory` per
unit of work, disposes it, and never caches scoped instances.
Options pattern for configuration:
- `IOptions<T>` for snapshot-at-startup values.
- `IOptionsSnapshot<T>` for per-request reload (Scoped).
- `IOptionsMonitor<T>` for hot-reload (Singleton).
- Never inject `IConfiguration` into application services — bind to a
POCO via `services.Configure<T>(...)` and inject the POCO.
Minimal API and controllers accept dependencies as parameters
(`[FromServices]` or Minimal API parameter binding), not via constructor
injection on a derived controller when a parameter would do.
`Program.cs` groups registrations by feature via extension methods:
`services.AddOrdersFeature(configuration)`, not 100 inline Add lines.
`services.AddValidatedOptions<T>()` (data annotations on the POCO plus
`.ValidateOnStart()`) so misconfiguration fails at startup, not on the
first request that hits the stale value.
Before — singleton captures scoped context, service locator for convenience:
public class OrderService
{
private readonly IServiceProvider _sp;
public OrderService(IServiceProvider sp) { _sp = sp; }
public async Task<Order> CreateAsync(CreateOrderDto dto)
{
var db = _sp.GetRequiredService<OrdersDbContext>();
var order = new Order { /* ... */ };
db.Orders.Add(order);
await db.SaveChangesAsync();
return order;
}
}
// Program.cs
services.AddSingleton<OrderService>();
Singleton OrderService pulls a DbContext off the root provider — never scoped to a request. Disposal is undefined. Service locator hides the dependency from the constructor.
After — scoped service, explicit dependency, validated options:
public sealed class OrderService(
OrdersDbContext db,
IOptions<OrderPolicy> policy,
ILogger<OrderService> logger) : IOrderService
{
public async Task<Order> CreateAsync(CreateOrderDto dto, CancellationToken ct)
{
if (dto.Total > policy.Value.MaxOrderAmount)
{
throw new DomainException("order_over_limit");
}
var order = Order.From(dto);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
logger.LogInformation("Order created {OrderId}", order.Id);
return order;
}
}
// Program.cs
services
.AddScoped<IOrderService, OrderService>()
.AddOptions<OrderPolicy>()
.Bind(configuration.GetSection("OrderPolicy"))
.ValidateDataAnnotations()
.ValidateOnStart();
Dependencies are explicit and constructor-injected. Lifetime is scoped. Configuration is bound, validated at startup, and injected as a POCO.
Rule 5: Configuration and Secrets — Typed Options, User-Secrets in Dev, Key Vault in Prod
Cursor will happily paste builder.Configuration["ConnectionString"] into the middle of a service, read a secret literal out of appsettings.json, and check DEBUG=true against a hard-coded string. The modern .NET pattern is: every configuration section is a strongly-typed POCO bound via services.Configure<T>, data-annotation-validated with ValidateOnStart, secrets come from user-secrets in development and Azure Key Vault / AWS Secrets Manager / environment variables in production, and IConfiguration never appears downstream of Program.cs.
The rule:
Configuration source hierarchy, in order (later wins):
1. appsettings.json (non-secret defaults, committed)
2. appsettings.{Environment}.json (environment overrides, committed)
3. User secrets (dotnet user-secrets, local dev only, NOT committed)
4. Environment variables (production)
5. Azure Key Vault / AWS Secrets Manager (production secrets)
Every configuration section is:
- A POCO with `[Required]`, `[Range]`, `[Url]`, etc. annotations.
- Registered via `services.AddOptions<T>().Bind(...)
.ValidateDataAnnotations().ValidateOnStart()`.
- Consumed via `IOptions<T>`, `IOptionsSnapshot<T>`, or
`IOptionsMonitor<T>` based on reload semantics.
`IConfiguration` is injected only into infrastructure adapters that
genuinely need the raw configuration tree. Application and domain
services receive typed options.
Secrets in `appsettings.json` are forbidden. A connection string with
a password in it is a secret. An API key is a secret. Use a placeholder
and override from environment or vault.
`appsettings.Development.json` never contains real secrets. Use
`dotnet user-secrets` for dev overrides.
`launchSettings.json` is committed (it controls IIS Express / dotnet run
settings) but never contains secrets.
Feature flags: `IFeatureManager` (Microsoft.FeatureManagement), not
ad-hoc `bool IsFooEnabled` config values.
Connection strings: `Microsoft.Extensions.Configuration.GetConnectionString`
or a typed options POCO. Never a raw environment variable read inside
a service.
Health checks include a configuration validation check in production.
Before — raw IConfiguration in a service, hard-coded fallback, no validation:
public class EmailService
{
private readonly IConfiguration _config;
public EmailService(IConfiguration config) { _config = config; }
public async Task SendAsync(string to, string subject, string body)
{
var apiKey = _config["SendGrid:ApiKey"] ?? "SG.dev-key-abc123";
var client = new SendGridClient(apiKey);
var msg = MailHelper.CreateSingleEmail(
new EmailAddress("ops@example.com"), new EmailAddress(to), subject, body, body);
await client.SendEmailAsync(msg);
}
}
Hard-coded API key as fallback (real code, shipped, leaked via decompile), IConfiguration injected directly, no validation.
After — typed options, validated at startup, injected as POCO:
public sealed class SendGridOptions
{
public const string SectionName = "SendGrid";
[Required, MinLength(20)]
public required string ApiKey { get; init; }
[Required, EmailAddress]
public required string FromAddress { get; init; }
[Range(1, 60)]
public int TimeoutSeconds { get; init; } = 10;
}
public sealed class EmailService(
IOptions<SendGridOptions> options,
ISendGridClient client) : IEmailService
{
public async Task SendAsync(string to, string subject, string body, CancellationToken ct)
{
var msg = MailHelper.CreateSingleEmail(
new EmailAddress(options.Value.FromAddress),
new EmailAddress(to),
subject, body, body);
var response = await client.SendEmailAsync(msg, ct);
if (!response.IsSuccessStatusCode) throw new EmailDeliveryException(response.StatusCode);
}
}
// Program.cs
services
.AddOptions<SendGridOptions>()
.Bind(configuration.GetSection(SendGridOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSendGrid((sp, c) =>
{
c.ApiKey = sp.GetRequiredService<IOptions<SendGridOptions>>().Value.ApiKey;
});
The app refuses to start if SendGrid:ApiKey is missing. Secrets come from user-secrets (dev) or the vault (prod). No configuration read inside the service body.
Rule 6: Records, Immutability, and Pattern Matching — Use Modern C#, Not Java-Style POCOs
Cursor's default "data class" is a public class with public mutable properties and a parameterless constructor — a Java-era POCO. C# 9+ records, init-only setters, required members (C# 11+), primary constructors (C# 12+), and pattern matching make most of this ceremony unnecessary. A record is the right shape for DTOs, value objects, and messages; pattern matching replaces the if/else ladders that AI loves to emit; sealed is the default for classes that aren't explicitly designed for inheritance.
The rule:
DTOs, value objects, domain events, and commands are `record` (or
`record struct` for small, frequently-allocated values).
Entities (EF Core-tracked objects with identity) are `class` because
EF requires mutability for change tracking — but properties are
`{ get; private set; }` with explicit methods for state transitions,
not public setters.
All classes are `sealed` by default. Unsealed only when inheritance is
a deliberate, documented design choice.
Properties on immutable types use `init` accessors. `required` is
used for construction invariants (C# 11+):
public sealed record Order
{
public required int Id { get; init; }
public required decimal Total { get; init; }
}
Collections in records expose `IReadOnlyList<T>`, never `List<T>`.
`ImmutableArray<T>` or `ImmutableList<T>` for "this collection never
changes after construction."
Pattern matching replaces type-checking chains:
return order.Status switch
{
OrderStatus.Pending => Process(order),
OrderStatus.Shipped => throw new InvalidOperationException(),
_ => throw new UnreachableException()
};
Property patterns for null-and-shape checks:
if (customer is { Email: { } email, IsActive: true }) { ... }
Nullable deconstruction with pattern matching:
if (result is not { Success: true, Value: var value }) return Results.BadRequest();
`ArgumentOutOfRangeException.ThrowIfNegative` and friends (System.ArgumentException.ThrowIf*)
for parameter validation — not a chain of if/throw.
`UnreachableException` (not `NotImplementedException`) for "this branch
is genuinely unreachable by construction."
Before — mutable POCO, type-check ladder, verbose construction:
public class Order
{
public int Id { get; set; }
public decimal Total { get; set; }
public string Status { get; set; }
public List<OrderItem> Items { get; set; }
}
public string DescribeOrder(Order o)
{
if (o == null) throw new ArgumentNullException(nameof(o));
if (o.Status == "Pending") return "awaiting payment";
else if (o.Status == "Shipped") return "in transit";
else if (o.Status == "Delivered") return "done";
else return "unknown";
}
Anyone can mutate any property. Status is a magic string. The describe method is a ladder that grows linearly with states.
After — sealed record, strong status, switch expression:
public enum OrderStatus { Pending, Shipped, Delivered, Cancelled }
public sealed record OrderItem(int ProductId, int Quantity, decimal UnitPrice);
public sealed record Order(
int Id,
decimal Total,
OrderStatus Status,
IReadOnlyList<OrderItem> Items)
{
public decimal TaxableTotal => Status is OrderStatus.Cancelled ? 0m : Total;
}
public static string Describe(Order order) => order.Status switch
{
OrderStatus.Pending => "awaiting payment",
OrderStatus.Shipped => "in transit",
OrderStatus.Delivered => "done",
OrderStatus.Cancelled => "cancelled",
_ => throw new UnreachableException()
};
Immutable by construction. Exhaustive switch over the enum — adding a new status breaks the build in the right place. Computed properties live on the record.
Rule 7: Testing With xUnit, FluentAssertions, and Testcontainers — Never Mock DbContext
Cursor's default test is a [Fact] that instantiates the service, passes in Mock<IDbContext> from Moq, and asserts that the mock's SaveChangesAsync was called. That test proves nothing — it exercises Moq, not your code. The modern .NET path is: xUnit + FluentAssertions + Testcontainers (real Postgres / SQL Server in a container) + WebApplicationFactory<TProgram> for integration tests against the actual HTTP pipeline, with test data built via object-mothers or a builder pattern, not hand-typed constructors.
The rule:
Test framework: xUnit. `[Fact]` for single cases, `[Theory]` +
`[InlineData]` for parametrized tests. MSTest and NUnit are migrated
on touch.
Assertions: FluentAssertions (`result.Should().Be(...)`). Bare
`Assert.Equal` is allowed only in tests authored before the rule
landed; new tests use FluentAssertions.
NEVER mock EF Core `DbContext`, `DbSet<T>`, or `IQueryable<T>`.
Use Testcontainers to spin up a real Postgres / SQL Server, apply
migrations, seed data, and run against the real provider.
For pure unit tests (domain logic, services with no I/O), use `InMemory`
is STILL forbidden — it diverges from real providers in subtle ways.
Isolate the domain from EF so the unit test has no DbContext at all.
Integration tests use `WebApplicationFactory<Program>` to boot the real
pipeline with test-specific service overrides (e.g., replace
`ISendGridClient` with a fake, keep everything else real).
Test data: object mothers / builders. `OrderBuilder.Valid().WithStatus(...)
.Build()`, not `new Order { Id = 1, Total = 10m, ... }` repeated in every
test.
External services: WireMock.Net for HTTP fakes, Testcontainers for Redis /
Kafka / etc. NEVER mock `HttpClient` directly — use a `DelegatingHandler`
or a fake `HttpMessageHandler`.
Async tests are `async Task` (not `async void`). Every await inside a
test flows a CancellationToken (usually the xUnit `CancellationToken`).
Assertion on collections uses `.Should().BeEquivalentTo(...)` with
`options.Excluding(...)` for fields that shouldn't compare.
One Assert per test is a guideline, not law — multiple asserts are
fine when they verify one logical behavior.
Coverage: >85% on domain/services, >70% on controllers. Mutation
testing via Stryker for critical paths.
Before — mocked DbContext, magic-string assertion, no async test contract:
[Fact]
public void CreateOrder_Saves()
{
var mock = new Mock<OrdersDbContext>();
var set = new Mock<DbSet<Order>>();
mock.Setup(x => x.Orders).Returns(set.Object);
var service = new OrderService(mock.Object);
var order = service.Create(new CreateOrderDto { Total = 10 });
set.Verify(x => x.Add(It.IsAny<Order>()), Times.Once);
Assert.Equal(10, order.Total);
}
Proves Moq works. Proves nothing about the query, the constraint, or what EF will actually do with the entity.
After — real Postgres, builder pattern, WebApplicationFactory:
public sealed class OrdersApiFixture : IAsyncLifetime
{
public PostgreSqlContainer Db { get; } = new PostgreSqlBuilder().Build();
public WebApplicationFactory<Program> App { get; private set; } = null!;
public async ValueTask InitializeAsync()
{
await Db.StartAsync();
App = new WebApplicationFactory<Program>()
.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, c) =>
c.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Orders"] = Db.GetConnectionString(),
})));
using var scope = App.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<OrdersDbContext>().Database.MigrateAsync();
}
public async ValueTask DisposeAsync()
{
await App.DisposeAsync();
await Db.DisposeAsync();
}
}
public sealed class OrdersApiTests(OrdersApiFixture fx) : IClassFixture<OrdersApiFixture>
{
[Fact]
public async Task Post_CreatesOrder_ReturnsCreated()
{
var client = fx.App.CreateClient();
var dto = OrderBuilder.Valid().WithTotal(99.99m).Build();
var response = await client.PostAsJsonAsync("/orders", dto);
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<OrderDto>();
body.Should().NotBeNull();
body!.Total.Should().Be(99.99m);
}
}
Real Postgres, real HTTP pipeline, builder-based input. A future regression in serialization, routing, DI, or SQL translation fails the test.
Rule 8: Global Error Handling, ProblemDetails, and Structured Logging With Serilog
Cursor's default error path is a try/catch (Exception ex) { return BadRequest(ex.Message); } inside every controller action. Stack traces leak to clients. Every endpoint reinvents the error shape. There is no log. The modern .NET path is: a single exception handler middleware (or app.UseExceptionHandler with IExceptionHandler), RFC 7807 ProblemDetails responses, mapped from domain exceptions via a consistent rule, and structured JSON logging to Seq / Elasticsearch / Application Insights via Serilog with request enrichment.
The rule:
Exactly one exception handler lives in the request pipeline, implemented
as `IExceptionHandler` (.NET 8+) registered via
`builder.Services.AddExceptionHandler<GlobalExceptionHandler>()`.
Domain exceptions (DomainException, NotFoundException, ConflictException,
etc.) map to HTTP status codes in the handler, not in the controller:
NotFoundException → 404
ValidationException → 400
ConflictException → 409
DomainException → 422
anything else → 500
Responses are RFC 7807 `ProblemDetails` (or `ValidationProblemDetails`
for 400s). Never a raw string, never ex.Message, never the stack trace.
`try/catch` in controllers and handlers is forbidden unless the catch
does something the global handler can't (e.g., convert to a fallback
value, enrich with context and rethrow).
`catch (Exception) { throw; }` is forbidden — it wrecks the stack trace.
Rethrow with `throw;` only to enrich (log then rethrow), or use
`ExceptionDispatchInfo.Capture(ex).Throw()`.
Logging: Serilog with structured sinks (Seq, Elastic, App Insights,
CloudWatch). `Log.Information("User {UserId} did {Action}", userId,
action)` — never string interpolation.
Serilog request-enrichment middleware adds: traceparent, userId,
tenantId, correlationId. Logs are JSON in production, console in dev.
Sensitive data: never log credentials, tokens, full PII. Use
`Destructure.ByTransforming<T>()` to scrub before serialization.
LogLevel conventions:
- Trace: developer-only diagnostics.
- Debug: detailed flow, opt-in per environment.
- Information: business-visible events (order created, user signed up).
- Warning: recoverable anomaly (retry, fallback, stale cache).
- Error: unhandled exception path, failed invariant.
- Critical: service unable to continue.
HTTP logging (request/response body) is off by default. On only for
specific routes under a feature flag, with body size limits.
OpenTelemetry tracing is wired (`AddOpenTelemetry().WithTracing(...)`)
with AspNetCore, HttpClient, EntityFrameworkCore instrumentation.
Before — per-controller try/catch, leaked stack trace, printf logging:
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
try
{
var order = await _service.GetAsync(id);
return Ok(order);
}
catch (Exception ex)
{
Console.WriteLine($"error for {id}: {ex}");
return BadRequest(ex.Message);
}
}
Returns 400 for not-found. Logs are unstructured. Stack trace potentially reaches the client.
After — global handler, ProblemDetails, structured logs:
public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext ctx,
Exception ex,
CancellationToken ct)
{
var (status, title) = ex switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
ConflictException => (StatusCodes.Status409Conflict, "Conflict"),
DomainException => (StatusCodes.Status422UnprocessableEntity, "Domain error"),
ValidationException => (StatusCodes.Status400BadRequest, "Validation failed"),
_ => (StatusCodes.Status500InternalServerError, "Unexpected error")
};
logger.LogError(ex, "Unhandled {Exception} for {Path}", ex.GetType().Name, ctx.Request.Path);
ctx.Response.StatusCode = status;
await ctx.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = status,
Title = title,
Type = $"https://httpstatuses.com/{status}",
Instance = ctx.Request.Path,
}, ct);
return true;
}
}
// Program.cs
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.Enrich.WithCorrelationId()
.WriteTo.Console(new CompactJsonFormatter())
.WriteTo.Seq("http://seq:5341"));
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.MapGet("/orders/{id:int}", async (int id, IOrderService svc, CancellationToken ct) =>
Results.Ok(await svc.GetAsync(id, ct)));
One handler, consistent shape, structured logs. Not-found returns 404 with a body a client SDK can parse. A developer searching Seq for Exception=NotFoundException finds every instance.
The Complete .cursorrules File
Drop this in the repo root. Cursor and Claude Code both pick it up.
# C# / .NET — Production Patterns
## Async
- Every I/O method is async and takes CancellationToken ct as last param.
- Never block: no .Result, .Wait(), .GetAwaiter().GetResult().
- async void only for event handlers; fire-and-forget goes through
IHostedService / Channel<T> / out-of-proc queue.
- ConfigureAwait(false) in libraries; omit in ASP.NET Core apps.
- Forward ct through every call: SaveChangesAsync(ct), GetAsync(url, ct).
- Never swallow OperationCanceledException.
- ValueTask only when the hot path has a real sync fast path.
## EF Core
- AsNoTracking() on every read query.
- .Select() projections for reads; Include only for write paths.
- Multi-collection Include → AsSplitQuery; otherwise Cartesian.
- No untranslatable methods in LINQ expressions.
- Pagination: Skip/Take with OrderBy; never ToList then paginate.
- Bulk writes: ExecuteUpdateAsync / ExecuteDeleteAsync for predicates.
- No .ToList() mid-query.
- SaveChangesAsync always takes ct. Multi-statement writes use
BeginTransactionAsync(ct).
## Nullable Reference Types
- <Nullable>enable</Nullable> + TreatWarningsAsErrors + nullable as error.
- `!` only with a // NRT: [reason] comment.
- ArgumentNullException.ThrowIfNull / ThrowIfNullOrWhiteSpace at
public entry points.
- Nullable properties are T?; non-null are not null.
- Collection returns never null; empty collection for "none."
- Use NotNullWhen / MaybeNullWhen / MemberNotNull attributes on helpers.
- Dictionary access: TryGetValue, not [key] + null check.
## Dependency Injection
- Default lifetime is Scoped. Singleton only for stateless/thread-safe.
- Services using DbContext are Scoped.
- No IServiceProvider injection for runtime resolution (no service locator).
- Singleton depending on Scoped: open IServiceScope via IServiceScopeFactory.
- IOptions<T> for typed config; IConfiguration never injected into app
services.
- Register via feature extension methods, not inline in Program.cs.
- AddOptions<T>().Bind(...).ValidateDataAnnotations().ValidateOnStart().
## Configuration & Secrets
- Hierarchy: appsettings → appsettings.{Env} → user-secrets → env vars
→ vault.
- Every section is a typed POCO with [Required], [Range], etc.
- Secrets never in appsettings.json.
- appsettings.Development.json has no real secrets; use user-secrets.
- Connection strings via GetConnectionString or typed options.
- Feature flags via IFeatureManager, not bool config values.
## Records & Pattern Matching
- DTOs, value objects, events, commands are `record`.
- Entities are `class` with { get; private set; } and explicit state
methods.
- All classes sealed by default.
- `required` + `init` for construction invariants.
- Collections in records are IReadOnlyList<T> / ImmutableArray<T>.
- Switch expressions over enums with default → UnreachableException.
- Property patterns for null-and-shape checks.
- ArgumentException.ThrowIfNull* / ArgumentOutOfRangeException.ThrowIf*
for arg validation.
## Testing
- xUnit + FluentAssertions. Theory + InlineData for parametrized.
- NEVER mock DbContext / DbSet / IQueryable; use Testcontainers.
- InMemory provider forbidden; real Postgres/SQL Server in container.
- Integration tests use WebApplicationFactory<Program> with targeted
overrides.
- Test data via builders / object mothers.
- HTTP fakes via WireMock.Net or custom HttpMessageHandler.
- Async tests return Task; await every call; thread ct.
- Coverage >85% domain, >70% controllers. Stryker mutation on critical
paths.
## Error Handling & Logging
- One IExceptionHandler registered via AddExceptionHandler<T>.
- Domain exceptions map to status codes in the handler, not controllers.
- Responses are ProblemDetails / ValidationProblemDetails.
- try/catch in controllers only when the catch does something the global
handler can't.
- catch (Exception) { throw; } forbidden; use throw; only to enrich.
- Serilog with structured sinks; JSON in prod, console in dev.
- Message templates with named holes, never string interpolation.
- PII scrubbed via Destructure.ByTransforming.
- OpenTelemetry wired with AspNetCore + HttpClient + EF Core.
End-to-End Example: A Minimal API Endpoint With Options, EF, and Global Errors
Without rules: sync controller, raw IConfiguration, in-memory filter, mocked DbContext test.
[ApiController, Route("orders")]
public class OrdersController : ControllerBase
{
public IActionResult Get(int id)
{
try
{
var db = new OrdersDbContext();
var o = db.Orders.Include(x => x.Items).Where(x => x.Id == id).ToList().FirstOrDefault();
if (o == null) return BadRequest("not found");
return Ok(o);
}
catch (Exception ex) { return StatusCode(500, ex.Message); }
}
}
With rules: Minimal API, projection, options, global error handler, Testcontainers-backed test.
// Program.cs
builder.Services
.AddDbContext<OrdersDbContext>(o => o.UseNpgsql(configuration.GetConnectionString("Orders")))
.AddScoped<IOrderService, OrderService>()
.AddOptions<OrderPolicy>().Bind(configuration.GetSection("OrderPolicy"))
.ValidateDataAnnotations().ValidateOnStart();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>()
.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.MapGet("/orders/{id:int}", async (
int id,
IOrderService svc,
CancellationToken ct) =>
{
var order = await svc.GetAsync(id, ct);
return Results.Ok(order);
});
// OrderService.cs
public sealed class OrderService(OrdersDbContext db) : IOrderService
{
public async Task<OrderDto> GetAsync(int id, CancellationToken ct)
{
return await db.Orders
.AsNoTracking()
.Where(o => o.Id == id)
.Select(o => new OrderDto(o.Id, o.Total, o.Items.Count, o.Customer.Name))
.FirstOrDefaultAsync(ct)
?? throw new NotFoundException($"order {id}");
}
}
// OrdersApiTests.cs
[Fact]
public async Task Get_ExistingOrder_Returns200()
{
var client = fx.App.CreateClient();
var seeded = await fx.SeedOrderAsync(OrderBuilder.Valid().Build());
var response = await client.GetAsync($"/orders/{seeded.Id}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<OrderDto>();
body!.Id.Should().Be(seeded.Id);
}
Async end-to-end, projected query, typed config, global errors, real DB in the test. A missing order returns 404 through the handler with a ProblemDetails body.
Get the Full Pack
These eight rules cover the .NET patterns where AI assistants consistently reach for the wrong idiom. Drop them into .cursorrules and the next prompt you write will look different — async-correct, EF-disciplined, nullable-aware, DI-clean, typed-configured, record-oriented, Testcontainers-tested, globally-handled C#, without having to re-prompt.
If you want the expanded pack — these eight plus rules for SignalR, minimal API route groups, FluentValidation, MediatR / vertical-slice architecture, OpenAPI with NSwag/Kiota, background workers with IHostedService and Channel<T>, caching with HybridCache (.NET 9+), outbox pattern for transactional messaging, and the deploy patterns I use for ASP.NET Core on Kubernetes — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship .NET you would actually merge.
Top comments (0)