| name | dotnet-techne-csharp-type-design-performance |
| description | Use when designing types and collections for hot paths and low-allocation .NET code. Keywords: readonly struct, sealed class, ValueTask, Span, FrozenDictionary, FrozenSet, allocation optimisation. |
| disable-model-invocation | false |
| user-invocable | true |
| metadata | {"author":"Metalnib","version":"1.0.0","trigger_keywords":["readonly struct","sealed class","valuetask","span","frozendictionary","frozenset","allocation optimisation"]} |
Type Design for Performance
When to Use This Skill
Use this skill when:
- Designing new types and APIs
- Reviewing code for performance issues
- Choosing between class, struct, and record
- Working with collections and enumerables
Core Principles
- Seal your types - Unless explicitly designed for inheritance
- Prefer readonly structs - For small, immutable value types
- Prefer static pure functions - Better performance and testability
- Defer enumeration - Don't materialize until you need to
- Return immutable collections - From API boundaries
Seal Classes by Default
Sealing classes enables JIT devirtualization and communicates API intent.
public sealed class OrderProcessor
{
public void Process(Order order) { }
}
public sealed record OrderCreated(OrderId Id, CustomerId CustomerId);
public class OrderProcessor
{
public virtual void Process(Order order) { }
}
Benefits:
- JIT can devirtualize method calls
- Communicates "this is not an extension point"
- Prevents accidental breaking changes
Readonly Structs for Value Types
Structs should be readonly when immutable. This prevents defensive copies.
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
}
public struct Point
{
public int X { get; set; }
public int Y { get; set; }
}
When to Use Structs
| Use Struct When | Use Class When |
|---|
| Small (≤16 bytes typically) | Larger objects |
| Short-lived | Long-lived |
| Frequently allocated | Shared references needed |
| Value semantics required | Identity semantics required |
| Immutable | Mutable state |
Prefer Static Pure Functions
Static methods with no side effects are faster and more testable.
public static class OrderCalculator
{
public static Money CalculateTotal(IReadOnlyList<OrderItem> items)
{
var total = items.Sum(i => i.Price * i.Quantity);
return new Money(total, "USD");
}
}
var total = OrderCalculator.CalculateTotal(items);
Benefits:
- No vtable lookup (faster)
- No hidden state
- Easier to test (pure input → output)
- Thread-safe by design
- Forces explicit dependencies
public class OrderCalculator
{
private readonly ITaxService _taxService;
private readonly IDiscountService _discountService;
public Money CalculateTotal(IReadOnlyList<OrderItem> items)
{
}
}
public static class OrderCalculator
{
public static Money CalculateTotal(
IReadOnlyList<OrderItem> items,
decimal taxRate,
decimal discountPercent)
{
}
}
Don't go overboard - Use instance methods when you genuinely need state or polymorphism.
Defer Enumeration
Don't materialize enumerables until necessary. Avoid excessive LINQ chains.
public IReadOnlyList<Order> GetActiveOrders()
{
return _orders
.Where(o => o.IsActive)
.ToList()
.OrderBy(o => o.CreatedAt)
.ToList();
}
public IReadOnlyList<Order> GetActiveOrders()
{
return _orders
.Where(o => o.IsActive)
.OrderBy(o => o.CreatedAt)
.ToList();
}
public IEnumerable<Order> GetActiveOrders()
{
return _orders
.Where(o => o.IsActive)
.OrderBy(o => o.CreatedAt);
}
Async Enumeration
Be careful with async and IEnumerable:
var results = orders
.Select(async o => await ProcessOrderAsync(o))
.ToList();
await Task.WhenAll(results);
public async IAsyncEnumerable<OrderResult> ProcessOrdersAsync(
IEnumerable<Order> orders,
[EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var order in orders)
{
ct.ThrowIfCancellationRequested();
yield return await ProcessOrderAsync(order, ct);
}
}
var results = await Task.WhenAll(
orders.Select(o => ProcessOrderAsync(o)));
ValueTask vs Task
Use ValueTask for hot paths that often complete synchronously. For real I/O, just use Task.
public ValueTask<User?> GetUserAsync(UserId id)
{
if (_cache.TryGetValue(id, out var user))
{
return ValueTask.FromResult<User?>(user);
}
return new ValueTask<User?>(FetchUserAsync(id));
}
public Task<Order> CreateOrderAsync(CreateOrderCommand cmd)
{
return _repository.CreateAsync(cmd);
}
ValueTask rules:
- Never await a ValueTask more than once
- Never use
.Result or .GetAwaiter().GetResult() before completion
- If in doubt, use Task
Span and Memory for Bytes
Use Span<T> and Memory<T> instead of byte[] for low-level operations.
public static int ParseInt(ReadOnlySpan<char> text)
{
return int.Parse(text);
}
public async Task WriteAsync(ReadOnlyMemory<byte> data)
{
await _stream.WriteAsync(data);
}
public static int ParseInt(string text)
{
return int.Parse(text);
}
Common Span Patterns
ReadOnlySpan<char> span = "Hello, World!".AsSpan();
var hello = span[..5];
Span<byte> buffer = stackalloc byte[256];
var buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
Collection Return Types
Return Immutable Collections from APIs
public IReadOnlyList<Order> GetOrders()
{
return _orders.ToList();
}
private static readonly FrozenDictionary<string, Handler> _handlers =
new Dictionary<string, Handler>
{
["create"] = new CreateHandler(),
["update"] = new UpdateHandler(),
}.ToFrozenDictionary();
public List<Order> GetOrders()
{
return _orders;
}
Internal Mutation is Fine
public IReadOnlyList<OrderItem> BuildOrderItems(Cart cart)
{
var items = new List<OrderItem>();
foreach (var cartItem in cart.Items)
{
items.Add(CreateOrderItem(cartItem));
}
return items;
}
Collection Guidelines
| Scenario | Return Type |
|---|
| API boundary | IReadOnlyList<T>, IReadOnlyCollection<T> |
| Static lookup data | FrozenDictionary<K,V>, FrozenSet<T> |
| Internal building | List<T>, then return as readonly |
| Single item or none | T? (nullable) |
| Zero or more, lazy | IEnumerable<T> |
Frozen Collections Guidance (.NET 8+)
Use FrozenDictionary<TKey,TValue> and FrozenSet<T> for read-mostly lookup data that is built once and queried many times.
using System.Collections.Frozen;
private static readonly FrozenDictionary<string, Handler> Handlers =
new Dictionary<string, Handler>
{
["create"] = new CreateHandler(),
["update"] = new UpdateHandler()
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
private static readonly FrozenSet<string> ReservedWords =
new[] { "if", "else", "for", "while" }.ToFrozenSet(StringComparer.Ordinal);
About "Frozen list"
.NET does not provide FrozenList<T>. For list-like read-mostly data, use:
ImmutableArray<T> when you need value-like immutability semantics
- plain array (
T[]) for minimal overhead and fast iteration
- combine with
FrozenDictionary/FrozenSet when you also need lookup acceleration
using System.Collections.Immutable;
private static readonly ImmutableArray<string> OrderedSteps =
["Parse", "Validate", "Transform", "Persist"];
private static readonly string[] OrderedStepsFast =
{ "Parse", "Validate", "Transform", "Persist" };
Quick Reference
| Pattern | Benefit |
|---|
sealed class | Devirtualization, clear API |
readonly record struct | No defensive copies, value semantics |
| Static pure functions | No vtable, testable, thread-safe |
Defer .ToList() | Single materialization |
ValueTask for hot paths | Avoid Task allocation |
Span<T> for bytes | Stack allocation, no copying |
IReadOnlyList<T> return | Immutable API contract |
FrozenDictionary | Fastest lookup for static data |
Anti-Patterns
public class OrderService { }
public struct Point { public int X; public int Y; }
public int Add(int a, int b) => a + b;
items.Where(...).ToList().OrderBy(...).ToList();
public List<Order> GetOrders();
public ValueTask<Order> CreateOrderAsync();
Resources