.NET Patterns
Original work by ECC. Credit belongs to ECC.
Use this skill when you write, review, or change C# and .NET code.
When to Use
Use it for:
- New C# code
- C# code reviews
- .NET code cleanup
- ASP.NET Core apps
- Service setup
- EF Core data access
- Async code
- API routes
Follow the style already used by the project when it is safe and clear.
Main Rules
1. Keep Data Fixed When You Can
Use records or init fields for data that should not change.
public sealed record Money(decimal Amount, string Currency);
public sealed class CreateOrderRequest
{
public required string CustomerId { get; init; }
public required IReadOnlyList<OrderItem> Items { get; init; }
}Use changeable fields only when the app needs them. Do not expose a changeable List<T> when callers should only read it.
Check values at the edge of the app. A required field can still hold an empty string or a null value from bad input.
2. Make Intent Clear
Turn on nullable checks in the project.
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>Use clear access rules and types.
public sealed class UserService
{
private readonly IUserRepository _repository;
public UserService(IUserRepository repository)
{
ArgumentNullException.ThrowIfNull(repository);
_repository = repository;
}
public Task<User?> FindByIdAsync(
Guid id,
CancellationToken cancellationToken)
{
return _repository.FindByIdAsync(id, cancellationToken);
}
}Do not add async and await when you only return one task and do no other work.
3. Use Small Service Boundaries
Use an interface when code needs a clear boundary, more than one form, or a test fake.
public interface IOrderRepository
{
Task<Order?> FindByIdAsync(
Guid id,
CancellationToken cancellationToken);
Task AddAsync(
Order order,
CancellationToken cancellationToken);
}Register each service with the right life span.
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();Use:
Singletonfor one safe shared objectScopedfor one object per web requestTransientfor a new small object each time
Do not put a scoped service inside a singleton. Do not use a service locator when normal constructor input will work.
Async Code
Use async code from the API route down to the data call.
public async Task<OrderSummary> GetOrderSummaryAsync(
Guid orderId,
CancellationToken cancellationToken)
{
var order = await _repository.FindByIdAsync(
orderId,
cancellationToken);
if (order is null)
{
throw new NotFoundException($"Order {orderId} was not found.");
}
var customer = await _customerService.GetAsync(
order.CustomerId,
cancellationToken);
return new OrderSummary(order, customer);
}Pass CancellationToken through each async call. Use CancellationToken.None only when canceling must not stop the work.
Do not block on a task.
// Bad
var order = repository.FindByIdAsync(id, token).Result;
// Bad
repository.AddAsync(order, token).Wait();Use async void only for event handlers. Use Task for other async methods.
Run Safe Work at the Same Time
Run calls at the same time only when they do not depend on each other.
public async Task<DashboardData> LoadDashboardAsync(
CancellationToken cancellationToken)
{
var ordersTask = _orderService.GetRecentAsync(cancellationToken);
var alertsTask = _alertService.GetActiveAsync(cancellationToken);
await Task.WhenAll(ordersTask, alertsTask);
return new DashboardData(
Orders: await ordersTask,
Alerts: await alertsTask);
}Do not use the same EF Core DbContext in two calls at the same time. A DbContext cannot safely do that.
Do not start a very large number of tasks at once. Set a limit for large sets of work.
App Settings
Bind settings to a typed class. Check them when the app starts.
public sealed class SmtpOptions
{
public const string SectionName = "Smtp";
public required string Host { get; init; }
public int Port { get; init; }
public bool UseSsl { get; init; } = true;
}builder.Services
.AddOptions<SmtpOptions>()
.Bind(builder.Configuration.GetSection(SmtpOptions.SectionName))
.Validate(
value => !string.IsNullOrWhiteSpace(value.Host),
"Smtp:Host is required.")
.Validate(
value => value.Port is > 0 and <= 65535,
"Smtp:Port must be from 1 to 65535.")
.ValidateOnStart();Use IOptions<T> for fixed settings. Use IOptionsMonitor<T> only when settings may change while the app runs.
Do not store passwords or keys in source files.
Expected Failures
Use a result type for normal failures. Examples include bad input, a missing item, or a rule that blocks a request.
Use an exception for a bug or a failure the current code cannot handle.
public sealed record Error(string Code, string Message);
public sealed record Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public Error? Error { get; }
private Result(T value)
{
IsSuccess = true;
Value = value;
}
private Result(Error error)
{
IsSuccess = false;
Error = error;
}
public static Result<T> Success(T value)
{
ArgumentNullException.ThrowIfNull(value);
return new Result<T>(value);
}
public static Result<T> Failure(string code, string message)
{
return new Result<T>(new Error(code, message));
}
}Do not read Value until IsSuccess is true. Use stable error codes in API code. Do not make app logic depend on error text.
EF Core Data Access
Use AsNoTracking() for read-only work. Pass the cancel token to EF Core.
public sealed class SqlOrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public SqlOrderRepository(AppDbContext db)
{
_db = db;
}
public Task<Order?> FindByIdAsync(
Guid id,
CancellationToken cancellationToken)
{
return _db.Orders
.Include(order => order.Items)
.AsNoTracking()
.FirstOrDefaultAsync(
order => order.Id == id,
cancellationToken);
}
public async Task AddAsync(
Order order,
CancellationToken cancellationToken)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync(cancellationToken);
}
}Also follow these rules:
- Pick only the fields you need for large reads.
- Page long lists.
- Sort rows before paging.
- Avoid one data call per row.
- Use a data rule for values that must be unique.
- Handle update clashes when two users can change the same row.
- Keep one
DbContextfor one short unit of work. - Do not return an EF query from a service boundary.
Add a new repository only when it makes the code clearer. EF Core already gives basic data access tools.
Request Middleware
Keep middleware small. Always call the next step unless the middleware ends the request on purpose.
public sealed class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(
RequestDelegate next,
ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var timer = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
timer.Stop();
_logger.LogInformation(
"Request {Method} {Path} took {ElapsedMs} ms. Status {StatusCode}",
context.Request.Method,
context.Request.Path,
timer.ElapsedMilliseconds,
context.Response.StatusCode);
}
}
}Register it in the right order.
app.UseExceptionHandler();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<RequestTimingMiddleware>();Do not log passwords, tokens, request bodies, or other private data.
Minimal APIs
Group related routes. Check input. Use typed results.
var orders = app.MapGroup("/api/orders")
.RequireAuthorization()
.WithTags("Orders");
orders.MapGet("/{id:guid}", async Task<Results<
Ok<Order>,
NotFound>> (
Guid id,
IOrderRepository repository,
CancellationToken cancellationToken) =>
{
var order = await repository.FindByIdAsync(
id,
cancellationToken);
return order is null
? TypedResults.NotFound()
: TypedResults.Ok(order);
});Map each result to the right status code.
200for a good read201for a new item204for success with no body400for bad request data401when sign-in is missing403when access is blocked404when an item is missing409for a state clash
Do not send stack traces or private error data to clients.
Guard Checks
Fail early when input is not valid.
public async Task<Result<Payment>> ProcessPaymentAsync(
PaymentRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (request.Amount <= 0)
{
return Result<Payment>.Failure(
"invalid_amount",
"Amount must be more than zero.");
}
if (string.IsNullOrWhiteSpace(request.Currency))
{
return Result<Payment>.Failure(
"missing_currency",
"Currency is required.");
}
return await _paymentGateway.ChargeAsync(
request,
cancellationToken);
}Use exceptions for bad code input. Use result errors for bad user input.
Resource Cleanup
Use using or await using for objects that must be closed.
await using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
var text = await reader.ReadToEndAsync(cancellationToken);Do not close an object that is owned by the service container or by another caller.
Logs and Errors
Use named log fields.
_logger.LogInformation(
"Order {OrderId} was placed for customer {CustomerId}",
order.Id,
order.CustomerId);Do not build log text with string joins. Do not catch an error only to throw the same error again.
Catch an error only when you can:
- Add useful facts
- Change it to a known result
- Retry safe work
- Clean up work
Never hide an error with an empty catch block.
Tests
Test public behavior. Cover success, bad input, missing data, canceling, and data clashes.
[Fact]
public async Task PlaceOrder_ReturnsFailure_WhenItemsAreEmpty()
{
var request = new CreateOrderRequest
{
CustomerId = "customer-1",
Items = Array.Empty<OrderItem>()
};
var result = await service.PlaceOrderAsync(
request,
CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal("empty_order", result.Error?.Code);
}Use a real test data store for EF Core query tests when query behavior matters. A mock may not act like the real store.
Concrete Usage Example
Task:
Add a route that gets an order by ID.
Apply this skill as follows:
- Add
FindByIdAsyncto the service boundary. - Pass
CancellationTokenfrom the route to EF Core. - Use
AsNoTracking()because the read does not change data. - Return
404when the order does not exist. - Return
200with the order when it exists. - Add tests for both paths.
orders.MapGet("/{id:guid}", async (
Guid id,
IOrderRepository repository,
CancellationToken cancellationToken) =>
{
var order = await repository.FindByIdAsync(
id,
cancellationToken);
return order is null
? Results.NotFound()
: Results.Ok(order);
});Review List
Before the work is done, check that:
- Nullable checks are on.
- Names state what the code does.
- Input is checked.
- Async calls are not blocked.
- Cancel tokens are passed on.
- Service life spans are safe.
- Read-only EF calls use
AsNoTracking(). - Large lists use paging.
- Expected failures have clear results.
- Private data is not logged.
- Errors are not hidden.
- Tests cover key paths.
- The code builds and tests pass.