275 lines
7.8 KiB
Markdown
275 lines
7.8 KiB
Markdown
# .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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
[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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// 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 => { ... });
|
|
```
|