csharp.md 25 KB


id: csharp name: C# Standards description: "Universal C# best practices for naming, async, LINQ, error handling, and testing" category: core type: standard version: 1.1.0

author: community

Universal C# Standards

Purpose: Universal C# best practices for AI agents working on .NET projects
Scope: Language-level patterns, not framework-specific
Last Updated: 2026-03-15


Table of Contents

  1. Naming Conventions
  2. Type Safety & Nullability
  3. Async/Await Patterns
  4. LINQ
  5. Error Handling
  6. Pattern Matching
  7. Code Organization
  8. Records & Immutability
  9. Dependency Injection
  10. Testing

1. Naming Conventions

1.1 General Rules

Element Convention Example
Classes, structs, records PascalCase UserService, OrderItem
Interfaces I + PascalCase IUserRepository, IOrderService
Methods PascalCase GetUserById, ProcessOrder
Properties PascalCase FirstName, CreatedAt
Fields (private) _ + camelCase _logger, _repository
Local variables camelCase userId, orderTotal
Parameters camelCase userId, cancellationToken
Constants PascalCase MaxRetryCount, DefaultTimeout
Enums PascalCase (type and members) OrderStatus.Pending
Async methods Async suffix GetUserAsync, SaveOrderAsync

1.2 Method Naming

// ✅ GOOD - Verb + noun, PascalCase
public async Task<User> GetUserByIdAsync(Guid userId) { }
public async Task<IReadOnlyList<Order>> ListOrdersAsync(Guid userId) { }
public async Task DeleteUserAsync(Guid userId) { }
public bool IsEligibleForDiscount(Order order) { }
public bool HasPermission(string action) { }

// ❌ AVOID - Ambiguous or wrong case
public async Task<User> fetchuser(Guid id) { }
public async Task<User> DoUserGet(Guid id) { }

1.3 Interface Naming

// ✅ GOOD - Always prefix with I
public interface IUserRepository { }
public interface IOrderService { }
public interface INotificationSender { }

// ❌ AVOID - No prefix, or wrong prefix
public interface UserRepository { }
public interface TUserRepository { }

1.4 Private Fields

// ✅ GOOD - Underscore prefix, camelCase
public class OrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly ILogger<OrderService> _logger;
    private int _retryCount;
}

// ❌ AVOID - No prefix, or m_ prefix
private IOrderRepository orderRepository;
private IOrderRepository m_orderRepository;

2. Type Safety & Nullability

2.1 Enable Nullable Reference Types

Rule: Always enable nullable reference types in all projects

<!-- .csproj -->
<PropertyGroup>
  <Nullable>enable</Nullable>
  <WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup>
// ✅ GOOD - Explicit nullability
public string Name { get; set; }          // Non-nullable: must be assigned
public string? MiddleName { get; set; }   // Nullable: may be null

public User? FindUser(Guid id) { }        // Returns null if not found
public User GetUser(Guid id) { }          // Never returns null (throws if not found)

2.2 Null Handling

// ✅ GOOD - Null-coalescing and conditional operators
var name = user?.Name ?? "Unknown";
var city = user?.Address?.City ?? string.Empty;
user?.Notify("Welcome");

// ✅ GOOD - Null guard at method entry
public void ProcessOrder(Order order)
{
    ArgumentNullException.ThrowIfNull(order);
    // ...
}

// ✅ GOOD - Null-coalescing assignment
_cache ??= new Dictionary<string, User>();

// ❌ AVOID - Manual null checks where operators suffice
if (user != null && user.Address != null)
    city = user.Address.City;

2.3 Avoid Primitive Obsession

// ✅ GOOD - Strongly typed IDs prevent mixing up parameters
public readonly record struct UserId(Guid Value);
public readonly record struct OrderId(Guid Value);

public Task<Order> GetOrderAsync(OrderId orderId, UserId userId) { }

// ❌ AVOID - Raw Guids are easy to mix up
public Task<Order> GetOrderAsync(Guid orderId, Guid userId) { }

3. Async/Await Patterns

3.1 Always Use CancellationToken

Rule: Every public async method must accept a CancellationToken

// ✅ GOOD - CancellationToken flows through all async calls
public async Task<User> GetUserAsync(Guid userId, CancellationToken cancellationToken = default)
{
    var user = await _repository.FindAsync(userId, cancellationToken);
    return user ?? throw new NotFoundException($"User {userId} not found");
}

// ❌ AVOID - No way to cancel long-running operations
public async Task<User> GetUserAsync(Guid userId)
{
    return await _repository.FindAsync(userId);
}

10.6 When to Use Moq Instead of NSubstitute

While NSubstitute is the default, use Moq for:

// Use Moq when you need MockBehavior.Strict (fail on unexpected calls)
var mockRepository = new Mock<IOrderRepository>(MockBehavior.Strict);
mockRepository.Setup(r => r.GetOrderAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
    .ReturnsAsync(new Order());

// Use Moq for verifying complex call sequences
var sequence = new MockSequence();
_serviceA.InSequence(sequence).Setup(x => x.MethodA()).ReturnsAsync(true);
_serviceB.InSequence(sequence).Setup(x => x.MethodB()).ReturnsAsync(false);

// Use Moq for verifying no other calls were made
mock.VerifyNoOtherCalls();

// Use Moq for mocking protected members
mock.Protected().Setup<string>("GetName").Returns("test");

Note: NSubstitute covers ~95% of real-world test scenarios. Only reach for Moq's advanced features if NSubstitute doesn't provide what you need.


4. LINQ

4.1 Prefer Method Syntax for Simple Chains

// ✅ GOOD - Method syntax for filter/project/sort
var activeUserNames = users
    .Where(u => u.IsActive)
    .OrderBy(u => u.LastName)
    .Select(u => u.FullName)
    .ToList();

// ✅ GOOD - Query syntax for complex joins (more readable)
var result =
    from order in orders
    join user in users on order.UserId equals user.Id
    where order.Status == OrderStatus.Pending
    select new { order.Id, user.Name };

4.2 Materialize at the Right Time

// ✅ GOOD - Materialize once, use the list
var activeUsers = users.Where(u => u.IsActive).ToList();
var count = activeUsers.Count;
var first = activeUsers.FirstOrDefault();

// ❌ AVOID - Multiple enumerations of IEnumerable (re-evaluates query each time)
var activeUsers = users.Where(u => u.IsActive);
var count = activeUsers.Count();   // evaluates query
var first = activeUsers.First();   // evaluates query again

4.3 Use Appropriate Termination Methods

// ✅ GOOD - Choose the right method for the intent
var first = items.FirstOrDefault();           // null if empty
var single = items.SingleOrDefault();         // null if empty, throws if >1
var any = items.Any(x => x.IsActive);         // bool, stops at first match
var all = items.All(x => x.IsActive);         // bool, fails fast on false
var count = items.Count(x => x.IsActive);     // full enumeration

// ❌ AVOID - Using Count() to check existence (full enumeration)
if (items.Count() > 0) { }   // Use Any() instead

4.4 Avoid LINQ in Performance-Critical Paths

// ✅ GOOD - Direct loop when allocation matters
var total = 0m;
foreach (var item in orderItems)
    total += item.Price * item.Quantity;

// LINQ alternative (fine for most code, avoids premature optimization)
var total = orderItems.Sum(item => item.Price * item.Quantity);

5. Error Handling

5.1 Use Specific Exception Types

// ✅ GOOD - Specific, meaningful exceptions
public async Task<User> GetUserAsync(Guid userId, CancellationToken ct = default)
{
    var user = await _repository.FindAsync(userId, ct);
    if (user is null)
        throw new NotFoundException($"User '{userId}' was not found.");
    return user;
}

// ✅ GOOD - Domain exception hierarchy
public class DomainException : Exception
{
    public DomainException(string message) : base(message) { }
    public DomainException(string message, Exception inner) : base(message, inner) { }
}

public class NotFoundException : DomainException
{
    public NotFoundException(string message) : base(message) { }
}

public class ValidationException : DomainException
{
    public IReadOnlyList<string> Errors { get; }

    public ValidationException(IReadOnlyList<string> errors)
        : base("One or more validation errors occurred.")
        => Errors = errors;
}

// ❌ AVOID - Generic exceptions with no context
throw new Exception("Not found");
throw new ApplicationException("Something went wrong");

5.2 Validate at Entry Points

// ✅ GOOD - Fail fast with guard clauses
public void PlaceOrder(Order order, Guid userId)
{
    ArgumentNullException.ThrowIfNull(order);
    ArgumentNullException.ThrowIfNull(userId);

    if (order.Items.Count == 0)
        throw new ValidationException(["Order must contain at least one item."]);

    if (order.TotalAmount <= 0)
        throw new ValidationException(["Order total must be greater than zero."]);

    // proceed with valid input
}

5.3 Result Pattern (for Expected Failures)

// ✅ GOOD - Use Result<T> when failure is a normal outcome (not exceptional)
public readonly record struct Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

    private Result(bool isSuccess, T? value, string? error)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
    }

    public static Result<T> Success(T value) => new(true, value, null);
    public static Result<T> Failure(string error) => new(false, default, error);
}

// Usage
public Result<Order> SubmitOrder(Cart cart)
{
    if (!cart.HasItems)
        return Result<Order>.Failure("Cart is empty.");

    var order = CreateOrderFromCart(cart);
    return Result<Order>.Success(order);
}

var result = SubmitOrder(cart);
if (result.IsSuccess)
    Console.WriteLine($"Order created: {result.Value!.Id}");
else
    Console.WriteLine($"Failed: {result.Error}");

// Reserve exceptions for truly exceptional, unexpected conditions.
// Use Result<T> for expected business failures (validation, not found in context of search, etc.)

5.4 Catch Specific Exceptions

// ✅ GOOD - Catch what you can handle
try
{
    await _paymentGateway.ChargeAsync(amount, ct);
}
catch (PaymentDeclinedException ex)
{
    _logger.LogWarning(ex, "Payment declined for amount {Amount}", amount);
    return Result<Receipt>.Failure("Payment was declined.");
}
catch (TimeoutException ex)
{
    _logger.LogError(ex, "Payment gateway timed out");
    throw; // re-throw: let caller or middleware handle retries
}

// ❌ AVOID - Swallowing exceptions silently
try { await _paymentGateway.ChargeAsync(amount, ct); }
catch { }

// ❌ AVOID - Catching Exception broadly without re-throw
catch (Exception ex)
{
    _logger.LogError(ex, "Error");
    return null; // hides the real problem
}

6. Pattern Matching

6.1 Switch Expressions (Prefer over switch statements)

// ✅ GOOD - Switch expression: concise, exhaustive
public decimal GetDiscount(CustomerTier tier) => tier switch
{
    CustomerTier.Bronze => 0.00m,
    CustomerTier.Silver => 0.05m,
    CustomerTier.Gold   => 0.10m,
    CustomerTier.Platinum => 0.20m,
    _ => throw new ArgumentOutOfRangeException(nameof(tier), tier, null)
};

// ❌ AVOID - Verbose switch statement for simple value mapping
switch (tier)
{
    case CustomerTier.Bronze: return 0.00m;
    case CustomerTier.Silver: return 0.05m;
    // ...
}

6.2 Type Patterns

// ✅ GOOD - Type pattern with declaration
public string Describe(Shape shape) => shape switch
{
    Circle c    => $"Circle with radius {c.Radius}",
    Rectangle r => $"Rectangle {r.Width}x{r.Height}",
    Triangle t  => $"Triangle with base {t.Base}",
    _           => "Unknown shape"
};

// ✅ GOOD - is pattern for type checking with binding
if (notification is EmailNotification email)
{
    await SendEmailAsync(email.Address, email.Body, ct);
}

6.3 Property Patterns

// ✅ GOOD - Property pattern for readable conditionals
public decimal CalculateShipping(Order order) => order switch
{
    { TotalAmount: >= 100 }            => 0m,           // free shipping
    { IsExpressDelivery: true }        => 15m,          // express
    { ShippingAddress.Country: "FI" }  => 5m,           // domestic
    _                                  => 10m           // international
};

6.4 Deconstruction

// ✅ GOOD - Deconstruct tuples and records
var (firstName, lastName) = GetFullName(userId);
var (lat, lon) = location;

// ✅ GOOD - Discard unused parts
var (id, _, createdAt) = GetOrderSummary(orderId);

7. Code Organization

7.1 Namespace Per Feature (not per type)

// ✅ GOOD - Feature-based namespaces
namespace MyApp.Orders;          // all order-related types together
namespace MyApp.Users;           // all user-related types together
namespace MyApp.Notifications;

// ❌ AVOID - Layer-based namespaces that scatter a feature across the codebase
namespace MyApp.Repositories;
namespace MyApp.Services;
namespace MyApp.Controllers;

7.2 File-Scoped Namespaces

// ✅ GOOD - File-scoped namespace (C# 10+): less indentation
namespace MyApp.Orders;

public class OrderService { }

// ❌ AVOID - Block-scoped namespace adds unnecessary indentation
namespace MyApp.Orders
{
    public class OrderService { }
}

7.3 One Type Per File

// ✅ GOOD - OrderService.cs contains only OrderService
// ✅ ACCEPTABLE - Small, closely related types in one file (e.g., value objects + their exceptions)

// ❌ AVOID - Multiple unrelated types in one file
// OrderService.cs containing OrderService + UserService + ProductRepository

7.4 Using Directives

// ✅ GOOD - Global usings for commonly used namespaces (in a GlobalUsings.cs file)
global using System;
global using System.Collections.Generic;
global using System.Threading;
global using System.Threading.Tasks;
global using Microsoft.Extensions.Logging;

// ✅ GOOD - File-level usings at the top, outside namespace
using System.Text.Json;
using MyApp.Common;

namespace MyApp.Orders;

7.5 Class Structure Order

Follow this order within a class:

public class OrderService : IOrderService
{
    // 1. Constants
    private const int MaxRetryCount = 3;

    // 2. Static fields
    private static readonly JsonSerializerOptions JsonOptions = new();

    // 3. Instance fields (private)
    private readonly IOrderRepository _repository;
    private readonly ILogger<OrderService> _logger;

    // 4. Constructor(s)
    public OrderService(IOrderRepository repository, ILogger<OrderService> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    // 5. Properties

    // 6. Public methods

    // 7. Private methods
}

8. Records & Immutability

8.1 Use Records for Value Objects and DTOs

// ✅ GOOD - Record for immutable data (value semantics, built-in equality)
public record UserDto(Guid Id, string Name, string Email);

public record Address(string Street, string City, string PostalCode, string Country);

// ✅ GOOD - Readonly record struct for small value objects (stack allocated)
public readonly record struct Money(decimal Amount, string Currency);
public readonly record struct Coordinates(double Latitude, double Longitude);

// Non-destructive mutation via 'with'
var updated = originalUser with { Email = "new@example.com" };

8.2 Immutable Collections

// ✅ GOOD - Expose immutable views
public class Order
{
    private readonly List<OrderItem> _items = new();

    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

    public void AddItem(OrderItem item)
    {
        ArgumentNullException.ThrowIfNull(item);
        _items.Add(item);
    }
}

// ✅ GOOD - ImmutableList for truly immutable scenarios
using System.Collections.Immutable;

public record ShoppingCart(ImmutableList<CartItem> Items)
{
    public ShoppingCart AddItem(CartItem item) =>
        this with { Items = Items.Add(item) };

    public ShoppingCart RemoveItem(Guid itemId) =>
        this with { Items = Items.RemoveAll(i => i.Id == itemId) };
}

8.3 init-only Properties

// ✅ GOOD - init allows construction but prevents later mutation
public class OrderConfiguration
{
    public Guid OrderId { get; init; }
    public string Currency { get; init; } = "EUR";
    public int MaxItems { get; init; } = 100;
}

// Can set during object initializer, but not after
var config = new OrderConfiguration { OrderId = Guid.NewGuid(), Currency = "USD" };
// config.Currency = "EUR"; // ❌ compile error

9. Dependency Injection

9.1 Constructor Injection (Preferred)

// ✅ GOOD - All dependencies injected through constructor, stored readonly
public class OrderService : IOrderService
{
    private readonly IOrderRepository _repository;
    private readonly IPaymentGateway _paymentGateway;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IOrderRepository repository,
        IPaymentGateway paymentGateway,
        ILogger<OrderService> logger)
    {
        _repository = repository;
        _paymentGateway = paymentGateway;
        _logger = logger;
    }
}

// ❌ AVOID - Service locator pattern (hidden dependencies)
public class OrderService
{
    public async Task ProcessAsync()
    {
        var repo = ServiceLocator.Get<IOrderRepository>(); // hidden dependency
    }
}

9.2 Lifetime Registration

// Registration in Program.cs or an extension method
services.AddScoped<IOrderService, OrderService>();      // per HTTP request
services.AddTransient<IEmailSender, SmtpEmailSender>(); // new instance each time
services.AddSingleton<ICacheService, MemoryCacheService>(); // single instance

// ✅ GOOD - Extension method groups related registrations
public static class OrdersServiceCollectionExtensions
{
    public static IServiceCollection AddOrders(this IServiceCollection services)
    {
        services.AddScoped<IOrderService, OrderService>();
        services.AddScoped<IOrderRepository, OrderRepository>();
        return services;
    }
}

// In Program.cs
builder.Services.AddOrders();

9.3 Options Pattern for Configuration

// ✅ GOOD - Strongly-typed configuration
public class PaymentOptions
{
    public const string SectionName = "Payment";

    public string ApiKey { get; set; } = string.Empty;
    public string BaseUrl { get; set; } = string.Empty;
    public int TimeoutSeconds { get; set; } = 30;
}

// Registration
builder.Services.Configure<PaymentOptions>(
    builder.Configuration.GetSection(PaymentOptions.SectionName));

// Usage
public class PaymentGateway
{
    private readonly PaymentOptions _options;

    public PaymentGateway(IOptions<PaymentOptions> options)
    {
        _options = options.Value;
    }
}

10. Testing

10.1 Framework & Structure

Use xUnit as the default test framework. Use Shouldly for readable assertions. Use NSubstitute for mocking (pragmatic, readable syntax). Use Moq only for scenarios requiring strict mock behavior or complex verification.

// ✅ GOOD - xUnit test class structure with NSubstitute
public class OrderServiceTests
{
    // Arrange shared fixtures in constructor or use class fixtures
    private readonly IOrderRepository _repositorySubstitute = Substitute.For<IOrderRepository>();
    private readonly ILogger<OrderService> _loggerSubstitute = Substitute.For<ILogger<OrderService>>();
    private readonly OrderService _sut;

    public OrderServiceTests()
    {
        _sut = new OrderService(_repositorySubstitute, _loggerSubstitute);
    }

    [Fact]
    public async Task GetOrderAsync_WhenOrderExists_ReturnsOrder()
    {
        // Arrange
        var orderId = Guid.NewGuid();
        var expected = new Order { Id = orderId };
        _repositorySubstitute
            .FindAsync(orderId, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<Order?>(expected));

        // Act
        var result = await _sut.GetOrderAsync(orderId);

        // Assert
        result.ShouldBeEquivalentTo(expected);
    }

    [Fact]
    public async Task GetOrderAsync_WhenOrderNotFound_ThrowsNotFoundException()
    {
        // Arrange
        var orderId = Guid.NewGuid();
        _repositorySubstitute
            .FindAsync(orderId, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<Order?>(null));

        // Act
        var act = () => _sut.GetOrderAsync(orderId);

        // Assert
        await act.ShouldThrowAsync<NotFoundException>();
    }

    [Fact]
    public async Task SaveOrderAsync_CallsRepository_VerifiesInteraction()
    {
        // Arrange
        var order = new Order { Id = Guid.NewGuid() };

        // Act
        await _sut.SaveOrderAsync(order);

        // Assert
        await _repositorySubstitute.Received(1).SaveAsync(order, Arg.Any<CancellationToken>());
    }
}

Why NSubstitute?

  • ✅ Fluent, readable syntax — substitutes ARE the interfaces, no .Object indirection
  • ✅ Lower ceremony, less noise in tests
  • Arg.Any<T>() is cleaner than It.IsAny<T>()
  • Received() reads naturally as "verify it received this call"
  • ✅ Faster to write and understand, even for complex scenarios

10.2 Parameterized Tests

// ✅ GOOD - Theory with InlineData for multiple cases
[Theory]
[InlineData(CustomerTier.Bronze, 0.00)]
[InlineData(CustomerTier.Silver, 0.05)]
[InlineData(CustomerTier.Gold, 0.10)]
[InlineData(CustomerTier.Platinum, 0.20)]
public void GetDiscount_ReturnsCorrectRate(CustomerTier tier, decimal expected)
{
    var result = _sut.GetDiscount(tier);
    result.ShouldBe(expected);
}

// ✅ GOOD - MemberData for complex input objects
public static IEnumerable<object[]> InvalidOrders =>
[
    [new Order { Items = [] }, "Order must contain at least one item."],
    [new Order { Items = [item], TotalAmount = -1 }, "Order total must be greater than zero."],
];

[Theory]
[MemberData(nameof(InvalidOrders))]
public void PlaceOrder_WithInvalidOrder_ThrowsValidationException(Order order, string expectedError)
{
    var act = () => _sut.PlaceOrder(order, Guid.NewGuid());
    act.ShouldThrow<ValidationException>()
        .Errors.ShouldContain(expectedError);
}

10.3 Test Naming

// ✅ GOOD - MethodName_StateUnderTest_ExpectedBehavior
public async Task GetOrderAsync_WhenOrderExists_ReturnsOrder() { }
public async Task GetOrderAsync_WhenOrderNotFound_ThrowsNotFoundException() { }
public void PlaceOrder_WithEmptyCart_ThrowsValidationException() { }
public void CalculateDiscount_ForPlatinumCustomer_Returns20Percent() { }

10.4 Avoid Logic in Tests

// ✅ GOOD - Direct, no conditionals or loops
[Fact]
public void FormatName_ReturnsFullName()
{
    var user = new User { FirstName = "John", LastName = "Doe" };
    var result = _sut.FormatName(user);
    result.ShouldBe("John Doe");
}

// ❌ AVOID - Logic in tests (makes failures hard to diagnose)
[Fact]
public void FormatNames_ReturnsFullNames()
{
    var users = GetTestUsers();
    foreach (var user in users)
    {
        if (user.FirstName != null)
            _sut.FormatName(user).ShouldContain(user.FirstName);
    }
}

10.5 Integration Tests

// ✅ GOOD - Use WebApplicationFactory for ASP.NET Core integration tests
public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public OrdersApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace real DB with in-memory for tests
                services.RemoveAll<DbContext>();
                services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("TestDb"));
            });
        }).CreateClient();
    }

    [Fact]
    public async Task GetOrder_ReturnsOk()
    {
        var response = await _client.GetAsync("/api/orders/123");
        response.StatusCode.ShouldBe(HttpStatusCode.OK);
    }
}

Quick Reference

Topic Key Rule
Naming PascalCase methods/properties, _camelCase fields, I-prefix interfaces, Async suffix
Nullability Enable <Nullable>enable</Nullable>, use ? explicitly, guard with ArgumentNullException.ThrowIfNull
Async Always pass CancellationToken, avoid async void, prefer Task.WhenAll for parallel ops
LINQ Materialize with ToList() once, use Any() not Count() > 0
Errors Specific exception types, validate at entry, Result<T> for expected failures
Pattern matching Switch expressions over switch statements, property patterns for readable conditionals
Organization File-scoped namespaces, feature-based folders, one type per file
Immutability Records for value objects/DTOs, IReadOnlyList for exposed collections
DI Constructor injection, readonly fields, Options pattern for config
Testing xUnit + Shouldly, [Fact]/[Theory], Arrange-Act-Assert, no logic in tests