Files
accounting-system/.opencode/skills/backend-patterns/SKILL.md
2026-02-04 23:30:06 +01:00

7.8 KiB

.NET Development Best Practices

Project Structure

src/
  Domain/           # Entities, value objects, domain events
  Application/      # Use cases, DTOs, interfaces
  Infrastructure/   # EF Core, external services
  Api/              # Controllers, middleware, filters
tests/
  Unit/
  Integration/

Code Style

// Use records for DTOs and value objects
public sealed record CreateDocumentRequest(string Name, string Type);

// Use primary constructors
public class DocumentService(IRepository<Document> repo, ILogger<DocumentService> logger)
{
    public async Task<Document?> GetAsync(Guid id, CancellationToken ct) =>
        await repo.GetByIdAsync(id, ct);
}

// Prefer expression body for simple methods
public Document? FindById(Guid id) => _documents.FirstOrDefault(d => d.Id == id);

// Use collection expressions
int[] numbers = [1, 2, 3];
List<string> names = ["Alice", "Bob"];

Async/Await

// Always pass CancellationToken
public async Task<Document> GetAsync(Guid id, CancellationToken ct)

// Use ConfigureAwait(false) in libraries
await _httpClient.GetAsync(url, ct).ConfigureAwait(false);

// Avoid async void (except event handlers)
public async Task ProcessAsync() { }  // Good
public async void Process() { }       // Bad

// Use ValueTask for hot paths with frequent sync completion
public ValueTask<int> GetCachedCountAsync()

Dependency Injection

// Register by interface
builder.Services.AddScoped<IDocumentService, DocumentService>();

// Use Options pattern for configuration
builder.Services.Configure<AppSettings>(builder.Configuration.GetSection("App"));

public class MyService(IOptions<AppSettings> options)
{
    private readonly AppSettings _settings = options.Value;
}

// Avoid service locator pattern
// Bad: var service = serviceProvider.GetService<IMyService>();
// Good: Constructor injection

Entity Framework Core

// Always use AsNoTracking for read-only queries
await _context.Documents.AsNoTracking().ToListAsync(ct);

// Use projection to select only needed fields
await _context.Documents
    .Where(d => d.Status == "Active")
    .Select(d => new DocumentDto(d.Id, d.Name))
    .ToListAsync(ct);

// Prevent N+1 with Include or projection
await _context.Documents.Include(d => d.Labels).ToListAsync(ct);

// Use explicit transactions for multiple operations
await using var tx = await _context.Database.BeginTransactionAsync(ct);

// Configure entities with IEntityTypeConfiguration
public class DocumentConfiguration : IEntityTypeConfiguration<Document>
{
    public void Configure(EntityTypeBuilder<Document> builder)
    {
        builder.HasKey(d => d.Id);
        builder.Property(d => d.Name).HasMaxLength(200).IsRequired();
        builder.HasIndex(d => d.Status);
    }
}

Error Handling

// Create domain-specific exceptions
public class NotFoundException(string resource, Guid id)
    : Exception($"{resource} not found: {id}");

// Use global exception handler
public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
    {
        logger.LogError(ex, "Error: {Message}", ex.Message);
        ctx.Response.StatusCode = ex is NotFoundException ? 404 : 500;
        await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }, ct);
        return true;
    }
}

// Use Result pattern for expected failures
public Result<Document> Validate(CreateRequest request) =>
    string.IsNullOrEmpty(request.Name)
        ? Result<Document>.Fail("Name is required")
        : Result<Document>.Ok(new Document(request.Name));

Validation

// Use FluentValidation
public class CreateDocumentValidator : AbstractValidator<CreateDocumentRequest>
{
    public CreateDocumentValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
        RuleFor(x => x.Type).Must(BeValidType).WithMessage("Invalid document type");
    }
}

// Or use Data Annotations for simple cases
public record CreateRequest(
    [Required, MaxLength(200)] string Name,
    [Range(1, 100)] int Quantity);

Logging

// Use structured logging with templates
logger.LogInformation("Processing document {DocumentId} for user {UserId}", docId, userId);

// Use appropriate log levels
logger.LogDebug("Cache hit for key {Key}", key);           // Development details
logger.LogInformation("Document {Id} created", id);         // Normal operations
logger.LogWarning("Retry attempt {Attempt} for {Op}", n, op); // Potential issues
logger.LogError(ex, "Failed to process {DocumentId}", id);  // Errors

// Configure log filtering in appsettings
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore": "Warning"
    }
  }
}

API Design

[ApiController]
[Route("api/v1/[controller]")]
public class DocumentsController(IDocumentService service) : ControllerBase
{
    [HttpGet("{id:guid}")]
    [ProducesResponseType<Document>(200)]
    [ProducesResponseType(404)]
    public async Task<IActionResult> Get(Guid id, CancellationToken ct)
    {
        var doc = await service.GetAsync(id, ct);
        return doc is null ? NotFound() : Ok(doc);
    }

    [HttpPost]
    public async Task<IActionResult> Create(CreateRequest request, CancellationToken ct)
    {
        var doc = await service.CreateAsync(request, ct);
        return CreatedAtAction(nameof(Get), new { id = doc.Id }, doc);
    }
}

Testing

// Use descriptive test names
[Fact]
public async Task GetById_WithValidId_ReturnsDocument()
{
    // Arrange
    var repo = Substitute.For<IRepository<Document>>();
    repo.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
        .Returns(new Document("Test"));
    var service = new DocumentService(repo);

    // Act
    var result = await service.GetAsync(Guid.NewGuid(), CancellationToken.None);

    // Assert
    result.Should().NotBeNull();
    result!.Name.Should().Be("Test");
}

// Use WebApplicationFactory for integration tests
public class ApiTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task GetDocuments_ReturnsSuccess()
    {
        var client = factory.CreateClient();
        var response = await client.GetAsync("/api/v1/documents");
        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}

Performance

// Use IMemoryCache for frequently accessed data
public class CachedService(IMemoryCache cache, IRepository<Document> repo)
{
    public async Task<Document?> GetAsync(Guid id, CancellationToken ct) =>
        await cache.GetOrCreateAsync($"doc:{id}", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            return await repo.GetByIdAsync(id, ct);
        });
}

// Use pagination for large collections
public async Task<PagedResult<Document>> GetPagedAsync(int page, int size, CancellationToken ct) =>
    new(
        await _context.Documents.Skip((page - 1) * size).Take(size).ToListAsync(ct),
        await _context.Documents.CountAsync(ct)
    );

// Use IAsyncEnumerable for streaming large datasets
public async IAsyncEnumerable<Document> StreamAllAsync([EnumeratorCancellation] CancellationToken ct)
{
    await foreach (var doc in _context.Documents.AsAsyncEnumerable().WithCancellation(ct))
        yield return doc;
}

Security

// Never hardcode secrets
var apiKey = builder.Configuration["ApiKey"];  // From environment/secrets

// Use parameterized queries (EF Core does this automatically)
// Bad: $"SELECT * FROM Users WHERE Id = {id}"
// Good: _context.Users.Where(u => u.Id == id)

// Validate and sanitize all inputs
// Use HTTPS in production
// Implement rate limiting
builder.Services.AddRateLimiter(options => { ... });