csharp-coding-standards

star 0

.NET C# coding standards, best practices, and patterns for Console/Library and ASP.NET Core Web API development targeting .NET 8 / C# 12.

AndyHsuTW By AndyHsuTW schedule Updated 2/25/2026

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.

Install via CLI
npx skills add https://github.com/AndyHsuTW/everything-llm-workspace --skill csharp-coding-standards
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator