Modern C# and .NET services
SkillDev toolsUse when writing, reviewing, testing, or shipping C# / .NET code — ASP.NET Core APIs (minimal APIs vs controllers), EF Core data access, async correctness, solution layout in .cs/.csproj/.sln. NOT a Java/Spring backend (that is spring-boot), NOT a Node/TypeScript backend (that is nestjs), NOT framework-neutral REST naming (that is api-design).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Modern C# and .NET services skill
What this skill tells your AI
The instructions your AI receives, as published by ericrisco/rsc-harness in skills/csharp-dotnet/SKILL.md and read by ahel’s review.
You write and review C#, ASP.NET Core APIs, and EF Core data access. You own the .NET idioms, the HTTP-contract patterns as expressed in .NET, async correctness, and the .NET-specific quality/security gates. You delegate everything language- agnostic to the right sibling.
Target: .NET 10 (LTS, released 2025-11-11, supported 3 years) with C# 14, EF Core
10, ASP.NET Core 10. .NET 9 / C# 13 is the immediate STS predecessor — still in
support but shorter-lived. Features gate on the target framework moniker (TFM): a
net10.0 project gets C# 14 by default; a net9.0 project caps at C# 13. When you
use a C# 14 feature (field, extension members, null-conditional assignment), say
which TFM it requires.
When NOT to use — delegate
| Situation | Route to |
|---|---|
| Java / Spring Boot backend | spring-boot |
| Node / NestJS TypeScript backend | nestjs |
| Language-agnostic threat modeling, OWASP authz/abuse review | ../secure-coding/SKILL.md |
| Raw Postgres schema/index/query tuning | ../postgresdb/SKILL.md |
| Framework-neutral REST resource taxonomy | api-design |
| Dockerfile / CI pipeline / deploy target | ../deployment/SKILL.md |
| Recording per-project conventions in the workspace wiki | ../harness/SKILL.md |
The .NET expression of an API contract and async correctness live HERE. Resource
naming theory lives in api-design; the JVM and Node counterparts are spring-boot
and nestjs.
Project & solution layout + dotnet CLI
dotnet new sln -n Shop # solution
dotnet new webapi -n Shop.Api # minimal API (default in .NET 10)
dotnet new webapi -n Shop.Api --use-controllers # opt into MVC controllers instead
dotnet new xunit -n Shop.Api.Tests # test project
dotnet sln add Shop.Api Shop.Api.Tests # wire into the solution
dotnet add Shop.Api package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet ef migrations add Initial -p Shop.Api # create a migration
dotnet ef database update -p Shop.Api # apply migrations
dotnet build -warnaserror # compile; analyzers + NRT as errors
dotnet test # run tests
dotnet format # apply style; --verify-no-changes in CI
dotnet publish -c Release # produce deployable output
Recommended shape:
Shop.sln
Directory.Packages.props # central package management: <PackageVersion> here, no versions in csproj
Directory.Build.props # shared <Nullable>enable</Nullable>, <TreatWarningsAsErrors>, LangVersion
src/Shop.Api/ # endpoints grouped by feature module (Products/, Orders/)
src/Shop.Domain/ # entities, value objects (no EF/ASP.NET dependency)
tests/Shop.Api.Tests/
Central package management: turn it on with <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
and list versions once in Directory.Packages.props. Why: one source of truth, no
version drift across projects.
NRT on for every project — <Nullable>enable</Nullable> in Directory.Build.props.
Why: the compiler turns whole classes of NullReferenceException into build-time
warnings. Never disable it to silence a warning — fix the nullability.
Modern C# idioms (C# 14 / .NET 10)
DTO as a record, not a class:
// Bad: mutable class, no value equality, hand-written boilerplate
public class ProductDto { public int Id { get; set; } public string Name { get; set; } }
// Good: immutable record DTO
public record ProductDto(int Id, string Name, decimal Price);
Why: value equality, with expressions, no boilerplate — and never reuse an EF entity
as the wire DTO (see anti-patterns).
Pattern matching over if-chains:
// Bad
if (shape is Circle) { var c = (Circle)shape; return Math.PI * c.R * c.R; }
// Good
return shape switch
{
Circle c => Math.PI * c.R * c.R,
Rectangle r => r.W * r.H,
_ => throw new ArgumentOutOfRangeException(nameof(shape)),
};
field keyword — field-backed property without a hand-written backing field
(requires C# 14 / net10.0):
// Bad: explicit backing field just to trim a string
private string _name = "";
public string Name { get => _name; set => _name = value.Trim(); }
// Good (C# 14)
public string Name { get; set => field = value.Trim(); }
Primary constructors, required/init, collection expressions:
public class OrderService(IOrderRepository repo, ILogger<OrderService> log) // primary ctor: DI in one line
{
public required string Region { get; init; } // must be set at construction, then immutable
private static readonly int[] DefaultTiers = [1, 2, 3]; // collection expression
}
Prefer NRT annotations over defensive null checks: declare string? note when null is
valid and let the compiler force callers to handle it, instead of if (x == null)
guards scattered everywhere.
Dispose deterministically: using / await using (or using declarations) for
anything IDisposable/IAsyncDisposable. Why: leaked connections and handles.
async/await correctness
This is the highest-leverage area to get right. Core rules in body; the full catalog (ConfigureAwait, ValueTask, IAsyncEnumerable, Channels, parallelism, deadlock cases) is in references/async.md — read it before any non-trivial async review.
- No
async voidexcept top-level event handlers. Why: exceptions escape onto the thread pool and crash the process; the caller cannotawaitor catch it. Useasync Task. - Never
.Result/.Wait()/GetAwaiter().GetResult()on the request path. This is the classic deadlock and the answer to "my endpoint hangs under load":
// Bad: blocks the thread on an async call -> thread-pool starvation / deadlock under load
public IActionResult Get() => Ok(_svc.LoadAsync().Result);
// Good: await all the way up
public async Task<IActionResult> Get(CancellationToken ct) => Ok(await _svc.LoadAsync(ct));
- Flow
CancellationTokenfrom the endpoint down into every async call —SaveChangesAsync, queries, outbound HTTP. Why: a dropped token means the request keeps running after the client gives up. ValueTaskonly for hot paths that usually complete synchronously; default toTask. NeverawaitaValueTasktwice.IAsyncEnumerable<T>for streaming results instead of materializing a huge list.
ASP.NET Core APIs
Decision: minimal API for new services; controllers only when you need MVC machinery (model binding conventions, action filters at scale, views). Both can coexist. Why minimal by default: less ceremony, first-class in .NET 10.
Production minimal API shape — route group + DataAnnotations validation (built in for minimal API parameters in .NET 10) + ProblemDetails + OpenAPI 3.1:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(); // OpenAPI 3.1, JSON Schema 2020-12
builder.Services.AddProblemDetails(); // RFC 9457 error bodies
builder.Services.AddDbContext<ShopDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));
var app = builder.Build();
app.MapOpenApi(); // serves the OpenAPI document
app.UseExceptionHandler(); // emits ProblemDetails on unhandled errors
var products = app.MapGroup("/products").WithTags("Products");
products.MapGet("/{id:int}", async (int id, ShopDb db, CancellationToken ct) =>
await db.Products.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price)) // project to DTO, no entity leak
.FirstOrDefaultAsync(ct) is { } dto
? Results.Ok(dto)
: Results.NotFound());
products.MapPost("/", async (CreateProduct req, ShopDb db, CancellationToken ct) =>
{ // DataAnnotations on CreateProduct are validated automatically in .NET 10
var entity = new Product { Name = req.Name, Price = req.Price };
db.Products.Add(entity);
await db.SaveChangesAsync(ct);
return Results.Created($"/products/{entity.Id}", new ProductDto(entity.Id, entity.Name, entity.Price));
});
app.Run();
DI lifetimes: Singleton (one for the app), Scoped (one per request — DbContext
lives here; it is not thread-safe, so sharing one across requests corrupts change
tracking), Transient (new each resolve). Never inject a Scoped service into a
Singleton (captive dependency — see anti-patterns). Middleware ordering, endpoint
filters, the options pattern, and auth defaults are in references/aspnetcore.md.
EF Core 10
// Bad: tracking + N+1 — loads orders, then one query per order's customer
var orders = await db.Orders.ToListAsync(ct);
foreach (var o in orders) { var name = o.Customer.Name; /* lazy/round-trip per row */ }
// Good: no-tracking read + single query via projection
var rows = await db.Orders.AsNoTracking()
.Select(o => new OrderRow(o.Id, o.Customer.Name)) // EF translates to one JOIN
.ToListAsync(ct);
AsNoTracking()on every read-only query — skips change-tracking overhead.- Kill N+1 with projection (
Select) orInclude/ThenInclude; useAsSplitQuery()when anIncludecartesian explosion hurts. - Migrations:
dotnet ef migrations add <Name>then review the generatedUp/Downbeforedatabase update. Never hand-edit applied migrations. - EF Core 10 highlights: native
jsoncolumn type,vectortype +VECTOR_DISTANCE()for embeddings/RAG,LeftJoin/RightJoinLINQ operators, and named query filters (multiple filters per entity, selectively disabled).
Raw SQL only via parameterized FromSql/ExecuteSql interpolated strings (EF
parameterizes the holes). Query patterns, migration workflow, the EF Core 10 features,
and raw-SQL safety in full are in references/efcore.md. Schema/index tuning is
../postgresdb/SKILL.md.
.NET security controls
Keep only the .NET-specific controls here; threat modeling and OWASP review go to
../secure-coding/SKILL.md.
- Parameterized data access — EF LINQ / parameterized
FromSql/ ADO.NETSqlParameter. Never concatenate user input into SQL. Why: SQL injection. - Secrets —
dotnet user-secretsin dev, Key Vault / environment in prod. Never inappsettings.jsonor source. - Antiforgery for cookie-auth browser POSTs; Data Protection for cookies/tokens (configure a persisted key ring across instances).
- Auth defaults — authenticate then authorize via middleware; prefer policy-based authorization; ASP.NET Core 10 is secure-by-default but still verify HTTPS redirection and HSTS in production.
Testing
- xUnit is the default. Unit-test domain logic with no framework dependency.
- Integration tests via
WebApplicationFactory<Program>— boots the real pipeline in-memory and lets youHttpClientyour endpoints. - Testcontainers to run a real Postgres in a container for EF integration tests instead of the in-memory provider (which lies about relational behavior).
- Gate everything with
scripts/verify.sh(format + build-warnaserror + test + vuln scan).
Anti-patterns
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
async void (non-event-handler) | Exceptions crash the process; uncatchable by caller | async Task |
.Result / .Wait() on request path | Deadlock / thread-pool starvation under load | await all the way |
| Scoped service injected into a Singleton | Captive dependency: stale/DbContext reused across requests | Inject IServiceScopeFactory, or make the consumer scoped |
| Reusing an EF entity as the wire DTO | Over-posting, serialization cycles, leaked columns | Separate record DTO + mapping |
| Tracking queries on read-only reads | Wasted memory/CPU on change tracking | AsNoTracking() |
| Lazy navigation in a loop | N+1 round-trips | Projection or Include |
Missing CancellationToken | Work continues after client disconnects | Flow the token to all I/O |
catch (Exception) { } swallow | Hides failures; corrupt state continues | Handle specifically or let UseExceptionHandler map it |
<Nullable>disable</Nullable> to mute a warning | Re-opens the NRE class of bugs | Keep NRT on; fix the nullability |
Project grounding
Record per-project conventions (target TFM, package manager, lint rules, deploy
target) in the workspace wiki via ../harness/SKILL.md, not inline assumptions. Before
calling a C# change done, run scripts/verify.sh from the solution/project root.
Signals
- GitHub stars
- 82
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
csharp-dotnet- Source
- github.com/ericrisco/rsc-harness