Essential Coding Best Practices for ASP.NET Core Development

Building Better ASP.NET Core Applications

Writing clean, maintainable code in ASP.NET Core requires following proven patterns and practices. Whether you're building your first API or maintaining enterprise applications, these best practices will help you create code that's easier to test, debug, and extend.

Good coding practices aren't just about making your code look nice. They prevent bugs, improve performance, and make your applications more secure. When your team follows consistent patterns, everyone can understand and modify the codebase more easily.

You'll learn practical techniques you can apply immediately: proper dependency injection, effective error handling, smart configuration management, and security fundamentals that protect your applications.

Master Dependency Injection

Dependency injection is built into ASP.NET Core and should be your primary way of managing dependencies. Register your services in Program.cs with the appropriate lifetime, then inject them through constructors.

Program.cs - Service Registration
var builder = WebApplication.CreateBuilder(args);

// Transient - Created each time they're requested
builder.Services.AddTransient<IEmailService, EmailService>();

// Scoped - Created once per request
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();

// Singleton - Created once for the application lifetime
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();

// DbContext is automatically scoped
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();
ProductsController.cs - Constructor Injection
public class ProductsController : ControllerBase
{
    private readonly IProductRepository _repository;
    private readonly ICacheService _cache;
    private readonly ILogger<ProductsController> _logger;

    // Inject dependencies through constructor
    public ProductsController(
        IProductRepository repository,
        ICacheService cache,
        ILogger<ProductsController> logger)
    {
        _repository = repository;
        _cache = cache;
        _logger = logger;
    }

    [HttpGet]
    public async Task<IActionResult> GetProducts()
    {
        // Use cached data if available
        var cacheKey = "all-products";
        var products = await _cache.GetAsync<List<Product>>(cacheKey);

        if (products == null)
        {
            products = await _repository.GetAllAsync();
            await _cache.SetAsync(cacheKey, products, TimeSpan.FromMinutes(10));
        }

        return Ok(products);
    }
}

Use scoped lifetime for most services, especially those that interact with databases. Singleton services must be thread-safe since they're shared across all requests. Avoid injecting scoped services into singletons as this creates memory leaks.

Use the Options Pattern for Configuration

Never hardcode configuration values. Use the options pattern to bind configuration sections to strongly-typed classes. This makes your settings type-safe, testable, and easy to validate.

appsettings.json - Configuration
{
  "EmailSettings": {
    "SmtpServer": "smtp.example.com",
    "Port": 587,
    "FromAddress": "noreply@example.com",
    "EnableSsl": true
  },
  "ApiSettings": {
    "BaseUrl": "https://api.example.com",
    "Timeout": 30,
    "RetryCount": 3
  }
}
EmailSettings.cs - Configuration Class
public class EmailSettings
{
    public string SmtpServer { get; set; }
    public int Port { get; set; }
    public string FromAddress { get; set; }
    public bool EnableSsl { get; set; }
}

// In Program.cs
builder.Services.Configure<EmailSettings>(
    builder.Configuration.GetSection("EmailSettings"));

// In your service
public class EmailService : IEmailService
{
    private readonly EmailSettings _settings;

    public EmailService(IOptions<EmailSettings> options)
    {
        _settings = options.Value;
    }

    public async Task SendEmailAsync(string to, string subject, string body)
    {
        using var client = new SmtpClient(_settings.SmtpServer, _settings.Port)
        {
            EnableSsl = _settings.EnableSsl
        };
        
        var message = new MailMessage(_settings.FromAddress, to, subject, body);
        await client.SendMailAsync(message);
    }
}

Store sensitive data like connection strings and API keys in environment variables or Azure Key Vault, never in source control. Use different appsettings files for each environment: appsettings.Development.json and appsettings.Production.json.

Implement Proper Error Handling

Use exception handling middleware to catch unhandled exceptions globally. Return consistent error responses with appropriate HTTP status codes. Log all exceptions with enough context to diagnose issues in production.

Program.cs - Global Error Handling
var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/error");
    app.UseHsts();
}

// Error handling endpoint
app.MapGet("/error", (HttpContext context, ILogger<Program> logger) =>
{
    var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
    
    logger.LogError(exception, "Unhandled exception occurred");
    
    return Results.Problem(
        title: "An error occurred",
        statusCode: StatusCodes.Status500InternalServerError);
});
Controller Error Handling
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;
    private readonly ILogger<OrdersController> _logger;

    [HttpPost]
    public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
    {
        try
        {
            var order = await _orderService.CreateAsync(request);
            return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
        }
        catch (ValidationException ex)
        {
            _logger.LogWarning(ex, "Validation failed for order creation");
            return BadRequest(new { error = ex.Message });
        }
        catch (InvalidOperationException ex)
        {
            _logger.LogWarning(ex, "Invalid operation during order creation");
            return Conflict(new { error = ex.Message });
        }
        // Let other exceptions bubble up to global handler
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetOrder(int id)
    {
        var order = await _orderService.GetByIdAsync(id);
        
        if (order == null)
            return NotFound(new { error = $"Order {id} not found" });
        
        return Ok(order);
    }
}

Return specific status codes for different scenarios: 400 for validation errors, 404 for missing resources, 409 for conflicts, and 500 for server errors. Include helpful error messages in development but keep them generic in production.

Use Async All the Way

Make your controllers and services async to improve scalability. Never block on async code with .Result or .Wait(). This prevents thread pool starvation and allows your app to handle more concurrent requests.

ProductRepository.cs - Async Methods
public class ProductRepository : IProductRepository
{
    private readonly ApplicationDbContext _context;

    public ProductRepository(ApplicationDbContext context)
    {
        _context = context;
    }

    // Use async methods for database operations
    public async Task<List<Product>> GetAllAsync()
    {
        return await _context.Products
            .Include(p => p.Category)
            .ToListAsync();
    }

    public async Task<Product> GetByIdAsync(int id)
    {
        return await _context.Products
            .Include(p => p.Category)
            .FirstOrDefaultAsync(p => p.Id == id);
    }

    public async Task<Product> CreateAsync(Product product)
    {
        _context.Products.Add(product);
        await _context.SaveChangesAsync();
        return product;
    }

    public async Task UpdateAsync(Product product)
    {
        _context.Products.Update(product);
        await _context.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id)
    {
        var product = await GetByIdAsync(id);
        if (product != null)
        {
            _context.Products.Remove(product);
            await _context.SaveChangesAsync();
        }
    }
}

Follow Security Best Practices

Security should be built into your application from the start. Use HTTPS, validate all inputs, protect against common attacks, and keep dependencies updated.

Program.cs - Security Configuration
var builder = WebApplication.CreateBuilder(args);

// Add authentication
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"]))
        };
    });

builder.Services.AddAuthorization();

// Add CORS with specific origins
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowedOrigins", policy =>
    {
        policy.WithOrigins("https://example.com")
              .AllowAnyHeader()
              .AllowAnyMethod();
    });
});

var app = builder.Build();

// Enable HTTPS redirection
app.UseHttpsRedirection();

// Security headers
app.Use(async (context, next) =>
{
    context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
    context.Response.Headers.Add("X-Frame-Options", "DENY");
    context.Response.Headers.Add("X-XSS-Protection", "1; mode=block");
    await next();
});

app.UseCors("AllowedOrigins");
app.UseAuthentication();
app.UseAuthorization();

Always validate user input on the server side, even if you have client-side validation. Use parameterized queries to prevent SQL injection. Store passwords with proper hashing algorithms like BCrypt or PBKDF2.

Implement Structured Logging

Use structured logging with ILogger to make your logs searchable and analyzable. Include relevant context in your log messages. Set appropriate log levels for different scenarios.

OrderService.cs - Logging Examples
public class OrderService : IOrderService
{
    private readonly IOrderRepository _repository;
    private readonly ILogger<OrderService> _logger;

    public OrderService(IOrderRepository repository, ILogger<OrderService> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    public async Task<Order> CreateAsync(CreateOrderRequest request)
    {
        _logger.LogInformation(
            "Creating order for customer {CustomerId} with {ItemCount} items",
            request.CustomerId,
            request.Items.Count);

        var order = new Order
        {
            CustomerId = request.CustomerId,
            Items = request.Items,
            TotalAmount = request.Items.Sum(i => i.Price * i.Quantity)
        };

        try
        {
            await _repository.CreateAsync(order);
            
            _logger.LogInformation(
                "Order {OrderId} created successfully for customer {CustomerId}",
                order.Id,
                request.CustomerId);
            
            return order;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "Failed to create order for customer {CustomerId}",
                request.CustomerId);
            throw;
        }
    }
}

Use LogInformation for normal operations, LogWarning for recoverable issues, and LogError for exceptions. Configure different log levels per environment in appsettings.json. Consider using a logging provider like Serilog for more advanced features.

Frequently Asked Questions (FAQ)

What's the most important best practice for ASP.NET Core development?

Proper dependency injection is fundamental to ASP.NET Core. Register services with appropriate lifetimes, use constructor injection for required dependencies, and avoid service locator patterns. This creates testable, maintainable code that follows SOLID principles and leverages the framework's built-in container effectively.

How should I handle configuration in ASP.NET Core?

Use the options pattern with strongly-typed configuration classes. Store settings in appsettings.json for defaults and environment variables for sensitive data. Never hardcode configuration values. Bind configuration sections to POCOs and inject IOptions interfaces for access throughout your application.

What's the best way to implement error handling?

Use exception handling middleware for global error handling rather than try-catch blocks everywhere. Return consistent error responses with proper HTTP status codes. Log exceptions with structured logging using ILogger. In development, show detailed errors, but return generic messages in production to avoid exposing sensitive information.

Back to Articles