All skills

Use clear C# and .NET patterns for safe, fast, and easy-to-change apps. Covers dependency injection, async code, EF Core, options, results, APIs, and tests.

  • 1 file
  • 12.6 KB
  • Updated 3 weeks ago
  • GitHub

Use this Skill: https://skilld.dev/gh/agenticluke/dotnet-patterns-plus/skill

This session only. Nothing lands on disk.

SKILL.md

≈41 tokens always: the name and description. ≈3.2k when used: this file.

.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:

  • Singleton for one safe shared object
  • Scoped for one object per web request
  • Transient for 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 DbContext for 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.

  • 200 for a good read
  • 201 for a new item
  • 204 for success with no body
  • 400 for bad request data
  • 401 when sign-in is missing
  • 403 when access is blocked
  • 404 when an item is missing
  • 409 for 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:

  1. Add FindByIdAsync to the service boundary.
  2. Pass CancellationToken from the route to EF Core.
  3. Use AsNoTracking() because the read does not change data.
  4. Return 404 when the order does not exist.
  5. Return 200 with the order when it exists.
  6. 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.

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at f649f97. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 weeks ago.

Activeupdated 3 weeks ago
origin
ECC

README badge

README badge for agenticluke/dotnet-patterns-plus