Chuyển tới nội dung chính

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.

ElementConventionExample
Class, struct, recordPascalCaseOrderService, CustomerRecord
InterfacePascalCase with I prefixILogger, IRepository<T>
MethodPascalCaseCalculateTotal(), GetById()
PropertyPascalCaseFirstName, OrderDate
Constant fieldPascalCaseMaxRetryCount, DefaultTimeout
Private fieldcamelCase with _ prefix_logger, _connectionString
Method parametercamelCaseorderId, customerName
Local variablecamelCasetotalCount, 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;
}
}
Use meaningful names

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;
}
Do not use Task.Run to make sync APIs async

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

async void methods cannot be awaited and exceptions will crash the process. Always use async Task except for event handlers.

Use properties instead of public fields

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; }
}
Assuming HttpRequest.ContentLength is never null

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.

Validate all user input

Never trust user input. Always validate and sanitize to prevent SQL injection, XSS, and other vulnerabilities.

Key Takeaways

  1. Follow consistent naming conventions — PascalCase for types/members, camelCase for locals/parameters, _ prefix for private fields.
  2. Keep methods small, focused on a single responsibility.
  3. Use async/await end-to-end — never block on async code with .Result or .Wait().
  4. Catch specific exceptions; avoid catch (Exception) except at top-level handlers.
  5. Use HttpClientFactory instead of creating HttpClient instances directly.
  6. Minimize large object allocations and exceptions in hot code paths.
  7. Program to interfaces and use dependency injection for loose coupling.
  8. Always validate user input and perform null checks.
  9. Use Any() over Count > 0 and string interpolation over concatenation.
  10. Never access HttpContext from 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.

References