| name | modern-csharp-coding-standards |
| description | Write modern, high-performance C# code using records, pattern matching, value objects, async/await, Span<T>/Memory<T>, and best-practice API design patterns. Emphasizes functional-style programming with C# 12+ features. |
| invocable | false |
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
Core Principles
- Immutability by Default - Use
record types and init-only properties
- Type Safety - Leverage nullable reference types and value objects
- Modern Pattern Matching - Use
switch expressions and patterns extensively
- Async Everywhere - Prefer async APIs with proper cancellation support
- Zero-Allocation Patterns - Use
Span<T> and Memory<T> for performance-critical code
- API Design - Accept abstractions, return appropriately specific types
- Composition Over Inheritance - Avoid abstract base classes, prefer composition
- 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.
public record CustomerDto(string Id, string Name, string Email);
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;
}
}
public record Order(string Id, decimal Subtotal, decimal Tax)
{
public decimal Total => Subtotal + Tax;
}
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 properties
record 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.
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 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}";
}
public readonly record struct PhoneNumber
{
public string Value { get; }
private PhoneNumber(string value) => Value = value;
public static Result<PhoneNumber, string> Create(string input)
{
if (string.IsNullOrWhiteSpace(input))
return Result<PhoneNumber, string>.Failure("Phone number cannot be empty");
var digits = new string(input.Where(char.IsDigit).ToArray());
if (digits.Length < 10 || digits.Length > 15)
return Result<PhoneNumber, string>.Failure("Phone number must be 10-15 digits");
return Result<PhoneNumber, string>.Success(new PhoneNumber(digits));
}
public override string ToString() => Value;
}
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}%";
}
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
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:
readonly prevents accidental mutation
- Pattern matching: Works seamlessly with switch expressions
CRITICAL: NO implicit conversions. Implicit operators defeat the purpose of value objects by allowing silent type coercion:
public readonly record struct UserId(Guid Value)
{
public static implicit operator UserId(Guid value) => new(value);
public static implicit operator Guid(UserId value) => value.Value;
}
void ProcessUser(UserId userId) { }
ProcessUser(Guid.NewGuid());
public readonly record struct UserId(Guid Value)
{
public static UserId New() => new(Guid.NewGuid());
}
Explicit conversions force every boundary crossing to be visible:
var userId = new UserId(request.UserId);
await _db.ExecuteAsync(sql, new { UserId = userId.Value });
Pattern Matching (C# 8-12)
Leverage modern pattern matching for cleaner, more expressive code.
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"
};
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
};
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))
};
public bool IsValidSequence(int[] numbers) => numbers switch
{
[] => false,
[_] => true,
[var first, .., var last] when first < last => true,
_ => false
};
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"
};
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"
};
public decimal CalculateShipping(Money total, Country destination) => (total, destination) switch
{
({ Amount: > 100m }, _) => 0m,
(_, { Code: "US" or "CA" }) => 5m,
(_, { Code: "GB" or "FR" or "DE" }) => 10m,
_ => 25m
};
Nullable Reference Types (C# 8+)
Enable nullable reference types in your project and handle nulls explicitly.
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
public class UserService
{
public string GetUserName(User user) => user.Name;
public string? FindUserName(string userId)
{
var user = _repository.Find(userId);
return user?.Name;
}
public string GetRequiredConfigValue(string key)
{
var value = Configuration[key];
return value!;
}
public Money? GetAccountBalance(string accountId)
{
var account = _repository.Find(accountId);
return account?.Balance;
}
}
public decimal GetDiscount(Customer? customer) => customer switch
{
null => 0m,
{ IsVip: true } => 0.20m,
{ OrderCount: > 10 } => 0.10m,
_ => 0.05m
};
public string GetDisplayName(User? user) =>
user?.PreferredName ?? user?.Email ?? "Guest";
public void ProcessOrder(Order? order)
{
ArgumentNullException.ThrowIfNull(order);
Console.WriteLine(order.Id);
}
Composition Over Inheritance
Avoid abstract base classes and inheritance hierarchies. Use composition and interfaces instead.
public abstract class PaymentProcessor
{
public abstract Task<PaymentResult> ProcessAsync(Money amount);
protected async Task<bool> ValidateAsync(Money amount)
{
return amount.Amount > 0;
}
}
public class CreditCardProcessor : PaymentProcessor
{
public override async Task<PaymentResult> ProcessAsync(Money amount)
{
await ValidateAsync(amount);
}
}
public interface IPaymentProcessor
{
Task<PaymentResult> ProcessAsync(Money amount, CancellationToken cancellationToken);
}
public interface IPaymentValidator
{
Task<ValidationResult> ValidateAsync(Money amount, CancellationToken cancellationToken);
}
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);
}
}
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();
}
}
public enum PaymentType { CreditCard, BankTransfer, Cash }
public record PaymentMethod
{
public PaymentType Type { get; init; }
public string? Last4 { get; init; }
public string? AccountNumber { get; init; }
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.,
ControllerBase in ASP.NET Core)
- Library integration (e.g., custom exceptions inheriting from
Exception)
- These should be rare cases in your application code
Performance Patterns
Async/Await Best Practices
Always use async for I/O-bound operations:
public async Task<Order> GetOrderAsync(string orderId, CancellationToken cancellationToken)
{
var order = await _repository.GetAsync(orderId, cancellationToken);
var customer = await _customerService.GetCustomerAsync(order.CustomerId, cancellationToken);
return order;
}
public Order GetOrder(string orderId)
{
return _repository.GetAsync(orderId).Result;
}
public ValueTask<Order?> GetCachedOrderAsync(string orderId, CancellationToken cancellationToken)
{
if (_cache.TryGetValue(orderId, out var order))
return ValueTask.FromResult<Order?>(order);
return GetFromDatabaseAsync(orderId, cancellationToken);
}
private async ValueTask<Order?> GetFromDatabaseAsync(string orderId, CancellationToken cancellationToken)
{
var order = await _repository.GetAsync(orderId, cancellationToken);
if (order is not null)
_cache[orderId] = order;
return order;
}
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;
}
}
public async Task<string> ProcessDataAsync(string input, CancellationToken cancellationToken)
{
var data = await FetchDataAsync(cancellationToken).ConfigureAwait(false);
var result = await TransformDataAsync(data, cancellationToken).ConfigureAwait(false);
return result;
}
Always accept CancellationToken:
public async Task<List<Order>> GetOrdersAsync(
string customerId,
CancellationToken cancellationToken = default)
{
var orders = await _repository.GetOrdersByCustomerAsync(customerId, cancellationToken);
return orders;
}
public async Task<OrderSummary> GetOrderSummaryAsync(
string customerId,
CancellationToken cancellationToken = default)
{
var orders = await GetOrdersAsync(customerId, cancellationToken);
var total = orders.Sum(o => o.Total);
return new OrderSummary(customerId, orders.Count, total);
}
public async Task<ProcessResult> ProcessWithTimeoutAsync(
string data,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
return await ProcessAsync(data, cts.Token);
}
Span and Memory for Zero-Allocation Code
Use Span<T> and Memory<T> instead of byte[] or string for performance-critical code.
public int ParseOrderId(ReadOnlySpan<char> input)
{
if (!input.StartsWith("ORD-"))
throw new FormatException("Invalid order ID format");
var numberPart = input.Slice(4);
return int.Parse(numberPart);
}
public void FormatMessage()
{
Span<char> buffer = stackalloc char[256];
var written = FormatInto(buffer);
var message = new string(buffer.Slice(0, written));
}
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));
}
public async Task<int> ReadDataAsync(
Memory<byte> buffer,
CancellationToken cancellationToken)
{
return await _stream.ReadAsync(buffer, cancellationToken);
}
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;
key = new string(line.Slice(0, colonIndex).Trim());
value = new string(line.Slice(colonIndex + 1).Trim());
return true;
}
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);
}
}
[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);
}
}
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);
}
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):
public decimal CalculateTotal(IEnumerable<OrderItem> items)
{
return items.Sum(item => item.Price * item.Quantity);
}
public bool HasMinimumItems(IReadOnlyCollection<OrderItem> items, int minimum)
{
return items.Count >= minimum;
}
public OrderItem GetMiddleItem(IReadOnlyList<OrderItem> items)
{
if (items.Count == 0)
throw new ArgumentException("List cannot be empty");
return items[items.Count / 2];
}
public int Sum(ReadOnlySpan<int> numbers)
{
int total = 0;
foreach (var num in numbers)
total += num;
return total;
}
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:
public IEnumerable<Order> GetOrdersLazy(string customerId)
{
foreach (var order in _repository.Query())
{
if (order.CustomerId == customerId)
yield return order;
}
}
public IReadOnlyList<Order> GetOrders(string customerId)
{
return _repository
.Query()
.Where(o => o.CustomerId == customerId)
.ToList();
}
public List<Order> GetMutableOrders(string customerId)
{
return _repository
.Query()
.Where(o => o.CustomerId == customerId)
.ToList();
}
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;
}
}
public byte[] SerializeOrder(Order order)
{
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
public async Task<Result<Order, OrderError>> CreateOrderAsync(
CreateOrderRequest request,
CancellationToken cancellationToken = default)
{
}
public async Task<List<Order>> GetOrdersAsync(
string customerId,
DateTime? startDate = null,
DateTime? endDate = null,
CancellationToken cancellationToken = default)
{
}
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)
{
}
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);
}
}
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;
}
Error Handling
Result Type Pattern (Railway-Oriented Programming)
For expected errors, use a Result<T, TError> type instead of exceptions.
public readonly record struct Result<TValue, TError>
{
private readonly TValue? _value;
private readonly TError? _error;
private readonly bool _isSuccess;
private Result(TValue value)
{
_value = value;
_error = default;
_isSuccess = true;
}
private Result(TError error)
{
_value = default;
_error = error;
_isSuccess = false;
}
public bool IsSuccess => _isSuccess;
public bool IsFailure => !_isSuccess;
public TValue Value => _isSuccess
? _value!
: throw new InvalidOperationException("Cannot access Value of a failed result");
public TError Error => !_isSuccess
? _error!
: throw new InvalidOperationException("Cannot access Error of a successful result");
public static Result<TValue, TError> Success(TValue value) => new(value);
public static Result<TValue, TError> Failure(TError error) => new(error);
public Result<TOut, TError> Map<TOut>(Func<TValue, TOut> mapper)
=> _isSuccess
? Result<TOut, TError>.Success(mapper(_value!))
: Result<TOut, TError>.Failure(_error!);
public Result<TOut, TError> Bind<TOut>(Func<TValue, Result<TOut, TError>> binder)
=> _isSuccess ? binder(_value!) : Result<TOut, TError>.Failure(_error!);
public TValue GetValueOr(TValue defaultValue)
=> _isSuccess ? _value! : defaultValue;
public TResult Match<TResult>(
Func<TValue, TResult> onSuccess,
Func<TError, TResult> onFailure)
=> _isSuccess ? onSuccess(_value!) : onFailure(_error!);
}
public readonly record struct OrderError(string Code, string Message);
public sealed class OrderService(IOrderRepository repository)
{
public async Task<Result<Order, OrderError>> CreateOrderAsync(
CreateOrderRequest request,
CancellationToken cancellationToken)
{
var validationResult = ValidateRequest(request);
if (validationResult.IsFailure)
return Result<Order, OrderError>.Failure(validationResult.Error);
var inventoryResult = await CheckInventoryAsync(request.Items, cancellationToken);
if (inventoryResult.IsFailure)
return Result<Order, OrderError>.Failure(inventoryResult.Error);
var order = new Order(
OrderId.New(),
new CustomerId(request.CustomerId),
request.Items);
await repository.SaveAsync(order, cancellationToken);
return Result<Order, OrderError>.Success(order);
}
public IActionResult MapToActionResult(Result<Order, OrderError> result)
{
return result.Match(
onSuccess: order => new OkObjectResult(order),
onFailure: error => error.Code switch
{
"VALIDATION_ERROR" => new BadRequestObjectResult(error.Message),
"INSUFFICIENT_INVENTORY" => new ConflictObjectResult(error.Message),
"NOT_FOUND" => new NotFoundObjectResult(error.Message),
_ => new ObjectResult(error.Message) { 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
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);
}
[Fact]
public void CalculateDiscount_LargeOrder_AppliesCorrectDiscount()
{
var baseOrder = new OrderBuilder().Build();
var largeOrder = baseOrder with
{
Total = new Money(1500m, "USD")
};
var discount = _service.CalculateDiscount(largeOrder);
discount.Should().Be(new Money(225m, "USD"));
}
[Theory]
[InlineData("ORD-12345", true)]
[InlineData("INVALID", false)]
public void TryParseOrderId_VariousInputs_ReturnsExpectedResult(
string input,
bool expected)
{
var result = OrderIdParser.TryParse(input.AsSpan(), out var orderId);
result.Should().Be(expected);
}
[Fact]
public void Money_Add_SameCurrency_ReturnsSum()
{
var money1 = new Money(100m, "USD");
var money2 = new Money(50m, "USD");
var result = money1.Add(money2);
result.Should().Be(new Money(150m, "USD"));
}
[Fact]
public void Money_Add_DifferentCurrency_ThrowsException()
{
var usd = new Money(100m, "USD");
var eur = new Money(50m, "EUR");
var act = () => usd.Add(eur);
act.Should().Throw<InvalidOperationException>()
.WithMessage("*different currencies*");
}
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
public record UserDto(string Id, string Name, string Email);
public record UserEntity(Guid Id, string FullName, string EmailAddress);
var dto = _mapper.Map<UserDto>(entity);
Use Explicit Mapping Methods Instead
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);
}
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.
var field = typeof(Order).GetField("_status", BindingFlags.NonPublic | BindingFlags.Instance);
var status = (OrderStatus)field!.GetValue(order)!;
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_status")]
static extern ref OrderStatus GetStatusField(Order order);
var status = GetStatusField(order);
Supported accessor kinds:
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")]
static extern ref List<OrderItem> GetItemsField(Order order);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Recalculate")]
static extern void CallRecalculate(Order order);
[UnsafeAccessor(UnsafeAccessorKind.StaticField, Name = "_instanceCount")]
static extern ref int GetInstanceCount(Order order);
[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:
Anti-Patterns to Avoid
❌ DON'T: Use mutable DTOs
public class CustomerDto
{
public string Id { get; set; }
public string Name { get; set; }
}
public record CustomerDto(string Id, string Name);
❌ DON'T: Use classes for value objects
public class OrderId
{
public string Value { get; }
public OrderId(string value) => Value = value;
}
public readonly record struct OrderId(string Value);
❌ DON'T: Create deep inheritance hierarchies
public abstract class Entity { }
public abstract class AggregateRoot : Entity { }
public abstract class Order : AggregateRoot { }
public class CustomerOrder : Order { }
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 when you mean IReadOnlyList
public List<Order> GetOrders() => _orders;
public IReadOnlyList<Order> GetOrders() => _orders;
❌ DON'T: Use byte[] when ReadOnlySpan works
public byte[] GetHeader()
{
var header = new byte[64];
return header;
}
public void GetHeader(Span<byte> destination)
{
if (destination.Length < 64)
throw new ArgumentException("Buffer too small");
}
❌ DON'T: Forget CancellationToken in async methods
public async Task<Order> GetOrderAsync(OrderId id)
{
return await _repository.GetAsync(id);
}
public async Task<Order> GetOrderAsync(
OrderId id,
CancellationToken cancellationToken = default)
{
return await _repository.GetAsync(id, cancellationToken);
}
❌ DON'T: Block on async code
public Order GetOrder(OrderId id)
{
return GetOrderAsync(id).Result;
}
public Order GetOrder(OrderId id)
{
return GetOrderAsync(id).GetAwaiter().GetResult();
}
public async Task<Order> GetOrderAsync(
OrderId id,
CancellationToken cancellationToken)
{
return await _repository.GetAsync(id, cancellationToken);
}
Code Organization
namespace MyApp.Domain.Orders;
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 });
}
}
public enum OrderStatus
{
Draft,
Submitted,
Processing,
Completed,
Cancelled
}
public record OrderItem(
ProductId ProductId,
Quantity Quantity,
Money UnitPrice
)
{
public Money Total => new(
UnitPrice.Amount * Quantity.Value,
UnitPrice.Currency);
}
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
public readonly record struct OrderError(string Code, string Message);
Best Practices Summary
DO's ✅
- Use
record for DTOs, messages, and domain entities
- Use
readonly record struct for value objects
- Leverage pattern matching with
switch expressions
- Enable and respect nullable reference types
- Use async/await for all I/O operations
- Accept
CancellationToken in all async methods
- Use
Span<T> and Memory<T> for high-performance scenarios
- Accept abstractions (
IEnumerable<T>, IReadOnlyList<T>)
- Return appropriate interfaces or concrete types
- Use
Result<T, TError> for expected errors
- Use
ConfigureAwait(false) in library code
- Pool buffers with
ArrayPool<T> for large allocations
- Prefer composition over inheritance
- Avoid abstract base classes in application code
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[] when Span<byte> suffices
- Don't forget
CancellationToken parameters
- Don't return mutable collections from APIs
- Don't throw exceptions for expected business errors
- Don't use
string concatenation in loops
- Don't allocate large arrays repeatedly (use
ArrayPool)
Additional Resources