name: csharp-coding-standards description: .NET C# coding standards, best practices, and patterns for Console/Library and ASP.NET Core Web API development targeting .NET 8 / C# 12.
C# Coding Standards & Best Practices
Universal coding standards for .NET 8 / C# 12 projects, covering Console/Library and ASP.NET Core Web API development.
Code Quality Principles
1. Readability First
- Code is read more than written
- Clear variable and function names
- Self-documenting code preferred over comments
- Consistent formatting
2. KISS (Keep It Simple, Stupid)
- Simplest solution that works
- Avoid over-engineering
- No premature optimization
- Easy to understand > clever code
3. DRY (Don't Repeat Yourself)
- Extract common logic into methods
- Create reusable services and utilities
- Share logic via extension methods or base classes
- Avoid copy-paste programming
4. YAGNI (You Aren't Gonna Need It)
- Don't build features before they're needed
- Avoid speculative generality
- Add complexity only when required
- Start simple, refactor when needed
C# Standards
Naming Conventions
// GOOD: Follow .NET naming conventions
public class MarketService // PascalCase for types
{
private readonly ILogger _logger; // _camelCase for private fields
public string MarketName { get; } // PascalCase for properties
public decimal TotalRevenue { get; } // PascalCase for properties
public async Task<Market> FetchMarketDataAsync(string marketId) // PascalCase for methods
{
var isUserAuthenticated = true; // camelCase for locals
const int MaxRetries = 3; // PascalCase for constants
// ...
}
}
// BAD: Inconsistent or unclear naming
public class mktSvc
{
private ILogger logger; // Missing underscore prefix
public string q; // Single letter, public field
public void market(string id) { } // Noun-only, not PascalCase
}
Immutability Pattern (CRITICAL)
// GOOD: Use records for immutable data
public record User(string Name, string Email, int Age);
// Non-destructive mutation with 'with' expression
var updatedUser = user with { Name = "New Name" };
// Immutable collections
var updatedList = items.Add(newItem); // ImmutableList<T>
// Read-only properties
public class Config
{
public required string ConnectionString { get; init; }
public required int MaxRetries { get; init; }
}
// BAD: Mutable state
public class User
{
public string Name { get; set; } // Mutable
}
user.Name = "New Name"; // Direct mutation
list.Add(newItem); // Mutating List<T> in place
Error Handling
// GOOD: Specific exception handling with context
public async Task<Market> GetMarketAsync(string marketId, CancellationToken ct)
{
try
{
var response = await _httpClient.GetAsync($"api/markets/{marketId}", ct);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException(
$"Failed to fetch market {marketId}: HTTP {(int)response.StatusCode}");
}
return await response.Content.ReadFromJsonAsync<Market>(ct)
?? throw new InvalidOperationException($"Market {marketId} returned null");
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error fetching market {MarketId}", marketId);
throw;
}
catch (TaskCanceledException) when (ct.IsCancellationRequested)
{
_logger.LogWarning("Request cancelled for market {MarketId}", marketId);
throw;
}
}
// BAD: Swallowing exceptions or catching everything
public Market GetMarket(string id)
{
try
{
return _repo.Find(id);
}
catch (Exception) // Catches everything, swallows details
{
return null; // Hides the error
}
}
Result Pattern (Alternative to Exceptions)
// GOOD: Explicit success/failure without exceptions for expected failures
public readonly record struct Result<T>
{
public T? Value { get; }
public string? Error { get; }
public bool IsSuccess { get; }
private Result(T value) => (Value, IsSuccess) = (value, true);
private Result(string error) => (Error, IsSuccess) = (error, false);
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(string error) => new(error);
}
// Usage
public Result<User> CreateUser(CreateUserRequest request)
{
if (string.IsNullOrWhiteSpace(request.Email))
return Result<User>.Failure("Email is required");
var user = new User(request.Name, request.Email);
return Result<User>.Success(user);
}
Async/Await Best Practices
// GOOD: Parallel execution when possible
var (users, markets, stats) = await (
FetchUsersAsync(ct),
FetchMarketsAsync(ct),
FetchStatsAsync(ct)
).WhenAll();
// Extension method for tuple awaiting
public static class TaskExtensions
{
public static async Task<(T1, T2, T3)> WhenAll<T1, T2, T3>(
this (Task<T1>, Task<T2>, Task<T3>) tasks)
{
await Task.WhenAll(tasks.Item1, tasks.Item2, tasks.Item3);
return (tasks.Item1.Result, tasks.Item2.Result, tasks.Item3.Result);
}
}
// GOOD: Always pass CancellationToken
public async Task<List<Market>> GetActiveMarketsAsync(CancellationToken ct = default)
{
return await _context.Markets
.Where(m => m.Status == MarketStatus.Active)
.ToListAsync(ct);
}
// BAD: Sequential when unnecessary
var users = await FetchUsersAsync(); // Waits
var markets = await FetchMarketsAsync(); // Then waits again
var stats = await FetchStatsAsync(); // Then waits again
// BAD: Missing CancellationToken
public async Task<List<Market>> GetActiveMarketsAsync()
{
return await _context.Markets.ToListAsync(); // No cancellation support
}
Nullable Reference Types
// GOOD: Explicit nullability
public Market? FindMarketById(string id)
{
return _markets.FirstOrDefault(m => m.Id == id);
}
// Null checks with pattern matching
if (FindMarketById(id) is { } market)
{
ProcessMarket(market); // market is not null here
}
// GOOD: Required members
public class CreateMarketRequest
{
public required string Name { get; init; }
public required string Description { get; init; }
public DateTimeOffset? EndDate { get; init; } // Explicitly optional
}
// BAD: Ignoring nullability warnings
string name = GetName(); // GetName() returns string? but no null check
name.ToUpper(); // Potential NullReferenceException
Primary Constructors (C# 12)
// GOOD: Primary constructors for DI
public class MarketService(
IMarketRepository repository,
ILogger<MarketService> logger)
{
public async Task<Market?> GetAsync(string id, CancellationToken ct)
{
logger.LogInformation("Fetching market {MarketId}", id);
return await repository.FindByIdAsync(id, ct);
}
}
// GOOD: Records with primary constructors
public record MarketSummary(string Id, string Name, decimal Volume);
// BAD: Unnecessary boilerplate when primary constructor suffices
public class MarketService
{
private readonly IMarketRepository _repository;
private readonly ILogger<MarketService> _logger;
public MarketService(IMarketRepository repository, ILogger<MarketService> logger)
{
_repository = repository;
_logger = logger;
}
}
Collection Expressions (C# 12)
// GOOD: Collection expressions
int[] numbers = [1, 2, 3, 4, 5];
List<string> names = ["Alice", "Bob", "Charlie"];
ReadOnlySpan<byte> bytes = [0x01, 0x02, 0x03];
// Spread operator
int[] combined = [..first, ..second, 42];
// BAD: Verbose initialization
var numbers = new int[] { 1, 2, 3, 4, 5 };
var names = new List<string> { "Alice", "Bob", "Charlie" };
ASP.NET Core Best Practices
Minimal API Structure
// GOOD: Organized Minimal API with endpoint grouping
var app = builder.Build();
app.MapGroup("/api/markets")
.MapMarketEndpoints()
.RequireAuthorization();
// In a separate file: MarketEndpoints.cs
public static class MarketEndpoints
{
public static RouteGroupBuilder MapMarketEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", GetAllMarketsAsync);
group.MapGet("/{id}", GetMarketByIdAsync);
group.MapPost("/", CreateMarketAsync);
group.MapPut("/{id}", UpdateMarketAsync);
group.MapDelete("/{id}", DeleteMarketAsync);
return group;
}
private static async Task<IResult> GetMarketByIdAsync(
string id,
IMarketService service,
CancellationToken ct)
{
var market = await service.GetAsync(id, ct);
return market is not null
? Results.Ok(ApiResponse<Market>.Ok(market))
: Results.NotFound(ApiResponse<Market>.Fail("Market not found"));
}
}
Dependency Injection
// GOOD: Register services with appropriate lifetimes
builder.Services.AddScoped<IMarketRepository, MarketRepository>();
builder.Services.AddScoped<IMarketService, MarketService>();
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddTransient<IEmailService, EmailService>();
// GOOD: Options pattern for configuration
builder.Services.Configure<MarketOptions>(
builder.Configuration.GetSection("Market"));
public class MarketOptions
{
public required string ApiBaseUrl { get; init; }
public int MaxRetries { get; init; } = 3;
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}
// Usage via IOptions<T>
public class MarketService(IOptions<MarketOptions> options)
{
private readonly MarketOptions _options = options.Value;
}
// BAD: Service locator anti-pattern
public class MarketService
{
public void DoWork()
{
var repo = ServiceLocator.Get<IMarketRepository>(); // Anti-pattern
}
}
Middleware Pipeline
// GOOD: Custom middleware with proper ordering
public class RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
try
{
await next(context);
}
finally
{
stopwatch.Stop();
logger.LogInformation(
"Request {Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}",
context.Request.Method,
context.Request.Path,
stopwatch.ElapsedMilliseconds,
context.Response.StatusCode);
}
}
}
// Registration order matters
app.UseExceptionHandler("/error");
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
Global Exception Handling
// GOOD: Problem Details for error responses (RFC 9457)
builder.Services.AddProblemDetails();
app.UseExceptionHandler(exceptionApp =>
{
exceptionApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(exception, "Unhandled exception");
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await Results.Problem(
title: "An error occurred",
statusCode: StatusCodes.Status500InternalServerError
).ExecuteAsync(context);
});
});
API Design Standards
REST API Conventions
GET /api/markets # List all markets
GET /api/markets/{id} # Get specific market
POST /api/markets # Create new market
PUT /api/markets/{id} # Update market (full)
PATCH /api/markets/{id} # Update market (partial)
DELETE /api/markets/{id} # Delete market
# Query parameters for filtering
GET /api/markets?status=active&limit=10&offset=0
Response Format
// GOOD: Consistent response envelope
public record ApiResponse<T>
{
public bool Success { get; init; }
public T? Data { get; init; }
public string? Error { get; init; }
public PaginationMeta? Meta { get; init; }
public static ApiResponse<T> Ok(T data, PaginationMeta? meta = null) =>
new() { Success = true, Data = data, Meta = meta };
public static ApiResponse<T> Fail(string error) =>
new() { Success = false, Error = error };
}
public record PaginationMeta(int Total, int Page, int Limit);
// Usage in endpoint
app.MapGet("/api/markets", async (
IMarketService service,
[AsParameters] MarketQuery query,
CancellationToken ct) =>
{
var (markets, total) = await service.ListAsync(query, ct);
return Results.Ok(ApiResponse<List<Market>>.Ok(
markets,
new PaginationMeta(total, query.Page, query.Limit)));
});
public record MarketQuery(
string? Status = null,
int Page = 1,
int Limit = 10);
Input Validation
// GOOD: FluentValidation
public class CreateMarketRequestValidator : AbstractValidator<CreateMarketRequest>
{
public CreateMarketRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(200);
RuleFor(x => x.Description)
.NotEmpty()
.MaximumLength(2000);
RuleFor(x => x.EndDate)
.GreaterThan(DateTimeOffset.UtcNow)
.When(x => x.EndDate.HasValue);
RuleFor(x => x.Categories)
.NotEmpty()
.Must(c => c.Count <= 10)
.WithMessage("Maximum 10 categories allowed");
}
}
// GOOD: Validation filter for Minimal API
app.MapPost("/api/markets", async (
CreateMarketRequest request,
IValidator<CreateMarketRequest> validator,
IMarketService service,
CancellationToken ct) =>
{
var validation = await validator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
return Results.ValidationProblem(validation.ToDictionary());
}
var market = await service.CreateAsync(request, ct);
return Results.Created($"/api/markets/{market.Id}", ApiResponse<Market>.Ok(market));
});
// GOOD: DataAnnotations alternative for simpler cases
public class CreateMarketRequest
{
[Required, StringLength(200, MinimumLength = 1)]
public required string Name { get; init; }
[Required, StringLength(2000, MinimumLength = 1)]
public required string Description { get; init; }
public DateTimeOffset? EndDate { get; init; }
[Required, MinLength(1)]
public required List<string> Categories { get; init; }
}
File Organization
Project Structure (Clean Architecture)
src/
+-- MyApp.Api/ # ASP.NET Core Web API
| +-- Endpoints/ # Minimal API endpoint groups
| +-- Middleware/ # Custom middleware
| +-- Filters/ # Endpoint filters
| +-- Program.cs # Entry point and DI configuration
+-- MyApp.Application/ # Business logic
| +-- Markets/ # Feature: Markets
| | +-- Commands/ # Create, Update, Delete
| | +-- Queries/ # List, GetById
| | +-- MarketService.cs
| | +-- IMarketService.cs
| +-- Common/ # Shared application logic
| +-- Interfaces/
| +-- Models/
+-- MyApp.Domain/ # Domain entities and interfaces
| +-- Entities/
| +-- ValueObjects/
| +-- Enums/
| +-- Exceptions/
+-- MyApp.Infrastructure/ # Data access, external services
| +-- Persistence/
| | +-- AppDbContext.cs
| | +-- Repositories/
| | +-- Migrations/
| +-- Services/
| +-- EmailService.cs
| +-- CacheService.cs
tests/
+-- MyApp.UnitTests/
+-- MyApp.IntegrationTests/
+-- MyApp.Api.Tests/
Vertical Slice Alternative
src/
+-- MyApp/
+-- Features/
| +-- Markets/
| | +-- CreateMarket.cs # Request, Handler, Validator, Endpoint
| | +-- GetMarket.cs
| | +-- ListMarkets.cs
| | +-- Market.cs # Entity
| | +-- MarketRepository.cs
| +-- Users/
| +-- CreateUser.cs
| +-- GetUser.cs
+-- Shared/
+-- Infrastructure/
+-- Middleware/
File Naming
MarketService.cs # PascalCase for all files
IMarketRepository.cs # 'I' prefix for interfaces
CreateMarketRequest.cs # Descriptive class name = file name
MarketServiceTests.cs # Tests mirror source structure
Comments & Documentation
When to Comment
// GOOD: Explain WHY, not WHAT
// Use exponential backoff to avoid overwhelming the API during outages
var delay = TimeSpan.FromMilliseconds(Math.Min(1000 * Math.Pow(2, retryCount), 30000));
// Deliberately using mutable list here for bulk insert performance
var items = new List<Market>(capacity: 10000);
// BAD: Stating the obvious
// Increment counter by 1
count++;
// Set name to user's name
name = user.Name;
XML Documentation for Public APIs
/// <summary>
/// Searches markets using semantic similarity.
/// </summary>
/// <param name="query">Natural language search query.</param>
/// <param name="limit">Maximum number of results (default: 10).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Markets sorted by similarity score.</returns>
/// <exception cref="HttpRequestException">If the search API is unavailable.</exception>
/// <example>
/// <code>
/// var results = await service.SearchMarketsAsync("election", limit: 5);
/// Console.WriteLine(results[0].Name); // "Trump vs Biden"
/// </code>
/// </example>
public async Task<IReadOnlyList<Market>> SearchMarketsAsync(
string query,
int limit = 10,
CancellationToken ct = default)
{
// Implementation
}
Performance Best Practices
Span and Memory
// GOOD: Use Span<T> for high-performance string/array processing
public static int CountOccurrences(ReadOnlySpan<char> text, char target)
{
var count = 0;
foreach (var c in text)
{
if (c == target) count++;
}
return count;
}
// GOOD: ArrayPool for temporary buffers
public static byte[] ProcessData(ReadOnlySpan<byte> input)
{
var buffer = ArrayPool<byte>.Shared.Rent(input.Length);
try
{
input.CopyTo(buffer);
// Process buffer...
return buffer[..input.Length].ToArray();
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
LINQ Best Practices
// GOOD: Efficient LINQ usage
var activeMarketNames = markets
.Where(m => m.Status == MarketStatus.Active)
.Select(m => m.Name)
.ToList();
// GOOD: Use Any() instead of Count() > 0
if (markets.Any(m => m.IsExpired))
{
// Handle expired markets
}
// BAD: Materializing unnecessarily
var allMarkets = markets.ToList(); // Materializes everything
var active = allMarkets.Where(m => m.IsActive); // Could have chained
// BAD: Multiple enumeration
var count = markets.Count(); // First enumeration
var first = markets.First(); // Second enumeration -- use .ToList() first
EF Core Query Optimization
// GOOD: Select only needed columns
var summaries = await context.Markets
.Where(m => m.Status == MarketStatus.Active)
.Select(m => new MarketSummary(m.Id, m.Name, m.Volume))
.Take(10)
.ToListAsync(ct);
// GOOD: Use AsNoTracking for read-only queries
var markets = await context.Markets
.AsNoTracking()
.Where(m => m.CreatedAt > cutoff)
.ToListAsync(ct);
// BAD: Loading entire entity graph
var markets = await context.Markets
.Include(m => m.Orders)
.Include(m => m.Users)
.ToListAsync(); // Loads everything, no pagination
Testing Standards
Test Structure (AAA Pattern)
public class MarketServiceTests
{
[Fact]
public async Task GetAsync_WhenMarketExists_ReturnsMarket()
{
// Arrange
var expectedMarket = new Market("test-id", "Test Market", 100m);
var repository = Substitute.For<IMarketRepository>();
repository.FindByIdAsync("test-id", Arg.Any<CancellationToken>())
.Returns(expectedMarket);
var sut = new MarketService(repository, Substitute.For<ILogger<MarketService>>());
// Act
var result = await sut.GetAsync("test-id", CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.Equal("Test Market", result.Name);
}
[Fact]
public async Task GetAsync_WhenMarketNotFound_ReturnsNull()
{
// Arrange
var repository = Substitute.For<IMarketRepository>();
repository.FindByIdAsync("missing", Arg.Any<CancellationToken>())
.Returns((Market?)null);
var sut = new MarketService(repository, Substitute.For<ILogger<MarketService>>());
// Act
var result = await sut.GetAsync("missing", CancellationToken.None);
// Assert
Assert.Null(result);
}
}
Test Naming Convention
// GOOD: MethodName_Scenario_ExpectedBehavior
[Fact]
public async Task CreateAsync_WithValidRequest_ReturnsCreatedMarket() { }
[Fact]
public async Task CreateAsync_WithDuplicateName_ThrowsConflictException() { }
[Fact]
public async Task SearchAsync_WhenApiUnavailable_FallsBackToDatabase() { }
// BAD: Vague test names
[Fact]
public void Works() { }
[Fact]
public void TestSearch() { }
Parameterized Tests
[Theory]
[InlineData("", false)]
[InlineData("a", true)]
[InlineData("a@b.com", true)]
[InlineData("invalid-email", false)]
public void IsValidEmail_ReturnsExpectedResult(string email, bool expected)
{
var result = EmailValidator.IsValid(email);
Assert.Equal(expected, result);
}
Integration Tests with WebApplicationFactory
public class MarketApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task GetMarkets_ReturnsOkWithMarkets()
{
// Act
var response = await _client.GetAsync("/api/markets");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content
.ReadFromJsonAsync<ApiResponse<List<Market>>>();
Assert.NotNull(content);
Assert.True(content.Success);
}
}
Code Smell Detection
Watch for these anti-patterns:
1. Long Methods
// BAD: Method > 50 lines
public async Task ProcessMarketData()
{
// 100 lines of code
}
// GOOD: Split into smaller methods
public async Task ProcessMarketData(CancellationToken ct)
{
var validated = await ValidateDataAsync(ct);
var transformed = TransformData(validated);
await SaveDataAsync(transformed, ct);
}
2. Deep Nesting
// BAD: 5+ levels of nesting
if (user != null)
{
if (user.IsAdmin)
{
if (market != null)
{
if (market.IsActive)
{
if (HasPermission(user, market))
{
// Do something
}
}
}
}
}
// GOOD: Guard clauses with early returns
if (user is null) return;
if (!user.IsAdmin) return;
if (market is null) return;
if (!market.IsActive) return;
if (!HasPermission(user, market)) return;
// Do something
3. Magic Numbers
// BAD: Unexplained numbers
if (retryCount > 3) { }
await Task.Delay(500);
// GOOD: Named constants
private const int MaxRetries = 3;
private static readonly TimeSpan DebounceDelay = TimeSpan.FromMilliseconds(500);
if (retryCount > MaxRetries) { }
await Task.Delay(DebounceDelay);
4. God Classes
// BAD: One class doing everything
public class MarketManager
{
public void CreateMarket() { }
public void SendEmail() { }
public void GenerateReport() { }
public void ProcessPayment() { }
public void UpdateCache() { }
}
// GOOD: Single Responsibility
public class MarketService { /* Market CRUD only */ }
public class EmailService { /* Email only */ }
public class ReportService { /* Reports only */ }
public class PaymentService { /* Payments only */ }
Remember: Code quality is not negotiable. Clear, maintainable code enables rapid development and confident refactoring.