C# Best Practices
Definition
C# best practices are proven guidelines for writing code that is readable, maintainable, performant, and reliable. They cover naming conventions, code structure, exception handling, string manipulation, performance optimization, and ASP.NET Core-specific patterns.
Naming Conventions
Consistent naming makes code self-documenting and easier for teams to navigate.
| Element | Convention | Example |
|---|---|---|
| Class, struct, record | PascalCase | OrderService, CustomerRecord |
| Interface | PascalCase with I prefix | ILogger, IRepository<T> |
| Method | PascalCase | CalculateTotal(), GetById() |
| Property | PascalCase | FirstName, OrderDate |
| Constant field | PascalCase | MaxRetryCount, DefaultTimeout |
| Private field | camelCase with _ prefix | _logger, _connectionString |
| Method parameter | camelCase | orderId, customerName |
| Local variable | camelCase | totalCount, isValid |
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private const int MaxRetryCount = 3;
public OrderService(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public decimal CalculateTotal(Order order)
{
var subtotal = order.Items.Sum(i => i.Price * i.Quantity);
return subtotal;
}
}
A well-named identifier eliminates the need for comments. Avoid cryptic abbreviations — CalculateArea is always better than Foo.
Code Structure
Declare Fields at the Top
Group all fields and constants at the top of a class. This makes the class's overall state visible at a glance:
public class Car
{
// Fields and constants at the top
private int _speed;
private readonly IEngine _engine;
private const int MaxSpeed = 250;
// Properties
public string Model { get; set; }
// Methods
public void Accelerate(int delta) { /* ... */ }
}
Single Responsibility for Methods
Each method should do one thing well. Large methods that combine multiple responsibilities are hard to test, debug, and reuse:
// Bad — mixing concerns
public void ProcessOrder(Order order)
{
ValidateOrder(order);
CalculateTotal(order);
UpdateInventory(order);
SendConfirmation(order);
}
// Good — each method has one responsibility
public void ProcessOrder(Order order)
{
ValidateOrder(order);
var total = CalculateTotal(order);
UpdateInventory(order);
SendConfirmation(order);
}
Use Curly Braces Consistently
Even for single-line if statements, always use curly braces. It prevents bugs when adding lines later:
// Good
if (condition)
{
var userId = GetUserId();
}
// Risky — easy to break when adding more lines
if (condition)
var userId = GetUserId();
Use Object Initializers
Object initializers reduce repetition and improve readability:
// Verbose
var person = new Person();
person.FirstName = "John";
person.LastName = "Doe";
person.Age = 30;
// Concise
var person = new Person
{
FirstName = "John",
LastName = "Doe",
Age = 30
};
Exception Handling
Catch Specific Exceptions
Avoid catching the base Exception type. Catch only exceptions you can handle meaningfully:
// Bad — masks all errors
try
{
var result = 10 / divisor;
}
catch (Exception ex)
{
Log.Error($"Something went wrong: {ex.Message}");
}
// Good — targeted handling
try
{
var result = 10 / divisor;
}
catch (DivideByZeroException ex)
{
_logger.LogWarning(ex, "Attempted division by zero");
return 0;
}
Avoid Magic Numbers and Strings
Replace hard-coded values with named constants or enums:
// Bad — what does "3" mean?
if (status == 3) { /* ... */ }
// Good — self-documenting
const int ActiveStatus = 3;
if (status == ActiveStatus) { /* ... */ }
// Better — use enums for discrete values
public enum OrderStatus
{
Pending = 0,
Processing = 1,
Shipped = 2,
Delivered = 3
}
if (order.Status == OrderStatus.Delivered) { /* ... */ }
Use Guard Clauses
Fail fast at the top of a method to keep the main logic clean:
public void ProcessOrder(Order? order)
{
ArgumentNullException.ThrowIfNull(order);
ArgumentException.ThrowIfNullOrEmpty(order.Id);
if (order.Items.Count == 0)
throw new ArgumentException("Order must contain items.");
// Main logic starts here — all preconditions validated
FulfillOrder(order);
}
Null Safety
Always Perform Null Checks
Use is not null pattern or null-conditional operator to prevent NullReferenceException:
// Pattern matching check
List<string>? users = GetUsers();
if (users is not null)
{
foreach (var user in users)
{
Console.WriteLine(user);
}
}
// Null-conditional operator for property chains
var city = person?.Address?.City;
Use string.IsNullOrWhiteSpace for Input Validation
if (string.IsNullOrWhiteSpace(userEmail))
{
Console.WriteLine("Email address is missing.");
}
String Best Practices
Prefer String Interpolation
String interpolation is more readable than concatenation:
// Avoid
var message = "Hello, " + firstName + " " + lastName + "!";
// Prefer
var message = $"Hello, {firstName} {lastName}!";
// With formatting
var formattedPrice = $"Price: {price:C2}";
Use string.Empty Instead of ""
It is more explicit and avoids ambiguity:
// Avoid
if (name == "") { /* ... */ }
// Prefer
if (name == string.Empty) { /* ... */ }
Case-Insensitive String Comparison
Always normalize case before comparing with user input:
if (string.Equals(input, "yes", StringComparison.OrdinalIgnoreCase))
{
// ...
}
Collections
Use Any() Instead of Count > 0
Any() short-circuits and avoids enumerating the entire collection:
// Bad — enumerates the entire collection
if (tasks.Count > 0) { /* ... */ }
// Good — stops at the first element
if (tasks.Any()) { /* ... */ }
// With predicate
if (tasks.Any(t => t.IsCompleted)) { /* ... */ }
Return Large Collections Across Pages
Never load massive datasets at once. Use pagination to avoid OutOfMemoryException and slow response times:
[HttpGet]
public async Task<ActionResult<PagedResult<Order>>> GetOrders(
int page = 1, int pageSize = 20)
{
var orders = await _dbContext.Orders
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return Ok(new PagedResult<Order>(orders, totalCount));
}
Performance
Use && and || (Short-Circuit Operators)
These stop evaluating as soon as the result is determined:
// Good — second condition is skipped if first is false
if (input.Length > 0 && input.StartsWith("prefix")) { /* ... */ }
if (role == "admin" || role == "supervisor") { /* ... */ }
Use var Judiciously
Use var when the type is obvious from the right side of the assignment. Avoid it when the type is unclear:
// Good — type is obvious
var name = "John Doe";
var orders = new List<Order>();
// Avoid — what does GetSomething return?
var x = GetSomething();
// Better — explicit type when unclear
Discount discount = CalculateDiscount(order);
Use using for Disposable Resources
Ensure proper cleanup of resources like file streams and database connections:
// Modern using declaration (C# 8+)
using var stream = new FileStream("data.txt", FileMode.Open);
// Automatically disposed when leaving scope
// Traditional using block
using (var connection = new SqlConnection(_connectionString))
{
await connection.OpenAsync();
// Automatically disposed when block exits
}
Choose Value Types vs Reference Types Intentionally
- Value types (
struct,int,double) — stored on the stack, efficient for small data - Reference types (
class,string) — stored on the heap, suitable for complex data structures
Dependency Injection and Loose Coupling
Program to Interfaces
Depend on abstractions, not concretions, to enable testability and flexibility:
public interface ILogger
{
void Log(string message);
}
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly ILogger _logger;
public OrderService(IOrderRepository repository, ILogger logger)
{
_repository = repository;
_logger = logger;
}
}
Do Not Capture Scoped Services in Background Threads
Scoped services (like DbContext) are tied to the request. Create a new scope in background work:
[HttpGet("/fire-and-forget")]
public IActionResult FireAndForget(
[FromServices] IServiceScopeFactory scopeFactory)
{
_ = Task.Run(async () =>
{
await using var scope = scopeFactory.CreateAsyncScope();
var dbContext = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
dbContext.Logs.Add(new LogEntry("Background task executed"));
await dbContext.SaveChangesAsync();
});
return Accepted();
}
ASP.NET Core Specific
Avoid Blocking Calls
ASP.NET Core apps must handle many concurrent requests. Use async/await throughout the entire call stack:
// Bad — blocks the thread
public ActionResult<Order> GetOrder(int id)
{
var order = _dbContext.Orders.Find(id); // synchronous
return order;
}
// Good — releases the thread while waiting
public async Task<ActionResult<Order>> GetOrder(int id)
{
var order = await _dbContext.Orders.FindAsync(id);
return order;
}
Task.Run schedules work on the thread pool but does not make synchronous I/O asynchronous. It wastes a thread pool thread. Instead, use truly async APIs.
Do Not Block on Async Code
Never call .Result or .Wait() on async methods — this causes thread pool starvation:
// Bad — causes deadlock / thread pool starvation
var result = GetDataAsync().Result;
GetDataAsync().Wait();
// Good
var result = await GetDataAsync();
Pool HTTP Connections with HttpClientFactory
Do not create and dispose HttpClient instances directly — it exhausts sockets:
// Bad — socket exhaustion
using var client = new HttpClient();
// Good — register in DI
builder.Services.AddHttpClient<IUserService, UserService>();
// Or use IHttpClientFactory directly
public class UserService
{
private readonly HttpClient _client;
public UserService(IHttpClientFactory factory)
{
_client = factory.CreateClient("UserApi");
}
}
Avoid Synchronous I/O on Request/Response Body
Kestrel does not support synchronous reads. Always use async overloads:
// Bad — sync over async, blocks thread pool
var json = new StreamReader(Request.Body).ReadToEnd();
// Good — truly async
var json = await new StreamReader(Request.Body).ReadToEndAsync();
// Best — streaming deserialization
return await JsonSerializer.DeserializeAsync<Order>(Request.Body);
Use ReadFormAsync Over Request.Form
// Bad — synchronous, can cause thread pool starvation
var form = HttpContext.Request.Form;
// Good — asynchronous
var form = await HttpContext.Request.ReadFormAsync();
Do Not Access HttpContext from Multiple Threads
HttpContext is not thread-safe. Copy needed data before parallel execution:
// Bad — HttpContext accessed from multiple threads
private async Task<SearchResults> SearchAsync(string query)
{
_logger.LogInformation("Path: {Path}", HttpContext.Request.Path);
// ...
}
// Good — copy data before parallel work
var path = HttpContext.Request.Path;
var query1 = SearchAsync(SearchEngine.Google, query, path);
var query2 = SearchAsync(SearchEngine.Bing, query, path);
await Task.WhenAll(query1, query2);
Do Not Store IHttpContextAccessor.HttpContext in a Field
HttpContext is only valid during the active request:
// Bad — captures null or stale context
public class MyService
{
private readonly HttpContext _context;
public MyService(IHttpContextAccessor accessor)
{
_context = accessor.HttpContext; // Often null in constructor
}
}
// Good — access HttpContext at the time of use
public class MyService
{
private readonly IHttpContextAccessor _accessor;
public MyService(IHttpContextAccessor accessor) => _accessor = accessor;
public void CheckAdmin()
{
var context = _accessor.HttpContext;
if (context is not null && !context.User.IsInRole("admin"))
throw new UnauthorizedAccessException();
}
}
Minimize Large Object Allocations
Objects >= 85,000 bytes go on the Large Object Heap (LOH) and require a full Gen 2 GC to clean up:
- Cache large objects that are frequently used
- Use
ArrayPool<T>for large buffers - Avoid allocating many short-lived large objects on hot paths
Minimize Exceptions in Hot Paths
Throwing and catching exceptions is slow. Do not use exceptions for normal control flow:
// Bad — exception for expected flow
try { return int.Parse(input); }
catch (FormatException) { return 0; }
// Good — no exception for expected cases
if (int.TryParse(input, out var result))
return result;
return 0;
Use No-Tracking Queries for Read-Only Data
// When you don't need to update entities
var products = await _dbContext.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync();
Do Not Modify Response Headers After Body Has Started
// Bad — may throw after response has started
app.Use(async (context, next) =>
{
await next();
context.Response.Headers["x-custom"] = "value"; // May fail
});
// Good — register callback before headers are flushed
app.Use(async (context, next) =>
{
context.Response.OnStarting(() =>
{
context.Response.Headers["x-custom"] = "value";
return Task.CompletedTask;
});
await next();
});
Common Pitfalls
async void methods cannot be awaited and exceptions will crash the process. Always use async Task except for event handlers.
Public fields violate encapsulation. Use properties to control access and add validation:
// Bad
public class Student
{
public string Name;
}
// Good
public class Student
{
public string Name { get; set; }
}
ContentLength is null when no Content-Length header is received — it does not mean zero. A check like Request.ContentLength > 1024 returns false even when the body exceeds 1024 bytes.
Never trust user input. Always validate and sanitize to prevent SQL injection, XSS, and other vulnerabilities.
Key Takeaways
- Follow consistent naming conventions — PascalCase for types/members, camelCase for locals/parameters,
_prefix for private fields. - Keep methods small, focused on a single responsibility.
- Use
async/awaitend-to-end — never block on async code with.Resultor.Wait(). - Catch specific exceptions; avoid
catch (Exception)except at top-level handlers. - Use
HttpClientFactoryinstead of creatingHttpClientinstances directly. - Minimize large object allocations and exceptions in hot code paths.
- Program to interfaces and use dependency injection for loose coupling.
- Always validate user input and perform null checks.
- Use
Any()overCount > 0and string interpolation over concatenation. - Never access
HttpContextfrom multiple threads or store it in a field.
Interview Questions
Q: Why should you avoid async void in ASP.NET Core?
Exceptions thrown in async void methods cannot be caught by the caller and will crash the process via the synchronization context. Use async Task so the framework can handle exceptions properly.
Q: What is the difference between Task.Run and a truly async API?
Task.Run schedules synchronous work onto the thread pool, wasting a thread. A truly async API (e.g., ReadAsync) releases the thread entirely while waiting for I/O to complete, allowing the thread to serve other requests.
Q: Why should you use HttpClientFactory instead of new HttpClient()?
Even though HttpClient implements IDisposable, disposing it leaves sockets in TIME_WAIT state. Creating and disposing many instances exhausts available sockets. HttpClientFactory pools and reuses HttpClient instances.
Q: What is the Large Object Heap and why does it matter? Objects >= 85,000 bytes are allocated on the LOH, which requires a full (Gen 2) garbage collection to clean up. Frequent LOH allocations cause GC pauses that degrade performance in high-throughput web apps.
Q: Why use Any() instead of Count > 0?
Any() stops enumerating at the first element, while Count may enumerate the entire collection. For IEnumerable<T> sources that are not ICollection<T>, the difference can be significant.
Q: Why should you not capture scoped services (like DbContext) in background threads?
Scoped services are tied to the HTTP request lifetime. After the request ends, the scope is disposed and the captured service becomes invalid, causing ObjectDisposedException. Create a new scope with IServiceScopeFactory in background work.