.NET / C# Coding Standards Handbook
Industry-standard conventions aligned with Microsoft's official guidelines, Google's C# style guide, and production patterns for .NET 8 / 9.
Naming Conventions
Consistent naming is the single highest-leverage standard. Follow Microsoft's official .NET naming guidelines — they're the baseline every employer, reviewer, and linter expects.
| Identifier | Convention | Example | Notes |
|---|---|---|---|
| Namespace | PascalCase | MyApp.Services.Auth | Company.Product.Feature |
| Class / Record / Struct | PascalCase | UserAccount | Nouns preferred |
| Interface | I + PascalCase | IOrderRepository | Always prefix I |
| Method / Property | PascalCase | GetUserById, IsActive | Verbs for methods |
| Local variable / param | camelCase | userId, orderCount | No abbreviations |
| Private field | _camelCase | _messageWriter | Underscore prefix (Google/MS runtime style) |
| Private static field | s_camelCase | s_defaultTimeout | s_ prefix (MS runtime style) |
| Constant | PascalCase | MaxRetryCount | NOT SCREAMING_CAPS |
| Enum type | PascalCase (singular) | OrderStatus | Flags enum → plural |
| Enum member | PascalCase | OrderStatus.Pending | No numeric suffix |
| Event | PascalCase (verb phrase) | OrderPlaced, DataLoaded | Past tense preferred |
| Async method | PascalCase + Async suffix | GetUserAsync | Required when sync counterpart exists |
| Generic type param | T + PascalCase | TEntity, TResult | T alone if single param |
| Boolean variable | is/has/can + camelCase | isActive, hasPermission | Question-form naming |
strName, iCount, objUser. The IDE shows types in tooltips. Abbreviations obscure intent; prefer employeeAssignment over empAsgmt. Exceptions: well-known acronyms (Id, Xml, Http, Uri, Ftp).public class OrderService { private readonly IOrderRepository _orderRepo; private static readonly TimeSpan s_defaultTimeout = TimeSpan.FromSeconds(30); public const int MaxRetryCount = 3; public async Task<Order> GetOrderAsync( int orderId, CancellationToken ct = default) { var order = await _orderRepo .FindByIdAsync(orderId, ct); return order; } }
public class orderSvc { private IOrderRepository OrderRepo; // public-style name private static TimeSpan DEFAULT_TIMEOUT = TimeSpan.FromSeconds(30); // screaming caps public const int MAX_RETRY_CNT = 3; // abbr + CAPS public async Task<Order> getOrder( int intOrderId) // Hungarian + no CT { var obj = await OrderRepo .FindByIdAsync(intOrderId); return obj; // meaningless name } }
File & Code Layout
OrderService.cs.namespace MyApp.Services; (no braces). Reduces one indentation level across the entire file.sealed to classes not designed for inheritance. Improves JIT devirtualization and signals design intent.// 1. System usings first using System.Text.Json; // 2. Third-party using Microsoft.Extensions.Logging; using Serilog; // 3. Internal project using MyApp.Domain.Entities; using MyApp.Infrastructure.Persistence; // 4. File-scoped namespace (no braces) namespace MyApp.Services; // 5. Type with member ordering public sealed class UserService(IUserRepository repo, ILogger<UserService> logger) { // Fields private readonly IUserRepository _repo = repo; private readonly ILogger<UserService> _logger = logger; // Properties public bool IsInitialized { get; private set; } // Methods — public before private public async Task<User?> GetByIdAsync(int id, CancellationToken ct) { _logger.LogInformation("Fetching user {UserId}", id); return await _repo.FindAsync(id, ct); } private static bool IsValidId(int id) => id > 0; }
Type Design
Prefer immutability — use records
// Positional record — immutable value type, structural equality, deconstruct built-in public sealed record Money(decimal Amount, string Currency); // With required init-only properties for DTOs public sealed class CreateUserRequest { public required string Name { get; init; } public required string Email { get; init; } } // Primary constructor (C# 12) — inject dependencies concisely public sealed class OrderHandler(IOrderRepo repo, ILogger<OrderHandler> log) { public async Task HandleAsync(Order order) { /* ... */ } }
Enum design
// Singular enum name, PascalCase members public enum OrderStatus { Pending, Processing, Completed, Cancelled } // [Flags] enum → plural name + explicit powers-of-2 values [Flags] public enum Permissions { None = 0, Read = 1, Write = 2, Delete = 4 }
Avoid: anemic domain models, god classes, static everything
Guard, StringHelper) but not for services holding state or orchestrating logic. Every static dependency is a hidden coupling that breaks unit testing.Modern C# Features
Always use the highest C# version available in the project. Enable <LangVersion>latest</LangVersion> in the .csproj. Prefer modern constructs — they reduce noise and surface bugs earlier.
Pattern matching
// Switch expression + property patterns var discount = order switch { { Status: OrderStatus.Completed, Total: > 1000m } => 0.10m, { Customer.IsPremium: true } => 0.05m, _ => 0m }; // List patterns (C# 11) if (items is [var first, .., var last]) Process(first, last);
Collection expressions & spread (C# 12)
// Concise collection creation int[] evens = [2, 4, 6, 8]; List<string> combined = [..defaults, ..overrides]; ReadOnlySpan<byte> magic = [0x89, 0x50, 0x4E, 0x47];
Raw string literals, interpolated strings
// Raw string for SQL, JSON, regex — no escaping nightmares var sql = """ SELECT u.Id, u.Name FROM Users u WHERE u.IsActive = 1 AND u.CreatedAt > @cutoff """; // Prefer interpolated strings over string.Format _logger.LogInformation($"Order {orderId} placed at {DateTimeOffset.UtcNow:O}"); // ⚠ But use structured logging params in ILogger — not string interpolation _logger.LogInformation("Order {OrderId} placed", orderId); // ✓
var usage
Use var when the type is obvious from the right-hand side. Never use var when it obscures the type (e.g., numeric literals, return values of opaque methods).
var user = new User(); var orders = await _repo.GetAllAsync(); var message = string.Empty;
var x = GetSomething(); // what type? var n = 100; // int? long? double? var flag = Compute(data); // bool? int?
Nullable Reference Types
Enable #nullable enable project-wide (set in .csproj). This is design-time and compile-time safety — it does not add runtime null checks, but it eliminates an entire class of bugs by surfacing them at build time.
<PropertyGroup> <Nullable>enable</Nullable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <WarningsAsErrors>nullable</WarningsAsErrors> </PropertyGroup>
// Non-null — compiler enforces initialization public string Name { get; init; } // Nullable — explicit opt-in public string? MiddleName { get; init; } // Null guard at boundaries public User Create(string? name) { ArgumentNullException.ThrowIfNull(name); return new User(name); }
// Never use null-forgiving ! unless unavoidable var name = user.Name!; // code smell // Never disable nullability warnings #pragma warning disable CS8602 var len = user.Name.Length; // hidden NPE #pragma warning restore CS8602
<Nullable>annotations</Nullable> first (adds metadata, no warnings), then graduate to enable file-by-file. Use #nullable enable at the top of individual files to migrate incrementally.LINQ
Prefer LINQ method syntax over query syntax for most use cases. Avoid LINQ in hot paths — prefer foreach or Span<T> when allocations matter.
var activeAdmins = users .Where(u => u.IsActive && u.Role == Role.Admin) .OrderBy(u => u.LastName) .Select(u => u.Email) .ToList(); // IQueryable deferred execution — filter in DB var query = _db.Orders .Where(o => o.CustomerId == customerId) .AsNoTracking(); if (status.HasValue) query = query.Where(o => o.Status == status); var results = await query.ToListAsync(ct);
// Don't load all data into memory then filter var bad = await _db.Orders.ToListAsync(); var filtered = bad.Where(o => o.IsActive); // N+1 // Avoid deeply chained LINQ with side effects users.ToList().ForEach(u => Send(u)); // use foreach // Don't use Count() when Any() suffices if (list.Count() > 0) // ❌ if (list.Any()) // ✓
IQueryable until you call ToListAsync()/FirstOrDefaultAsync(). Materializing early with ToList() before filtering runs the query in memory — a common EF Core performance trap.Async / Await
All I/O-bound operations must be async. Never block async code synchronously — it causes deadlocks in ASP.NET Core.
| Rule | Severity | Reason |
|---|---|---|
Always return Task/Task<T>, never void (except event handlers) |
MUST | Void async swallows exceptions silently |
Suffix async methods with Async |
MUST | Required when sync counterpart exists; convention everywhere else |
Pass CancellationToken through the call chain |
MUST | Enables cooperative cancellation on request teardown |
Never use .Result, .Wait(), GetAwaiter().GetResult() |
MUST | Deadlocks in synchronization-context environments |
Use ConfigureAwait(false) in library/infrastructure code |
SHOULD | Avoids unnecessary sync-context capture in non-UI layers |
Prefer ValueTask<T> for high-frequency hot paths |
SHOULD | Avoids Task allocation when result is synchronous |
Never await in a constructor |
MUST | Use factory pattern or IHostedService.StartAsync |
Use await using for IAsyncDisposable |
MUST | Ensures async cleanup (DB connections, streams) |
public async Task<Order?> GetOrderAsync( int orderId, CancellationToken ct = default) { await using var conn = await _pool .OpenAsync(ct).ConfigureAwait(false); return await conn .QueryFirstOrDefaultAsync<Order>(sql, new { orderId }) .ConfigureAwait(false); } // Async factory pattern — when async init is needed public static async Task<DataLoader> CreateAsync( CancellationToken ct) { var data = await FetchInitialDataAsync(ct); return new DataLoader(data); }
// DEADLOCK — blocks thread waiting for async public Order? GetOrder(int id) { return GetOrderAsync(id).Result; // ❌ } // Swallows exceptions public async void ProcessOrder() { // ❌ await _service.DoWorkAsync(); } // Await in constructor public MyService(IRepo repo) { _data = LoadAsync().Result; // ❌ deadlock }
Parallel async
// Run independent tasks concurrently var (users, products) = await (GetUsersAsync(ct), GetProductsAsync(ct)); // Bounded parallel I/O — avoid task flooding await Parallel.ForEachAsync( orders, new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct }, async (order, innerCt) => await ProcessAsync(order, innerCt));
SOLID Principles
NotImplementedException in an override, LSP is violated.IReadableRepository<T> + IWritableRepository<T> instead of a single IRepository<T> with 15 methods.new a service inside another service.Dependency Injection
| Lifetime | Per | Use for | Risk |
|---|---|---|---|
| Singleton | Application | Config, caches, thread-safe stateless services, HttpClient factories | Capturing Scoped deps → scope leak |
| Scoped | HTTP request / DI scope | DbContext, Unit of Work, repositories — default for most services | Captured in Singleton → stale state |
| Transient | Every resolution | Lightweight, stateless utilities | Too many = GC pressure |
// Register services — prefer extension methods per layer builder.Services .AddScoped<IOrderService, OrderService>() .AddScoped<IOrderRepository, OrderRepository>() .AddSingleton<ICacheService, RedisCacheService>() .AddHttpClient<IPaymentGateway, StripeGateway>(c => c.BaseAddress = new Uri(config["Stripe:BaseUrl"]!)); // ✓ Constructor injection — primary pattern public sealed class OrderService( IOrderRepository repository, ILogger<OrderService> logger) { public async Task<Order> PlaceOrderAsync( PlaceOrderCommand cmd, CancellationToken ct) { // ... } } // ✓ Scoped operation in a Singleton via IServiceScopeFactory public sealed class BackgroundWorker(IServiceScopeFactory scopeFactory) { public async Task DoWorkAsync(CancellationToken ct) { await using var scope = scopeFactory.CreateAsyncScope(); var repo = scope.ServiceProvider .GetRequiredService<IOrderRepository>(); // ... } }
IServiceProvider just to call GetService<T>() in application code — that's the service locator anti-pattern. It hides dependencies and makes code untestable. Only acceptable at composition root boundaries (e.g., middleware, background workers needing scoped services).Error Handling
Exception silently. Use specific exception types. If you can't recover, let it propagate.catch { } blocks are forbidden. At minimum, log and rethrow. Use throw; not throw ex; to preserve stack trace.IExceptionHandler for ProblemDetails responses. Never let raw exceptions reach the client.ArgumentNullException.ThrowIfNull, ArgumentOutOfRangeException.ThrowIfNegative at public method entry points.// ✓ Specific catch, meaningful message try { return await _repo.GetAsync(id, ct); } catch (EntityNotFoundException ex) { _logger.LogWarning(ex, "Order {OrderId} not found", id); return null; } // ✓ Global exception handler (ASP.NET Core) public sealed class GlobalExceptionHandler( ILogger<GlobalExceptionHandler> logger) : IExceptionHandler { public async ValueTask<bool> TryHandleAsync( HttpContext ctx, Exception ex, CancellationToken ct) { var traceId = Activity.Current?.Id ?? ctx.TraceIdentifier; logger.LogError(ex, "Unhandled exception on {Machine} trace={TraceId}", Environment.MachineName, traceId); (int status, string title) = ex switch { ValidationException => (400, "Bad Request"), UnauthorizedException => (401, "Unauthorized"), NotFoundException => (404, "Not Found"), _ => (500, "Internal Server Error") }; await ctx.Response.WriteAsJsonAsync( new ProblemDetails { Status = status, Title = title, Extensions = { ["traceId"] = traceId } }, ct); return true; } }
Logging
// Structured logging — searchable in Seq/Loki/Splunk logger.LogInformation( "Order {OrderId} placed for customer {CustomerId}", orderId, customerId); // Source-generated log methods (fastest, zero alloc) public static partial class Log { [LoggerMessage( Level = LogLevel.Information, Message = "Order {OrderId} placed")] public static partial void OrderPlaced( this ILogger logger, int orderId); } // Conditional to avoid string allocation if (logger.IsEnabled(LogLevel.Debug)) logger.LogDebug("Expensive detail: {Data}", BuildPayload());
// Interpolated string — loses structured data logger.LogInformation( $"Order {orderId} placed"); // ❌ no structure // Logging exceptions incorrectly catch (Exception ex) { logger.LogError($"Error: {ex.Message}"); // ❌ // ✓ logger.LogError(ex, "Failed to process"); } // Console.WriteLine in production Console.WriteLine("Something happened"); // ❌
| Level | Use when |
|---|---|
Trace | Extremely verbose — loop internals, method entry/exit. Dev only. |
Debug | Diagnostic info useful during development and troubleshooting. |
Information | Normal application events — request received, order placed, user logged in. |
Warning | Unexpected but recoverable — retry attempt, degraded mode, config fallback. |
Error | Failures requiring attention — exception caught, operation failed. Pass the exception object. |
Critical | System-level failure — data corruption, process crash, unrecoverable state. |
Security Standards
Secret Manager (dev). Never in source code or appsettings.json committed to git.UseHttpsRedirection() + UseHsts() in Program.cs.// ✓ Parameterized query with Dapper var user = await conn.QueryFirstOrDefaultAsync<User>( "SELECT * FROM Users WHERE Email = @Email", new { Email = email }); // ✓ Secrets from configuration var connStr = builder.Configuration["Database:ConnectionString"] ?? throw new InvalidOperationException("DB connection string missing"); // ✓ Hashing passwords — never MD5/SHA1 var hash = BCrypt.Net.BCrypt.HashPassword(plainPassword, workFactor: 12); bool valid = BCrypt.Net.BCrypt.Verify(plainPassword, storedHash);
Performance
// Span<T> for stack-allocated slices ReadOnlySpan<char> slice = input.AsSpan(0, 10); // StringBuilder for string-heavy loops var sb = new StringBuilder(); foreach (var item in items) sb.Append(item).Append(','); // ArrayPool to avoid large heap allocations var buffer = ArrayPool<byte>.Shared.Rent(4096); try { /* use buffer */ } finally { ArrayPool<byte>.Shared.Return(buffer); } // Frozen collections for read-only lookup (net8) private static readonly FrozenSet<string> s_keywords = new[] { "buy", "sell", "hold" }.ToFrozenSet();
// String concat in loop — O(n²) allocs string result = ""; foreach (var item in items) result += item; // ❌ // LINQ on hot path — enumerator allocs while (true) { var sum = list.Sum(); // ❌ in tight loop } // Boxing value types unnecessarily ArrayList list = new(); // ❌ boxes int, bool List<int> typed = new(); // ✓
BenchmarkDotNet to profile hot paths. Use dotMemory or dotnet-trace to find GC pressure. Never optimize speculatively — always benchmark with realistic data volumes.Testing Standards
Tests are first-class citizens. Untestable code is a design smell. Target: ≥80% line coverage on business logic; 100% on critical paths.
Naming: Arrange / Act / Assert + Given/When/Then
public class OrderServiceTests { private readonly IOrderRepository _repo = Substitute.For<IOrderRepository>(); private readonly ILogger<OrderService> _logger = Substitute.For<ILogger<OrderService>>(); [Fact] public async Task PlaceOrderAsync_GivenValidCommand_ShouldReturnCreatedOrder() { // Arrange var cmd = new PlaceOrderCommand(CustomerId: 1, Sku: "ABC"); _repo.SaveAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>()) .Returns(new Order(Id: 42, Sku: "ABC")); var sut = new OrderService(_repo, _logger); // Act var result = await sut.PlaceOrderAsync(cmd, CancellationToken.None); // Assert result.Should().NotBeNull(); result!.Id.Should().Be(42); await _repo.Received(1).SaveAsync( Arg.Any<Order>(), Arg.Any<CancellationToken>()); } }
| Layer | Framework | Notes |
|---|---|---|
| Unit tests | xUnit + NSubstitute + FluentAssertions | Fast, isolated, mock all I/O |
| Integration tests | xUnit + WebApplicationFactory + Testcontainers | Real DB (Postgres/SQL in Docker), real HTTP |
| Architecture tests | NetArchTest or ArchUnitNET | Enforce layer dependencies as tests |
| Mutation testing | Stryker.NET | Find tests that pass without catching bugs |
| Load testing | k6 / NBomber | Validate throughput under realistic concurrency |
Documentation
/// <summary> /// Retrieves an order by its unique identifier. /// Returns <see langword="null"/> if no order exists with the given ID. /// </summary> /// <param name="orderId">The unique identifier of the order.</param> /// <param name="ct">Cancellation token for the async operation.</param> /// <returns>The matching <see cref="Order"/>, or <see langword="null"/>.</returns> /// <exception cref="ArgumentOutOfRangeException"> /// Thrown when <paramref name="orderId"/> is less than or equal to zero. /// </exception> public async Task<Order?> GetOrderAsync(int orderId, CancellationToken ct);
- All public APIs require XML doc comments — enforced by
<GenerateDocumentationFile>true</GenerateDocumentationFile>+ CS1591 as warning. - Internal/private members: comment only when the why isn't obvious from the code. "What" comments are noise; "Why" comments are signal.
- TODO / FIXME: always include a ticket reference:
// TODO(PROJ-123): Remove after migration completes. - Avoid comments that re-state code:
// Increment counterabovecounter++adds zero value.
Tooling & Static Analysis
<AnalysisLevel>latest</AnalysisLevel> and <TreatWarningsAsErrors>true</TreatWarningsAsErrors> in release builds.dotnet format --verify-no-changes). Covers indentation, bracket style, using placement, newlines.Recommended .editorconfig snippet
[*.cs] indent_style = space indent_size = 4 end_of_line = lf charset = utf-8-bom trim_trailing_whitespace = true insert_final_newline = true max_line_length = 120 # Naming rules dotnet_naming_rule.private_fields.symbols = private_fields dotnet_naming_rule.private_fields.style = prefix_underscore dotnet_naming_symbols.private_fields.applicable_kinds = field dotnet_naming_symbols.private_fields.applicable_accessibilities = private dotnet_naming_style.prefix_underscore.capitalization = camel_case dotnet_naming_style.prefix_underscore.required_prefix = _ # Language preferences csharp_prefer_simple_using_statement = true:suggestion csharp_style_namespace_declarations = file_scoped:warning csharp_style_prefer_primary_constructors = true:suggestion csharp_style_expression_bodied_methods = when_on_single_line # Nullability dotnet_diagnostic.CS8600.severity = error # Converting null to non-nullable dotnet_diagnostic.CS8602.severity = error # Dereference of possibly null dotnet_diagnostic.CS8604.severity = error # Null argument for non-nullable param
CI/CD gates (enforce, don't just report)
# Enforce in CI — fail build on violation dotnet build --configuration Release /warnaserror dotnet format --verify-no-changes dotnet test --collect:"XPlat Code Coverage" --threshold 80 dotnet tool run dotnet-stryker # mutation score gate
Reference Links
Quick-scan rule severity matrix
| Area | Must | Should | Avoid |
|---|---|---|---|
| Naming | PascalCase classes/methods, camelCase locals, _camelCase privates, I-prefix interfaces | Abbreviation-free names, Boolean is/has prefix | Hungarian notation, SCREAMING_CAPS constants, single-letter names outside loops |
| Async | No .Result/.Wait(), always pass CancellationToken, never async void (except events) | ConfigureAwait(false) in libs, ValueTask on hot paths | Async in constructors, fire-and-forget without error handling |
| Nullability | Enable project-wide, ArgumentNullException.ThrowIfNull at boundaries | Avoid nullable reference types in domain models where possible | Null-forgiving ! operator except where absolutely unavoidable |
| Exceptions | Specific catch types, never swallow, always log with exception object | Global exception handler for APIs returning ProblemDetails | Catch(Exception) with empty body, throw ex (loses stack trace) |
| DI | Constructor injection, respect lifetimes, never new a service | IServiceScopeFactory for Singleton→Scoped, register per layer | Service locator pattern, property injection in ASP.NET Core |
| Security | No hardcoded secrets, parameterized queries, validate all inputs | bcrypt/Argon2 for passwords, Data Protection API | MD5/SHA1 passwords, string-concat SQL, trusting user-controlled data |
| Testing | Arrange/Act/Assert structure, one concept per test, mock all I/O | ≥80% line coverage on business logic, integration tests with Testcontainers | Shared mutable test state, testing implementation details |