
Modern Csharp Coding Standards
- 1.7k installs
- 1.1k repo stars
- Updated July 3, 2026
- aaronontheweb/dotnet-skills
modern-csharp-coding-standards teaches C# 12+ patterns with records, pattern matching, async APIs, and zero-allocation buffers.
About
The modern-csharp-coding-standards skill guides agents toward C# 12+ idioms for libraries and services. Core principles include immutability with records and init-only properties, nullable reference types, switch expressions, async APIs with cancellation, zero-allocation Span and Memory usage, and composition over inheritance. Reference files expand value objects, performance patterns, Result-based error handling, and anti-patterns such as reflection overuse. Examples show readonly record structs, discriminated unions, pipeline-friendly APIs, and testing guidance. Use when writing new C# code, refactoring legacy classes, designing public APIs, or optimizing hot paths in backend services.
- Promotes records, init-only properties, and readonly record structs for value objects.
- Documents pattern matching, Result types, and composition over abstract bases.
- Covers async/await with cancellation and Span<T>/Memory<T> performance patterns.
- Splits deep guidance across value-objects, performance, composition, and anti-pattern refs.
- Targets library and service API design with strong typing defaults.
Modern Csharp Coding Standards by the numbers
- 1,747 all-time installs (skills.sh)
- +35 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #8 of 153 .NET & C# skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
modern-csharp-coding-standards capabilities & compatibility
- Capabilities
- records · pattern matching · async cancellation · span memory
- Use cases
- api development · refactoring
What modern-csharp-coding-standards says it does
Write modern, high-performance C# code using records, pattern matching, value objects, async/await, Span<T>/Memory<T>, and best-practice API design patterns.
npx skills add https://github.com/aaronontheweb/dotnet-skills --skill modern-csharp-coding-standardsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.7k |
|---|---|
| repo stars | ★ 1.1k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 3, 2026 |
| Repository | aaronontheweb/dotnet-skills ↗ |
How should I structure modern, performant C# APIs and domain models?
Write modern high-performance C# using records, pattern matching, value objects, async/await, Span<T>/Memory<T>, and composition-first API design.
Who is it for?
New C# features, refactors, and performance-critical backend code paths.
Skip if: Non-.NET stacks or UI-only work without C# components.
When should I use this skill?
User writes or reviews C# services, records, or high-throughput APIs.
What you get
Code following immutability, pattern matching, async, and composition conventions with fewer legacy pitfalls.
- refactored explicit mappings
- anti-pattern review notes
Files
Modern C# Coding Standards
When to Use This Skill
Use this skill when:
- Writing new C# code or refactoring existing code
- Designing public APIs for libraries or services
- Optimizing performance-critical code paths
- Implementing domain models with strong typing
- Building async/await-heavy applications
- Working with binary data, buffers, or high-throughput scenarios
Reference Files
- value-objects-and-patterns.md: Full value object examples and pattern matching code
- performance-and-api-design.md: Span<T>/Memory<T> examples and API design principles
- composition-and-error-handling.md: Composition over inheritance, Result type, testing patterns
- anti-patterns-and-reflection.md: Reflection avoidance and common anti-patterns
Core Principles
1. Immutability by Default - Use record types and init-only properties 2. Type Safety - Leverage nullable reference types and value objects 3. Modern Pattern Matching - Use switch expressions and patterns extensively 4. Async Everywhere - Prefer async APIs with proper cancellation support 5. Zero-Allocation Patterns - Use Span<T> and Memory<T> for performance-critical code 6. API Design - Accept abstractions, return appropriately specific types 7. Composition Over Inheritance - Avoid abstract base classes, prefer composition 8. Value Objects as Structs - Use readonly record struct for value objects
---
Language Patterns
Records for Immutable Data (C# 9+)
Use record types for DTOs, messages, events, and domain entities.
// Simple immutable DTO
public record CustomerDto(string Id, string Name, string Email);
// Record with validation in constructor
public record EmailAddress
{
public string Value { get; init; }
public EmailAddress(string value)
{
if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
throw new ArgumentException("Invalid email address", nameof(value));
Value = value;
}
}
// Records with collections - use IReadOnlyList
public record ShoppingCart(
string CartId,
string CustomerId,
IReadOnlyList<CartItem> Items
)
{
public decimal Total => Items.Sum(item => item.Price * item.Quantity);
}When to use `record class` vs `record struct`:
record class(default): Reference types, use for entities, aggregates, DTOs with multiple propertiesrecord struct: Value types, use for value objects (see next section)
Value Objects as readonly record struct
Value objects should always be `readonly record struct` for performance and value semantics. Use explicit conversions, never implicit operators.
public readonly record struct OrderId(string Value)
{
public OrderId(string value) : this(
!string.IsNullOrWhiteSpace(value)
? value
: throw new ArgumentException("OrderId cannot be empty", nameof(value)))
{ }
public override string ToString() => Value;
}
public readonly record struct Money(decimal Amount, string Currency);
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
}See value-objects-and-patterns.md for complete examples including multi-value objects, factory patterns, and the no-implicit-conversion rule.
Pattern Matching (C# 8-12)
Use switch expressions, property patterns, relational patterns, and list patterns for cleaner code.
public decimal CalculateDiscount(Order order) => order switch
{
{ Total: > 1000m } => order.Total * 0.15m,
{ Total: > 500m } => order.Total * 0.10m,
{ Total: > 100m } => order.Total * 0.05m,
_ => 0m
};See value-objects-and-patterns.md for full pattern matching examples.
---
Nullable Reference Types (C# 8+)
Enable nullable reference types in your project and handle nulls explicitly.
// In .csproj
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
// Explicit nullability
public string? FindUserName(string userId)
{
var user = _repository.Find(userId);
return user?.Name;
}
// Pattern matching with null checks
public decimal GetDiscount(Customer? customer) => customer switch
{
null => 0m,
{ IsVip: true } => 0.20m,
{ OrderCount: > 10 } => 0.10m,
_ => 0.05m
};
// Guard clauses with ArgumentNullException.ThrowIfNull (C# 11+)
public void ProcessOrder(Order? order)
{
ArgumentNullException.ThrowIfNull(order);
// order is now non-nullable in this scope
Console.WriteLine(order.Id);
}---
Composition Over Inheritance
Avoid abstract base classes. Use interfaces + composition. Use static helpers for shared logic. Use records with factory methods for variants.
See composition-and-error-handling.md for full examples.
---
Performance Patterns
Async/Await Best Practices
// Async all the way - always accept CancellationToken
public async Task<Order> GetOrderAsync(string orderId, CancellationToken cancellationToken)
{
var order = await _repository.GetAsync(orderId, cancellationToken);
return order;
}
// ValueTask for frequently-called, often-synchronous methods
public ValueTask<Order?> GetCachedOrderAsync(string orderId, CancellationToken cancellationToken)
{
if (_cache.TryGetValue(orderId, out var order))
return ValueTask.FromResult<Order?>(order);
return GetFromDatabaseAsync(orderId, cancellationToken);
}
// IAsyncEnumerable for streaming
public async IAsyncEnumerable<Order> StreamOrdersAsync(
string customerId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var order in _repository.StreamAllAsync(cancellationToken))
{
if (order.CustomerId == customerId)
yield return order;
}
}Key rules:
- Always accept
CancellationTokenwith= default - Use
ConfigureAwait(false)in library code - Never block on async code (no
.Resultor.Wait()) - Use linked CancellationTokenSource for timeouts
Span<T> and Memory<T>
Use Span<T> for synchronous zero-allocation operations, Memory<T> for async, and ArrayPool<T> for large temporary buffers.
See performance-and-api-design.md for complete Span/Memory examples and the API design section.
---
Error Handling: Result Type
For expected errors, use Result<T, TError> instead of exceptions. Use exceptions only for unexpected/system errors.
See composition-and-error-handling.md for the full Result type implementation and usage examples.
---
Avoid Reflection-Based Metaprogramming
Banned: AutoMapper, Mapster, ExpressMapper. Use explicit mapping extension methods instead. Use UnsafeAccessorAttribute (.NET 8+) when you genuinely need private member access.
See anti-patterns-and-reflection.md for full guidance.
---
Code Organization
// File: Domain/Orders/Order.cs
namespace MyApp.Domain.Orders;
// 1. Primary domain type
public record Order(
OrderId Id,
CustomerId CustomerId,
Money Total,
OrderStatus Status,
IReadOnlyList<OrderItem> Items
)
{
public bool IsCompleted => Status is OrderStatus.Completed;
public Result<Order, OrderError> AddItem(OrderItem item)
{
if (Status is not OrderStatus.Draft)
return Result<Order, OrderError>.Failure(
new OrderError("ORDER_NOT_DRAFT", "Can only add items to draft orders"));
var newItems = Items.Append(item).ToList();
var newTotal = new Money(
Items.Sum(i => i.Total.Amount) + item.Total.Amount,
Total.Currency);
return Result<Order, OrderError>.Success(
this with { Items = newItems, Total = newTotal });
}
}
// 2. Enums for state
public enum OrderStatus { Draft, Submitted, Processing, Completed, Cancelled }
// 3. Related types
public record OrderItem(ProductId ProductId, Quantity Quantity, Money UnitPrice)
{
public Money Total => new(UnitPrice.Amount * Quantity.Value, UnitPrice.Currency);
}
// 4. Value objects
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
// 5. Errors
public readonly record struct OrderError(string Code, string Message);---
Best Practices Summary
DO's
- Use
recordfor DTOs, messages, and domain entities - Use
readonly record structfor value objects - Leverage pattern matching with
switchexpressions - Enable and respect nullable reference types
- Use async/await for all I/O operations
- Accept
CancellationTokenin all async methods - Use
Span<T>andMemory<T>for high-performance scenarios - Accept abstractions (
IEnumerable<T>,IReadOnlyList<T>) - Use
Result<T, TError>for expected errors - Pool buffers with
ArrayPool<T>for large allocations - Prefer composition over inheritance
DON'Ts
- Don't use mutable classes when records work
- Don't use classes for value objects (use
readonly record struct) - Don't create deep inheritance hierarchies
- Don't ignore nullable reference type warnings
- Don't block on async code (
.Result,.Wait()) - Don't use
byte[]whenSpan<byte>suffices - Don't forget
CancellationTokenparameters - Don't return mutable collections from APIs
- Don't throw exceptions for expected business errors
- Don't allocate large arrays repeatedly (use
ArrayPool)
See anti-patterns-and-reflection.md for detailed anti-pattern examples.
---
Additional Resources
- C# Language Specification: https://learn.microsoft.com/en-us/dotnet/csharp/
- Pattern Matching: https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/pattern-matching
- Span<T> and Memory<T>: https://learn.microsoft.com/en-us/dotnet/standard/memory-and-spans/
- Async Best Practices: https://learn.microsoft.com/en-us/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming
- .NET Performance Tips: https://learn.microsoft.com/en-us/dotnet/framework/performance/
Anti-Patterns and Reflection Avoidance
Guidelines on avoiding reflection-based metaprogramming and common C# anti-patterns.
Contents
Avoid Reflection-Based Metaprogramming
Prefer statically-typed, explicit code over reflection-based "magic" libraries.
Reflection-based libraries like AutoMapper trade compile-time safety for convenience. When mappings break, you find out at runtime (or worse, in production) instead of at compile time.
Banned Libraries
| Library | Problem |
|---|---|
| AutoMapper | Reflection magic, hidden mappings, runtime failures, hard to debug |
| Mapster | Same issues as AutoMapper |
| ExpressMapper | Same issues |
Why Reflection Mapping Fails
// With AutoMapper - compiles fine, fails at runtime
public record UserDto(string Id, string Name, string Email);
public record UserEntity(Guid Id, string FullName, string EmailAddress);
// This mapping silently produces garbage:
// - Id: string vs Guid mismatch
// - Name vs FullName: no match, null/default
// - Email vs EmailAddress: no match, null/default
var dto = _mapper.Map<UserDto>(entity); // Compiles! Breaks at runtime.Use Explicit Mapping Methods Instead
// Extension method - compile-time checked, easy to find, easy to debug
public static class UserMappings
{
public static UserDto ToDto(this UserEntity entity) => new(
Id: entity.Id.ToString(),
Name: entity.FullName,
Email: entity.EmailAddress);
public static UserEntity ToEntity(this CreateUserRequest request) => new(
Id: Guid.NewGuid(),
FullName: request.Name,
EmailAddress: request.Email);
}
// Usage - explicit and traceable
var dto = entity.ToDto();
var entity = request.ToEntity();Benefits of Explicit Mappings
| Aspect | AutoMapper | Explicit Methods |
|---|---|---|
| Compile-time safety | No - runtime errors | Yes - compiler catches mismatches |
| Discoverability | Hidden in profiles | "Go to Definition" works |
| Debugging | Black box | Step through code |
| Refactoring | Rename breaks silently | IDE renames correctly |
| Performance | Reflection overhead | Direct property access |
| Testing | Need integration tests | Simple unit tests |
Complex Mappings
For complex transformations, explicit code is even more valuable:
public static OrderSummaryDto ToSummary(this Order order) => new(
OrderId: order.Id.Value.ToString(),
CustomerName: order.Customer.FullName,
ItemCount: order.Items.Count,
Total: order.Items.Sum(i => i.Quantity * i.UnitPrice),
Status: order.Status switch
{
OrderStatus.Pending => "Awaiting Payment",
OrderStatus.Paid => "Processing",
OrderStatus.Shipped => "On the Way",
OrderStatus.Delivered => "Completed",
_ => "Unknown"
},
FormattedDate: order.CreatedAt.ToString("MMMM d, yyyy"));This is:
- Readable: Anyone can understand the transformation
- Debuggable: Set a breakpoint, inspect values
- Testable: Pass an Order, assert on the result
- Refactorable: Change a property name, compiler tells you everywhere it's used
When Reflection is Acceptable
Reflection has legitimate uses, but mapping DTOs isn't one of them:
| Use Case | Acceptable? |
|---|---|
| Serialization (System.Text.Json, Newtonsoft) | Yes - well-tested, source generators available |
| Dependency injection container | Yes - framework infrastructure |
| ORM entity mapping (EF Core) | Yes - necessary for database abstraction |
| Test fixtures and builders | Sometimes - for convenience in tests only |
| DTO/domain object mapping | No - use explicit methods |
UnsafeAccessorAttribute (.NET 8+)
When you genuinely need to access private or internal members (serializers, test helpers, framework code), use UnsafeAccessorAttribute instead of traditional reflection. It provides zero-overhead, AOT-compatible member access.
// AVOID: Traditional reflection - slow, allocates, breaks AOT
var field = typeof(Order).GetField("_status", BindingFlags.NonPublic | BindingFlags.Instance);
var status = (OrderStatus)field!.GetValue(order)!;
// PREFER: UnsafeAccessor - zero overhead, AOT-compatible
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_status")]
static extern ref OrderStatus GetStatusField(Order order);
var status = GetStatusField(order); // Direct access, no reflectionSupported accessor kinds:
// Private field access
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")]
static extern ref List<OrderItem> GetItemsField(Order order);
// Private method access
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Recalculate")]
static extern void CallRecalculate(Order order);
// Private static field
[UnsafeAccessor(UnsafeAccessorKind.StaticField, Name = "_instanceCount")]
static extern ref int GetInstanceCount(Order order);
// Private constructor
[UnsafeAccessor(UnsafeAccessorKind.Constructor)]
static extern Order CreateOrder(OrderId id, CustomerId customerId);Why UnsafeAccessor over reflection:
| Aspect | Reflection | UnsafeAccessor |
|---|---|---|
| Performance | Slow (100-1000x) | Zero overhead |
| AOT compatible | No | Yes |
| Allocations | Yes (boxing, arrays) | None |
| Compile-time checked | No | Partially (signature) |
Use cases:
- Serializers accessing private backing fields
- Test helpers verifying internal state
- Framework code that needs to bypass visibility
Resources:
- A new way of doing reflection with .NET 8
- Accessing private members without reflection in .NET 8.0
- Modern .NET Reflection with UnsafeAccessor
Anti-Patterns to Avoid
Don't: Use mutable DTOs
// BAD: Mutable DTO
public class CustomerDto
{
public string Id { get; set; }
public string Name { get; set; }
}
// GOOD: Immutable record
public record CustomerDto(string Id, string Name);Don't: Use classes for value objects
// BAD: Value object as class
public class OrderId
{
public string Value { get; }
public OrderId(string value) => Value = value;
}
// GOOD: Value object as readonly record struct
public readonly record struct OrderId(string Value);Don't: Create deep inheritance hierarchies
// BAD: Deep inheritance
public abstract class Entity { }
public abstract class AggregateRoot : Entity { }
public abstract class Order : AggregateRoot { }
public class CustomerOrder : Order { }
// GOOD: Flat structure with composition
public interface IEntity
{
Guid Id { get; }
}
public record Order(OrderId Id, CustomerId CustomerId, Money Total) : IEntity
{
Guid IEntity.Id => Id.Value;
}Don't: Return List<T> when you mean IReadOnlyList<T>
// BAD: Exposes internal list for modification
public List<Order> GetOrders() => _orders;
// GOOD: Returns read-only view
public IReadOnlyList<Order> GetOrders() => _orders;Don't: Use byte[] when ReadOnlySpan<byte> works
// BAD: Allocates array on every call
public byte[] GetHeader()
{
var header = new byte[64];
// Fill header
return header;
}
// GOOD: Zero allocation with Span
public void GetHeader(Span<byte> destination)
{
if (destination.Length < 64)
throw new ArgumentException("Buffer too small");
// Fill header directly into caller's buffer
}Don't: Forget CancellationToken in async methods
// BAD: No cancellation support
public async Task<Order> GetOrderAsync(OrderId id)
{
return await _repository.GetAsync(id);
}
// GOOD: Cancellation support
public async Task<Order> GetOrderAsync(
OrderId id,
CancellationToken cancellationToken = default)
{
return await _repository.GetAsync(id, cancellationToken);
}Don't: Block on async code
// BAD: Deadlock risk!
public Order GetOrder(OrderId id)
{
return GetOrderAsync(id).Result;
}
// BAD: Also deadlock risk!
public Order GetOrder(OrderId id)
{
return GetOrderAsync(id).GetAwaiter().GetResult();
}
// GOOD: Async all the way
public async Task<Order> GetOrderAsync(
OrderId id,
CancellationToken cancellationToken)
{
return await _repository.GetAsync(id, cancellationToken);
}Composition and Error Handling
Composition over inheritance, Result type pattern, and testing patterns for modern C#.
Contents
Composition Over Inheritance
Avoid abstract base classes and inheritance hierarchies. Use composition and interfaces instead.
// BAD: Abstract base class hierarchy
public abstract class PaymentProcessor
{
public abstract Task<PaymentResult> ProcessAsync(Money amount);
protected async Task<bool> ValidateAsync(Money amount)
{
// Shared validation logic
return amount.Amount > 0;
}
}
public class CreditCardProcessor : PaymentProcessor
{
public override async Task<PaymentResult> ProcessAsync(Money amount)
{
await ValidateAsync(amount);
// Process credit card...
}
}
// GOOD: Composition with interfaces
public interface IPaymentProcessor
{
Task<PaymentResult> ProcessAsync(Money amount, CancellationToken cancellationToken);
}
public interface IPaymentValidator
{
Task<ValidationResult> ValidateAsync(Money amount, CancellationToken cancellationToken);
}
// Concrete implementations compose validators
public sealed class CreditCardProcessor : IPaymentProcessor
{
private readonly IPaymentValidator _validator;
private readonly ICreditCardGateway _gateway;
public CreditCardProcessor(IPaymentValidator validator, ICreditCardGateway gateway)
{
_validator = validator;
_gateway = gateway;
}
public async Task<PaymentResult> ProcessAsync(Money amount, CancellationToken cancellationToken)
{
var validation = await _validator.ValidateAsync(amount, cancellationToken);
if (!validation.IsValid)
return PaymentResult.Failed(validation.Error);
return await _gateway.ChargeAsync(amount, cancellationToken);
}
}
// GOOD: Static helper classes for shared logic (no inheritance)
public static class PaymentValidation
{
public static ValidationResult ValidateAmount(Money amount)
{
if (amount.Amount <= 0)
return ValidationResult.Invalid("Amount must be positive");
if (amount.Amount > 10000m)
return ValidationResult.Invalid("Amount exceeds maximum");
return ValidationResult.Valid();
}
}
// GOOD: Records for modeling variants (not inheritance)
public enum PaymentType { CreditCard, BankTransfer, Cash }
public record PaymentMethod
{
public PaymentType Type { get; init; }
public string? Last4 { get; init; } // For credit cards
public string? AccountNumber { get; init; } // For bank transfers
public static PaymentMethod CreditCard(string last4) => new()
{
Type = PaymentType.CreditCard,
Last4 = last4
};
public static PaymentMethod BankTransfer(string accountNumber) => new()
{
Type = PaymentType.BankTransfer,
AccountNumber = accountNumber
};
public static PaymentMethod Cash() => new() { Type = PaymentType.Cash };
}When inheritance is acceptable:
- Framework requirements (e.g.,
ControllerBasein ASP.NET Core) - Library integration (e.g., custom exceptions inheriting from
Exception) - These should be rare cases in your application code
Result Type Pattern
For expected errors, use a domain-specific result type instead of exceptions. Don't build a generic Result<T> — each operation knows what success and failure look like, so let the result type reflect that. Use sealed records with factory methods and enum error codes.
// Enum for error classification - type-safe and switchable
public enum OrderErrorCode
{
ValidationError,
InsufficientInventory,
NotFound
}
// Domain-specific result type - sealed record with factory methods
public sealed record CreateOrderResult
{
public bool IsSuccess { get; private init; }
public Order? Order { get; private init; }
public OrderErrorCode? ErrorCode { get; private init; }
public string? ErrorMessage { get; private init; }
public static CreateOrderResult Success(Order order) => new()
{
IsSuccess = true,
Order = order
};
public static CreateOrderResult Failed(OrderErrorCode code, string message) => new()
{
IsSuccess = false,
ErrorCode = code,
ErrorMessage = message
};
}
// Usage example
public sealed class OrderService(IOrderRepository repository)
{
public async Task<CreateOrderResult> CreateOrderAsync(
CreateOrderRequest request,
CancellationToken cancellationToken)
{
if (!IsValid(request))
return CreateOrderResult.Failed(
OrderErrorCode.ValidationError, "Invalid order request");
if (!await HasInventoryAsync(request.Items, cancellationToken))
return CreateOrderResult.Failed(
OrderErrorCode.InsufficientInventory, "Items out of stock");
var order = new Order(
OrderId.New(),
new CustomerId(request.CustomerId),
request.Items);
await repository.SaveAsync(order, cancellationToken);
return CreateOrderResult.Success(order);
}
// Map result to HTTP response - switch on enum error codes
public IActionResult MapToActionResult(CreateOrderResult result)
{
if (result.IsSuccess)
return new OkObjectResult(result.Order);
return result.ErrorCode switch
{
OrderErrorCode.ValidationError =>
new BadRequestObjectResult(new { error = result.ErrorMessage }),
OrderErrorCode.InsufficientInventory =>
new ConflictObjectResult(new { error = result.ErrorMessage }),
OrderErrorCode.NotFound =>
new NotFoundObjectResult(new { error = result.ErrorMessage }),
_ => new ObjectResult(new { error = result.ErrorMessage }) { StatusCode = 500 }
};
}
}When to use Result vs Exceptions:
- Use Result: Expected errors (validation, business rules, not found)
- Use Exceptions: Unexpected errors (network failures, system errors, programming bugs)
Testing Patterns
// Use record for test data builders
public record OrderBuilder
{
public OrderId Id { get; init; } = OrderId.New();
public CustomerId CustomerId { get; init; } = CustomerId.New();
public Money Total { get; init; } = new Money(100m, "USD");
public IReadOnlyList<OrderItem> Items { get; init; } = Array.Empty<OrderItem>();
public Order Build() => new(Id, CustomerId, Total, Items);
}
// Use 'with' expression for test variations
[Fact]
public void CalculateDiscount_LargeOrder_AppliesCorrectDiscount()
{
// Arrange
var baseOrder = new OrderBuilder().Build();
var largeOrder = baseOrder with
{
Total = new Money(1500m, "USD")
};
// Act
var discount = _service.CalculateDiscount(largeOrder);
// Assert
discount.Should().Be(new Money(225m, "USD")); // 15% of 1500
}
// Span-based testing
[Theory]
[InlineData("ORD-12345", true)]
[InlineData("INVALID", false)]
public void TryParseOrderId_VariousInputs_ReturnsExpectedResult(
string input,
bool expected)
{
// Act
var result = OrderIdParser.TryParse(input.AsSpan(), out var orderId);
// Assert
result.Should().Be(expected);
}
// Testing with value objects
[Fact]
public void Money_Add_SameCurrency_ReturnsSum()
{
// Arrange
var money1 = new Money(100m, "USD");
var money2 = new Money(50m, "USD");
// Act
var result = money1.Add(money2);
// Assert
result.Should().Be(new Money(150m, "USD"));
}
[Fact]
public void Money_Add_DifferentCurrency_ThrowsException()
{
// Arrange
var usd = new Money(100m, "USD");
var eur = new Money(50m, "EUR");
// Act & Assert
var act = () => usd.Add(eur);
act.Should().Throw<InvalidOperationException>()
.WithMessage("*different currencies*");
}Performance and API Design Patterns
Zero-allocation patterns with Span<T>/Memory<T> and API design principles for accepting and returning the right types.
Contents
Span<T> and Memory<T> for Zero-Allocation Code
Use Span<T> and Memory<T> instead of byte[] or string for performance-critical code.
// Span<T> for synchronous, zero-allocation operations
public int ParseOrderId(ReadOnlySpan<char> input)
{
// Work with data without allocations
if (!input.StartsWith("ORD-"))
throw new FormatException("Invalid order ID format");
var numberPart = input.Slice(4);
return int.Parse(numberPart);
}
// stackalloc with Span<T>
public void FormatMessage()
{
Span<char> buffer = stackalloc char[256];
var written = FormatInto(buffer);
var message = new string(buffer.Slice(0, written));
}
// SkipLocalsInit with stackalloc - skips zero-initialization for performance
// By default, .NET zero-initializes all locals (.locals init flag). This can have
// measurable overhead with stackalloc. Use [SkipLocalsInit] when:
// - You write to the buffer before reading (like FormatInto below)
// - Profiling shows zero-init as a bottleneck
// WARNING: Reading before writing returns garbage data
// Requires: <AllowUnsafeBlocks>true</AllowUnsafeBlocks> in .csproj
// See: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/attributes/general#skiplocalsinit-attribute
using System.Runtime.CompilerServices;
[SkipLocalsInit]
public void FormatMessage()
{
Span<char> buffer = stackalloc char[256];
var written = FormatInto(buffer);
var message = new string(buffer.Slice(0, written));
}
// Memory<T> for async operations (Span can't cross await)
public async Task<int> ReadDataAsync(
Memory<byte> buffer,
CancellationToken cancellationToken)
{
return await _stream.ReadAsync(buffer, cancellationToken);
}
// String manipulation with Span to avoid allocations
public bool TryParseKeyValue(ReadOnlySpan<char> line, out string key, out string value)
{
key = string.Empty;
value = string.Empty;
int colonIndex = line.IndexOf(':');
if (colonIndex == -1)
return false;
// Only allocate strings once we know the format is valid
key = new string(line.Slice(0, colonIndex).Trim());
value = new string(line.Slice(colonIndex + 1).Trim());
return true;
}
// ArrayPool for temporary large buffers
public async Task ProcessLargeFileAsync(
Stream stream,
CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(), cancellationToken)) > 0)
{
ProcessChunk(buffer.AsSpan(0, bytesRead));
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
// Hybrid buffer pattern for transient UTF-8 work. See caveats of SkipLocalsInit in the corresponding section.
[SkipLocalsInit]
static short GenerateHashCode(string? key)
{
if (key is null) return 0;
const int StackLimit = 256;
var enc = Encoding.UTF8;
var max = enc.GetMaxByteCount(key.Length);
byte[]? rented = null;
Span<byte> buf = max <= StackLimit
? stackalloc byte[StackLimit]
: (rented = ArrayPool<byte>.Shared.Rent(max));
try
{
var written = enc.GetBytes(key.AsSpan(), buf);
ComputeHash(buf[..written], out var h1, out var h2);
return unchecked((short)(h1 ^ h2));
}
finally
{
if (rented is not null) ArrayPool<byte>.Shared.Return(rented);
}
}
// Span-based parsing without substring allocations
public static (string Protocol, string Host, int Port) ParseUrl(ReadOnlySpan<char> url)
{
var protocolEnd = url.IndexOf("://");
var protocol = new string(url.Slice(0, protocolEnd));
var afterProtocol = url.Slice(protocolEnd + 3);
var portStart = afterProtocol.IndexOf(':');
var host = new string(afterProtocol.Slice(0, portStart));
var portSpan = afterProtocol.Slice(portStart + 1);
var port = int.Parse(portSpan);
return (protocol, host, port);
}
// Writing data to Span
public bool TryFormatOrderId(int orderId, Span<char> destination, out int charsWritten)
{
const string prefix = "ORD-";
if (destination.Length < prefix.Length + 10)
{
charsWritten = 0;
return false;
}
prefix.AsSpan().CopyTo(destination);
var numberWritten = orderId.TryFormat(
destination.Slice(prefix.Length),
out var numberChars);
charsWritten = prefix.Length + numberChars;
return numberWritten;
}When to use what:
| Type | Use Case |
|---|---|
Span<T> | Synchronous operations, stack-allocated buffers, slicing without allocation |
ReadOnlySpan<T> | Read-only views, method parameters for data you won't modify |
Memory<T> | Async operations (Span can't cross await boundaries) |
ReadOnlyMemory<T> | Read-only async operations |
byte[] | When you need to store data long-term or pass to APIs requiring arrays |
ArrayPool<T> | Large temporary buffers (>1KB) to avoid GC pressure |
API Design Principles
Accept Abstractions, Return Appropriately Specific
For Parameters (Accept):
// Accept IEnumerable<T> if you only iterate once
public decimal CalculateTotal(IEnumerable<OrderItem> items)
{
return items.Sum(item => item.Price * item.Quantity);
}
// Accept IReadOnlyCollection<T> if you need Count
public bool HasMinimumItems(IReadOnlyCollection<OrderItem> items, int minimum)
{
return items.Count >= minimum;
}
// Accept IReadOnlyList<T> if you need indexing
public OrderItem GetMiddleItem(IReadOnlyList<OrderItem> items)
{
if (items.Count == 0)
throw new ArgumentException("List cannot be empty");
return items[items.Count / 2]; // Indexed access
}
// Accept ReadOnlySpan<T> for high-performance, zero-allocation APIs
public int Sum(ReadOnlySpan<int> numbers)
{
int total = 0;
foreach (var num in numbers)
total += num;
return total;
}
// Accept IAsyncEnumerable<T> for async streaming
public async Task<int> CountItemsAsync(
IAsyncEnumerable<Order> orders,
CancellationToken cancellationToken)
{
int count = 0;
await foreach (var order in orders.WithCancellation(cancellationToken))
count++;
return count;
}For Return Types:
// Return IEnumerable<T> for lazy/deferred execution
public IEnumerable<Order> GetOrdersLazy(string customerId)
{
foreach (var order in _repository.Query())
{
if (order.CustomerId == customerId)
yield return order; // Lazy evaluation
}
}
// Return IReadOnlyList<T> for materialized, immutable collections
public IReadOnlyList<Order> GetOrders(string customerId)
{
return _repository
.Query()
.Where(o => o.CustomerId == customerId)
.ToList(); // Materialized
}
// Return concrete types when callers need mutation
public List<Order> GetMutableOrders(string customerId)
{
// Explicitly allow mutation by returning List<T>
return _repository
.Query()
.Where(o => o.CustomerId == customerId)
.ToList();
}
// Return IAsyncEnumerable<T> for async streaming
public async IAsyncEnumerable<Order> StreamOrdersAsync(
string customerId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var order in _repository.StreamAllAsync(cancellationToken))
{
if (order.CustomerId == customerId)
yield return order;
}
}
// Return arrays for interop or when caller expects array
public byte[] SerializeOrder(Order order)
{
// Binary serialization - byte[] is appropriate here
return MessagePackSerializer.Serialize(order);
}Summary Table:
| Scenario | Accept | Return |
|---|---|---|
| Only iterate once | IEnumerable<T> | IEnumerable<T> (if lazy) |
| Need count | IReadOnlyCollection<T> | IReadOnlyCollection<T> |
| Need indexing | IReadOnlyList<T> | IReadOnlyList<T> |
| High-performance, sync | ReadOnlySpan<T> | Span<T> (rarely) |
| Async streaming | IAsyncEnumerable<T> | IAsyncEnumerable<T> |
| Caller needs mutation | - | List<T>, T[] |
Method Signatures Best Practices
// Complete async method signature
public async Task<Result<Order, OrderError>> CreateOrderAsync(
CreateOrderRequest request,
CancellationToken cancellationToken = default)
{
// Implementation
}
// Optional parameters at the end
public async Task<List<Order>> GetOrdersAsync(
string customerId,
DateTime? startDate = null,
DateTime? endDate = null,
CancellationToken cancellationToken = default)
{
// Implementation
}
// Use record for multiple related parameters
public record SearchOrdersRequest(
string? CustomerId,
DateTime? StartDate,
DateTime? EndDate,
OrderStatus? Status,
int PageSize = 20,
int PageNumber = 1
);
public async Task<PagedResult<Order>> SearchOrdersAsync(
SearchOrdersRequest request,
CancellationToken cancellationToken = default)
{
// Implementation
}
// Primary constructors (C# 12+) for simple classes
public sealed class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
public async Task<Order> GetOrderAsync(OrderId orderId, CancellationToken cancellationToken)
{
logger.LogInformation("Fetching order {OrderId}", orderId);
return await repository.GetAsync(orderId, cancellationToken);
}
}
// Options pattern for complex configuration
public sealed class EmailServiceOptions
{
public required string SmtpHost { get; init; }
public int SmtpPort { get; init; } = 587;
public bool UseSsl { get; init; } = true;
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}
public sealed class EmailService(IOptions<EmailServiceOptions> options)
{
private readonly EmailServiceOptions _options = options.Value;
}Value Objects and Pattern Matching
Full code examples for value objects and pattern matching in modern C#.
Contents
- Value Objects as readonly record struct
- Constraint-Enforcing Value Objects
- No Implicit Conversions
- Pattern Matching (C# 8-12)
Value Objects as readonly record struct
Value objects should always be `readonly record struct` for performance and value semantics.
// Single-value object
public readonly record struct OrderId(string Value)
{
public OrderId(string value) : this(
!string.IsNullOrWhiteSpace(value)
? value
: throw new ArgumentException("OrderId cannot be empty", nameof(value)))
{
}
public override string ToString() => Value;
// NO implicit conversions - defeats type safety!
// Access inner value explicitly: orderId.Value
}
// Multi-value object
public readonly record struct Money(decimal Amount, string Currency)
{
public Money(decimal amount, string currency) : this(
amount >= 0 ? amount : throw new ArgumentException("Amount cannot be negative", nameof(amount)),
ValidateCurrency(currency))
{
}
private static string ValidateCurrency(string currency)
{
if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
throw new ArgumentException("Currency must be a 3-letter code", nameof(currency));
return currency.ToUpperInvariant();
}
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException($"Cannot add {Currency} to {other.Currency}");
return new Money(Amount + other.Amount, Currency);
}
public override string ToString() => $"{Amount:N2} {Currency}";
}
// Value object with input normalization
public readonly record struct PhoneNumber
{
public string Value { get; }
public PhoneNumber(string input)
{
if (string.IsNullOrWhiteSpace(input))
throw new ArgumentException("Phone number cannot be empty", nameof(input));
// Normalize: remove all non-digits
var digits = new string(input.Where(char.IsDigit).ToArray());
if (digits.Length is < 10 or > 15)
throw new ArgumentException("Phone number must be 10-15 digits", nameof(input));
Value = digits;
}
public override string ToString() => Value;
}
// Percentage value object with range validation
public readonly record struct Percentage
{
private readonly decimal _value;
public decimal Value => _value;
public Percentage(decimal value)
{
if (value < 0 || value > 100)
throw new ArgumentOutOfRangeException(nameof(value), "Percentage must be between 0 and 100");
_value = value;
}
public decimal AsDecimal() => _value / 100m;
public static Percentage FromDecimal(decimal decimalValue)
{
if (decimalValue < 0 || decimalValue > 1)
throw new ArgumentOutOfRangeException(nameof(decimalValue), "Decimal must be between 0 and 1");
return new Percentage(decimalValue * 100);
}
public override string ToString() => $"{_value}%";
}
// Strongly-typed ID
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
// Quantity with units
public readonly record struct Quantity(int Value, string Unit)
{
public Quantity(int value, string unit) : this(
value >= 0 ? value : throw new ArgumentException("Quantity cannot be negative"),
!string.IsNullOrWhiteSpace(unit) ? unit : throw new ArgumentException("Unit cannot be empty"))
{
}
public override string ToString() => $"{Value} {Unit}";
}Why `readonly record struct` for value objects:
- Value semantics: Equality based on content, not reference
- Stack allocation: Better performance, no GC pressure
- Immutability:
readonlyprevents accidental mutation - Pattern matching: Works seamlessly with switch expressions
Constraint-Enforcing Value Objects
Value objects aren't just for identifiers. They're equally valuable for enforcing domain constraints on strings, numbers, and URIs — making illegal states unrepresentable at the type level.
Key principle: validate at construction, trust everywhere else. Once you have an AbsoluteUrl, every consumer knows it's valid without re-checking.
// AbsoluteUrl - enforces HTTP/HTTPS scheme constraints
public readonly record struct AbsoluteUrl
{
public Uri Value { get; }
public AbsoluteUrl(string uriString) : this(new Uri(uriString, UriKind.Absolute)) { }
public AbsoluteUrl(Uri value)
{
if (!value.IsAbsoluteUri)
throw new ArgumentException(
$"Value must be an absolute URL. Instead found [{value}]", nameof(value));
if (value.Scheme != Uri.UriSchemeHttp && value.Scheme != Uri.UriSchemeHttps)
throw new ArgumentException(
$"Value must be an HTTP or HTTPS URL. Instead found [{value.Scheme}]", nameof(value));
Value = value;
}
/// <summary>
/// Resolves a potentially relative URL against a base URL.
/// Handles Linux quirk where Uri.TryCreate("/path", UriKind.Absolute)
/// succeeds as file:///path.
/// </summary>
public static AbsoluteUrl FromRelative(string? url, AbsoluteUrl baseUrl)
{
if (string.IsNullOrEmpty(url))
throw new ArgumentException("URL cannot be null or empty", nameof(url));
if (Uri.TryCreate(url, UriKind.Absolute, out var absoluteUri) &&
(absoluteUri.Scheme == Uri.UriSchemeHttp || absoluteUri.Scheme == Uri.UriSchemeHttps))
return new AbsoluteUrl(absoluteUri);
return new AbsoluteUrl(new Uri(baseUrl.Value, url));
}
public override string ToString() => Value.ToString();
}
// NonEmptyString - prevents empty/whitespace strings from propagating
public readonly record struct NonEmptyString
{
public string Value { get; }
public NonEmptyString(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Value cannot be null or whitespace", nameof(value));
Value = value;
}
public override string ToString() => Value;
}
// EmailAddress - format validation at construction
public readonly record struct EmailAddress
{
public string Value { get; }
public EmailAddress(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Email cannot be empty", nameof(value));
if (!value.Contains('@') || !value.Contains('.'))
throw new ArgumentException($"Invalid email format: {value}", nameof(value));
Value = value.ToLowerInvariant();
}
public override string ToString() => Value;
}
// PositiveAmount - numeric range constraint
public readonly record struct PositiveAmount
{
public decimal Value { get; }
public PositiveAmount(decimal value)
{
if (value <= 0)
throw new ArgumentOutOfRangeException(nameof(value), "Amount must be positive");
Value = value;
}
public override string ToString() => Value.ToString("N2");
}Why this matters:
- APIs like Slack Block Kit silently reject relative URLs with cryptic errors. Transactional email links break if they're relative.
AbsoluteUrlmakes the compiler prevent this. - Platform gotchas belong in the value object — e.g., Linux
Uri.TryCreatetreating/pathasfile:///pathis handled once inFromRelative, not at every call site.
TypeConverter Support for Configuration Binding
Add a TypeConverter so your value objects work with IOptions<T> and configuration binding:
[TypeConverter(typeof(AbsoluteUrlTypeConverter))]
public readonly record struct AbsoluteUrl
{
// ... same as above
}
public sealed class AbsoluteUrlTypeConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
=> sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
public override object? ConvertFrom(
ITypeDescriptorContext? context, CultureInfo? culture, object value)
=> value is string s ? new AbsoluteUrl(s) : base.ConvertFrom(context, culture, value);
}
// Now this works with appsettings.json binding:
public sealed class WebhookOptions
{
public AbsoluteUrl CallbackUrl { get; set; }
public AbsoluteUrl HealthCheckUrl { get; set; }
}
// appsettings.json:
// { "Webhook": { "CallbackUrl": "https://example.com/callback" } }
services.Configure<WebhookOptions>(configuration.GetSection("Webhook"));No Implicit Conversions
CRITICAL: NO implicit conversions. Implicit operators defeat the purpose of value objects by allowing silent type coercion:
// WRONG - defeats compile-time safety:
public readonly record struct UserId(Guid Value)
{
public static implicit operator UserId(Guid value) => new(value); // NO!
public static implicit operator Guid(UserId value) => value.Value; // NO!
}
// With implicit operators, this compiles silently:
void ProcessUser(UserId userId) { }
ProcessUser(Guid.NewGuid()); // Oops - meant to pass PostId
// CORRECT - all conversions explicit:
public readonly record struct UserId(Guid Value)
{
public static UserId New() => new(Guid.NewGuid());
// No implicit operators
// Create: new UserId(guid) or UserId.New()
// Extract: userId.Value
}Explicit conversions force every boundary crossing to be visible:
// API boundary - explicit conversion IN
var userId = new UserId(request.UserId); // Validates on entry
// Database boundary - explicit conversion OUT
await _db.ExecuteAsync(sql, new { UserId = userId.Value });Pattern Matching (C# 8-12)
Leverage modern pattern matching for cleaner, more expressive code.
// Switch expressions with value objects
public string GetPaymentMethodDescription(PaymentMethod payment) => payment switch
{
{ Type: PaymentType.CreditCard, Last4: var last4 } => $"Credit card ending in {last4}",
{ Type: PaymentType.BankTransfer, AccountNumber: var account } => $"Bank transfer from {account}",
{ Type: PaymentType.Cash } => "Cash payment",
_ => "Unknown payment method"
};
// Property patterns
public decimal CalculateDiscount(Order order) => order switch
{
{ Total: > 1000m } => order.Total * 0.15m,
{ Total: > 500m } => order.Total * 0.10m,
{ Total: > 100m } => order.Total * 0.05m,
_ => 0m
};
// Relational and logical patterns
public string ClassifyTemperature(int temp) => temp switch
{
< 0 => "Freezing",
>= 0 and < 10 => "Cold",
>= 10 and < 20 => "Cool",
>= 20 and < 30 => "Warm",
>= 30 => "Hot",
_ => throw new ArgumentOutOfRangeException(nameof(temp))
};
// List patterns (C# 11+)
public bool IsValidSequence(int[] numbers) => numbers switch
{
[] => false, // Empty
[_] => true, // Single element
[var first, .., var last] when first < last => true, // First < last
_ => false
};
// Type patterns with null checks
public string FormatValue(object? value) => value switch
{
null => "null",
string s => $"\"{s}\"",
int i => i.ToString(),
double d => d.ToString("F2"),
DateTime dt => dt.ToString("yyyy-MM-dd"),
Money m => m.ToString(),
IEnumerable<object> collection => $"[{string.Join(", ", collection)}]",
_ => value.ToString() ?? "unknown"
};
// Combining patterns for complex logic
public record OrderState(bool IsPaid, bool IsShipped, bool IsCancelled);
public string GetOrderStatus(OrderState state) => state switch
{
{ IsCancelled: true } => "Cancelled",
{ IsPaid: true, IsShipped: true } => "Delivered",
{ IsPaid: true, IsShipped: false } => "Processing",
{ IsPaid: false } => "Awaiting Payment",
_ => "Unknown"
};
// Pattern matching with value objects
public decimal CalculateShipping(Money total, Country destination) => (total, destination) switch
{
({ Amount: > 100m }, _) => 0m, // Free shipping over $100
(_, { Code: "US" or "CA" }) => 5m, // North America
(_, { Code: "GB" or "FR" or "DE" }) => 10m, // Europe
_ => 25m // International
};Related skills
How it compares
Pick modern-csharp-coding-standards over generic lint rules when banning reflection mappers and enforcing explicit C# DTO conversions.
FAQ
What is modern-csharp-coding-standards?
Write modern high-performance C# using records, pattern matching, value objects, async/await, Span<T>/Memory<T>, and composition-first API design.
What is modern-csharp-coding-standards?
Write modern high-performance C# using records, pattern matching, value objects, async/await, Span<T>/Memory<T>, and composition-first API design.
What is modern-csharp-coding-standards?
Write modern high-performance C# using records, pattern matching, value objects, async/await, Span<T>/Memory<T>, and composition-first API design.
Is Modern Csharp Coding Standards safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.