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.
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();
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.
{
"EmailSettings": {
"SmtpServer": "smtp.example.com",
"Port": 587,
"FromAddress": "noreply@example.com",
"EnableSsl": true
},
"ApiSettings": {
"BaseUrl": "https://api.example.com",
"Timeout": 30,
"RetryCount": 3
}
}
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.
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);
});
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.
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.
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.
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.