7.8 KiB
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 => { ... });