---
title: "skill by agenticluke · skilld"
canonical_url: "https://skilld.dev/gh/agenticluke/dotnet-patterns-plus"
meta:
  description: "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. From agenticluke/dotnet-patterns-plus."
  "og:description": "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. From agenticluke/dotnet-patterns-plus."
  "og:title": "skill by agenticluke"
  "twitter:description": "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. From agenticluke/dotnet-patterns-plus."
  "twitter:title": "skill by agenticluke"
---

`

[All skills](https://skilld.dev/skills)

[![agenticluke avatar](https://skilld.dev/_img/avatar?url=https%3A%2F%2Fgithub.com%2Fagenticluke.png%3Fsize%3D96)](https://skilld.dev/gh/agenticluke)

# **/skill**

[@f649f97](https://github.com/agenticluke/dotnet-patterns-plus/commit/f649f970372c4df76cc66b4bbc3d0b1851436094 "Your agent reads SKILL.md at commit f649f97")

by [agenticluke](https://skilld.dev/gh/agenticluke)· [agenticluke](https://skilld.dev/gh/agenticluke)/ [dotnet-patterns-plus](https://skilld.dev/gh/agenticluke/dotnet-patterns-plus)

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](https://github.com/agenticluke/dotnet-patterns-plus/blob/f649f970372c4df76cc66b4bbc3d0b1851436094/skill/SKILL.md "View SKILL.md on GitHub")

## SKILL.md

12.6 KB

**≈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](https://github.com/agenticluke/dotnet-patterns-plus/blob/f649f970372c4df76cc66b4bbc3d0b1851436094/skill/SKILL.md)

## Third-party checks

No third-party reports yet.

## Provenance

[Signed by skilld at f649f97.](https://github.com/agenticluke/dotnet-patterns-plus/commit/f649f970372c4df76cc66b4bbc3d0b1851436094 "f649f970372c4df76cc66b4bbc3d0b1851436094") 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

## Capability

<dl>

<dt>origin</dt>
<dd>ECC</dd>

</dl>

## README badge

![README badge for agenticluke/dotnet-patterns-plus](https://skilld.dev/b/agenticluke/dotnet-patterns-plus?theme=light&label=0)

## Related skills

-
-
-
-
-
-