ASP.NET Core Best Practices

#dotnet #aspnetcore #bestpractices #architecture #performance

Last Updated: May 18, 2025 Related: .NET vs Laravel Complete Developer Guide


Quick Navigation


Project Structure & Architecture

๐Ÿ—๏ธ Clean Architecture Structure

Solution/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ API/                    # Web API Controllers
โ”‚   โ”œโ”€โ”€ Application/            # Business Logic
โ”‚   โ”‚   โ”œโ”€โ”€ Commands/
โ”‚   โ”‚   โ”œโ”€โ”€ Queries/
โ”‚   โ”‚   โ””โ”€โ”€ Services/
โ”‚   โ”œโ”€โ”€ Domain/                 # Entities & Business Rules
โ”‚   โ”‚   โ”œโ”€โ”€ Entities/
โ”‚   โ”‚   โ”œโ”€โ”€ ValueObjects/
โ”‚   โ”‚   โ””โ”€โ”€ Interfaces/
โ”‚   โ””โ”€โ”€ Infrastructure/         # Data Access & External Services
โ”‚       โ”œโ”€โ”€ Data/
โ”‚       โ”œโ”€โ”€ Services/
โ”‚       โ””โ”€โ”€ Repositories/
โ”œโ”€โ”€ tests/
โ”‚   โ”œโ”€โ”€ UnitTests/
โ”‚   โ”œโ”€โ”€ IntegrationTests/
โ”‚   โ””โ”€โ”€ ApiTests/
โ””โ”€โ”€ docs/

๐Ÿ“ Folder Organization Best Practices

// โœ… Good: Organized by feature
Controllers/
โ”œโ”€โ”€ Users/
โ”‚   โ”œโ”€โ”€ UsersController.cs
โ”‚   โ”œโ”€โ”€ UserDto.cs
โ”‚   โ””โ”€โ”€ UserValidator.cs
โ”œโ”€โ”€ Orders/
โ”‚   โ”œโ”€โ”€ OrdersController.cs
โ”‚   โ”œโ”€โ”€ OrderDto.cs
โ”‚   โ””โ”€โ”€ OrderValidator.cs

// โŒ Bad: Organized by type
Controllers/
โ”œโ”€โ”€ UsersController.cs
โ”œโ”€โ”€ OrdersController.cs
DTOs/
โ”œโ”€โ”€ UserDto.cs
โ”œโ”€โ”€ OrderDto.cs

Configuration Management

โš™๏ธ Configuration Hierarchy

// appsettings.json (base)
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=MyApp;Trusted_Connection=true;"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information"
    }
  }
}

// appsettings.Development.json (overrides)
{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft.AspNetCore": "Warning"
    }
  }
}

// appsettings.Production.json (overrides)
{
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  }
}

๐Ÿ”’ Secrets Management

// โœ… Good: Use User Secrets for development
// dotnet user-secrets init
// dotnet user-secrets set "ConnectionStrings:DefaultConnection" "secret-connection"

// โœ… Good: Use Azure Key Vault for production
public static void Main(string[] args)
{
    CreateHostBuilder(args).Build().Run();
}

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            if (context.HostingEnvironment.IsProduction())
            {
                var keyVaultEndpoint = new Uri(Environment.GetEnvironmentVariable("VaultUri"));
                config.AddAzureKeyVault(keyVaultEndpoint, new DefaultAzureCredential());
            }
        });

๐Ÿท๏ธ Strongly Typed Configuration

// Configuration class
public class DatabaseSettings
{
    public const string SectionName = "Database";
    
    public string ConnectionString { get; set; } = string.Empty;
    public int CommandTimeout { get; set; } = 30;
    public bool EnableSensitiveDataLogging { get; set; } = false;
}

// Registration in Program.cs
builder.Services.Configure<DatabaseSettings>(
    builder.Configuration.GetSection(DatabaseSettings.SectionName));

// Usage in services
public class UserService
{
    private readonly DatabaseSettings _dbSettings;
    
    public UserService(IOptions<DatabaseSettings> dbSettings)
    {
        _dbSettings = dbSettings.Value;
    }
}

Dependency Injection

๐Ÿ’‰ Service Registration Patterns

// Program.cs - Modern minimal API style
var builder = WebApplication.CreateBuilder(args);

// โœ… Good: Register by lifetime
// Transient: New instance every time
builder.Services.AddTransient<IEmailService, EmailService>();

// Scoped: One instance per request
builder.Services.AddScoped<IUserRepository, UserRepository>();

// Singleton: One instance for application lifetime
builder.Services.AddSingleton<ICacheService, RedisCacheService>();

// โœ… Good: Use factory pattern for complex dependencies
builder.Services.AddSingleton<IHttpClientFactory>(provider =>
{
    var factory = new HttpClientFactory();
    factory.ConfigureClient("api", client =>
    {
        client.BaseAddress = new Uri("https://api.example.com");
        client.DefaultRequestHeaders.Add("ApiKey", "secret");
    });
    return factory;
});

๐ŸŽฏ Service Locator Anti-Pattern

// โŒ Bad: Service locator pattern
public class OrderService
{
    public void ProcessOrder(Order order)
    {
        var provider = HttpContext.RequestServices;
        var emailService = provider.GetService<IEmailService>();
        var paymentService = provider.GetService<IPaymentService>();
        // ... processing logic
    }
}

// โœ… Good: Constructor injection
public class OrderService
{
    private readonly IEmailService _emailService;
    private readonly IPaymentService _paymentService;
    
    public OrderService(IEmailService emailService, IPaymentService paymentService)
    {
        _emailService = emailService;
        _paymentService = paymentService;
    }
    
    public void ProcessOrder(Order order)
    {
        // ... processing logic
    }
}

Error Handling & Logging

๐Ÿšจ Global Exception Handling

// Custom exception middleware
public class GlobalExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<GlobalExceptionMiddleware> _logger;

    public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "An unhandled exception occurred");
            await HandleExceptionAsync(context, ex);
        }
    }

    private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        context.Response.ContentType = "application/json";
        
        var response = exception switch
        {
            NotFoundException => new { error = "Resource not found", status = 404 },
            ValidationException => new { error = exception.Message, status = 400 },
            UnauthorizedAccessException => new { error = "Unauthorized", status = 401 },
            _ => new { error = "An error occurred", status = 500 }
        };
        
        context.Response.StatusCode = response.status;
        await context.Response.WriteAsync(JsonSerializer.Serialize(response));
    }
}

// Register in Program.cs
app.UseMiddleware<GlobalExceptionMiddleware>();

๐Ÿ“ Structured Logging

// โœ… Good: Structured logging with Serilog
public static void Main(string[] args)
{
    Log.Logger = new LoggerConfiguration()
        .WriteTo.Console()
        .WriteTo.File("logs/log-.txt", rollingInterval: RollingInterval.Day)
        .WriteTo.Seq("http://localhost:5341") // Optional: Seq for log analysis
        .CreateLogger();

    try
    {
        Log.Information("Starting web application");
        CreateHostBuilder(args).Build().Run();
    }
    catch (Exception ex)
    {
        Log.Fatal(ex, "Application terminated unexpectedly");
    }
    finally
    {
        Log.CloseAndFlush();
    }
}

// Usage in controllers
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly ILogger<UsersController> _logger;
    
    [HttpGet("{id}")]
    public async Task<ActionResult<User>> GetUser(int id)
    {
        _logger.LogInformation("Getting user with ID: {UserId}", id);
        
        try
        {
            var user = await _userService.GetUserAsync(id);
            _logger.LogInformation("Successfully retrieved user {UserId}", id);
            return Ok(user);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving user {UserId}", id);
            throw;
        }
    }
}

Security Best Practices

๐Ÿ” Authentication & Authorization

// JWT Configuration
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
        };
    });

// Policy-based authorization
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("RequireAdminRole", policy =>
        policy.RequireRole("Admin"));
    
    options.AddPolicy("MinimumAge", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(18)));
});

// Custom authorization requirement
public class MinimumAgeRequirement : IAuthorizationRequirement
{
    public int MinimumAge { get; }
    
    public MinimumAgeRequirement(int minimumAge)
    {
        MinimumAge = minimumAge;
    }
}

๐Ÿ›ก๏ธ Security Headers

// Security headers middleware
app.Use(async (context, next) =>
{
    context.Response.Headers.Add("X-Frame-Options", "DENY");
    context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
    context.Response.Headers.Add("X-XSS-Protection", "1; mode=block");
    context.Response.Headers.Add("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
    context.Response.Headers.Add("Content-Security-Policy", 
        "default-src 'self'; script-src 'self' 'unsafe-inline'");
    
    await next();
});

// Or use NWebsec package
builder.Services.AddHsts(options =>
{
    options.Preload = true;
    options.IncludeSubDomains = true;
    options.MaxAge = TimeSpan.FromDays(365);
});

๐Ÿ”’ Data Protection

// Input validation with FluentValidation
public class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
    public CreateUserValidator()
    {
        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .MaximumLength(256);
            
        RuleFor(x => x.Password)
            .NotEmpty()
            .MinimumLength(8)
            .Matches(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]")
            .WithMessage("Password must contain uppercase, lowercase, number, and special character");
    }
}

// SQL injection prevention with parameterized queries
public async Task<User> GetUserByEmailAsync(string email)
{
    // โœ… Good: Parameterized query
    var sql = "SELECT * FROM Users WHERE Email = @Email";
    return await _connection.QuerySingleOrDefaultAsync<User>(sql, new { Email = email });
    
    // โŒ Bad: String concatenation
    // var sql = $"SELECT * FROM Users WHERE Email = '{email}'";
}

Performance Optimization

โšก Async/Await Best Practices

// โœ… Good: Async all the way
public class UserController : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<IEnumerable<User>>> GetUsersAsync()
    {
        var users = await _userService.GetAllUsersAsync();
        return Ok(users);
    }
}

// โœ… Good: Configure await false in libraries
public async Task<User> GetUserAsync(int id)
{
    var user = await _repository.GetByIdAsync(id).ConfigureAwait(false);
    return user;
}

// โŒ Bad: Blocking async calls
public User GetUserSync(int id)
{
    return _userService.GetUserAsync(id).Result; // Can cause deadlocks
}

๐Ÿ—„๏ธ Database Optimization

// Entity Framework optimizations
public class ApplicationDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // Connection pooling
        optionsBuilder.EnableServiceProviderCaching();
        optionsBuilder.EnableSensitiveDataLogging(false);
        
        // Query optimization
        optionsBuilder.EnableSplitQueries();
    }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Indexes for performance
        modelBuilder.Entity<User>()
            .HasIndex(u => u.Email)
            .IsUnique();
            
        // Query filters for soft delete
        modelBuilder.Entity<User>()
            .HasQueryFilter(u => !u.IsDeleted);
    }
}

// โœ… Good: Projection instead of full entities
public async Task<IEnumerable<UserDto>> GetUserSummariesAsync()
{
    return await _context.Users
        .Where(u => u.IsActive)
        .Select(u => new UserDto
        {
            Id = u.Id,
            Name = u.Name,
            Email = u.Email
        })
        .ToListAsync();
}

// โœ… Good: Use AsNoTracking for read-only queries
public async Task<IEnumerable<User>> GetReadOnlyUsersAsync()
{
    return await _context.Users
        .AsNoTracking()
        .ToListAsync();
}

๐Ÿ’พ Caching Strategies

// Memory caching
builder.Services.AddMemoryCache();

public class UserService
{
    private readonly IMemoryCache _cache;
    private readonly IUserRepository _repository;
    
    public async Task<User> GetUserAsync(int id)
    {
        var cacheKey = $"user_{id}";
        
        if (_cache.TryGetValue(cacheKey, out User cachedUser))
        {
            return cachedUser;
        }
        
        var user = await _repository.GetByIdAsync(id);
        _cache.Set(cacheKey, user, TimeSpan.FromMinutes(30));
        
        return user;
    }
}

// Distributed caching with Redis
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
});

public class DistributedUserService
{
    private readonly IDistributedCache _cache;
    
    public async Task<User> GetUserAsync(int id)
    {
        var cacheKey = $"user_{id}";
        var cachedUserJson = await _cache.GetStringAsync(cacheKey);
        
        if (!string.IsNullOrEmpty(cachedUserJson))
        {
            return JsonSerializer.Deserialize<User>(cachedUserJson);
        }
        
        var user = await _repository.GetByIdAsync(id);
        var userJson = JsonSerializer.Serialize(user);
        await _cache.SetStringAsync(cacheKey, userJson, new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
        });
        
        return user;
    }
}

Testing Strategies

๐Ÿงช Unit Testing Setup

// Test project structure
Tests/
โ”œโ”€โ”€ UnitTests/
โ”‚   โ”œโ”€โ”€ Controllers/
โ”‚   โ”œโ”€โ”€ Services/
โ”‚   โ””โ”€โ”€ Repositories/
โ”œโ”€โ”€ IntegrationTests/
โ”‚   โ”œโ”€โ”€ Controllers/
โ”‚   โ””โ”€โ”€ Endpoints/
โ””โ”€โ”€ TestUtilities/
    โ”œโ”€โ”€ Fixtures/
    โ””โ”€โ”€ Builders/

// Example unit test with xUnit and Moq
public class UserServiceTests
{
    private readonly Mock<IUserRepository> _mockRepository;
    private readonly Mock<ILogger<UserService>> _mockLogger;
    private readonly UserService _userService;
    
    public UserServiceTests()
    {
        _mockRepository = new Mock<IUserRepository>();
        _mockLogger = new Mock<ILogger<UserService>>();
        _userService = new UserService(_mockRepository.Object, _mockLogger.Object);
    }
    
    [Fact]
    public async Task GetUserAsync_WithValidId_ReturnsUser()
    {
        // Arrange
        var userId = 1;
        var expectedUser = new User { Id = userId, Name = "John Doe" };
        _mockRepository.Setup(r => r.GetByIdAsync(userId))
                      .ReturnsAsync(expectedUser);
        
        // Act
        var result = await _userService.GetUserAsync(userId);
        
        // Assert
        Assert.NotNull(result);
        Assert.Equal(expectedUser.Id, result.Id);
        Assert.Equal(expectedUser.Name, result.Name);
    }
    
    [Fact]
    public async Task GetUserAsync_WithInvalidId_ThrowsException()
    {
        // Arrange
        var userId = -1;
        _mockRepository.Setup(r => r.GetByIdAsync(userId))
                      .ThrowsAsync(new ArgumentException("Invalid user ID"));
        
        // Act & Assert
        await Assert.ThrowsAsync<ArgumentException>(() => _userService.GetUserAsync(userId));
    }
}

๐Ÿ”— Integration Testing

// Custom web application factory
public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> 
    where TStartup : class
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Remove the app's ApplicationDbContext registration
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
            if (descriptor != null)
            {
                services.Remove(descriptor);
            }
            
            // Add ApplicationDbContext using an in-memory database for testing
            services.AddDbContext<ApplicationDbContext>(options =>
            {
                options.UseInMemoryDatabase("InMemoryDbForTesting");
            });
        });
    }
}

// Integration test example
public class UsersControllerIntegrationTests : IClassFixture<CustomWebApplicationFactory<Program>>
{
    private readonly CustomWebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;
    
    public UsersControllerIntegrationTests(CustomWebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _client = _factory.CreateClient();
    }
    
    [Fact]
    public async Task GetUsers_ReturnsSuccessAndCorrectContentType()
    {
        // Act
        var response = await _client.GetAsync("/api/users");
        
        // Assert
        response.EnsureSuccessStatusCode();
        Assert.Equal("application/json; charset=utf-8", 
                    response.Content.Headers.ContentType?.ToString());
    }
}

Development Workflow

๐Ÿ”„ Code Quality Tools

<!-- .editorconfig -->
root = true

[*.cs]
# Indentation preferences
indent_style = space
indent_size = 4

# Code style rules
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true

# Naming conventions
dotnet_naming_rule.private_members_with_underscore.symbols = private_fields
dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.prefix_underscore.capitalization = camel_case
dotnet_naming_style.prefix_underscore.required_prefix = _

๐Ÿ“Š Health Checks

// Health checks setup
builder.Services.AddHealthChecks()
    .AddDbContext<ApplicationDbContext>()
    .AddRedis(builder.Configuration.GetConnectionString("Redis"))
    .AddCheck<CustomHealthCheck>("custom_check");

app.MapHealthChecks("/health");
app.MapHealthChecks("/health/detail", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

// Custom health check
public class CustomHealthCheck : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        // Custom health check logic
        var isHealthy = true; // Your check here
        
        return Task.FromResult(isHealthy 
            ? HealthCheckResult.Healthy("Service is healthy")
            : HealthCheckResult.Unhealthy("Service is unhealthy"));
    }
}


Quick Reference Commands

Development Commands

# Create new project with template
dotnet new webapi -n MyApi --framework net8.0

# Add packages
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Serilog.AspNetCore

# Run with specific environment
dotnet run --environment Development

# Watch for changes
dotnet watch run

Testing Commands

# Run all tests
dotnet test

# Run tests with coverage
dotnet test --collect:"XPlat Code Coverage"

# Run specific test
dotnet test --filter "FullyQualifiedName~UserServiceTests"

Tags

#aspnetcore #dotnet #bestpractices #architecture #security #performance #testing


Keep this guide updated as new patterns and practices emerge in the .NET ecosystem.