Design .NET types for performance. Seal classes, use readonly structs, prefer static pure functions, avoid premature enumeration, and choose the right…
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.
// DO: Seal classes not designed for inheritance
public sealed class OrderProcessor
{
public void Process(Order order) { }
}
// DO: Seal records (they're classes)
public sealed record OrderCreated(OrderId Id, CustomerId CustomerId);
// DON'T: Leave unsealed without reason
public class OrderProcessor // Can be subclassed - intentional?
{
public virtual void Process(Order order) { } // Virtual = slower
}
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.
// DO: Readonly struct for immutable value types
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
// DO: Readonly struct for small, short-lived data
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
}
// DON'T: Mutable struct (causes defensive copies)
public struct Point // Not readonly!
{
public int X { get; set; } // Mutable!
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.
// DO: Static pure function
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");
}
}
// Usage - predictable, testable
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
// DON'T: Instance method hiding dependencies
public class OrderCalculator
{
private readonly ITaxService _taxService; // Hidden dependency
private readonly IDiscountService _discountService; // Hidden dependency
public Money CalculateTotal(IReadOnlyList<OrderItem> items)
{
// What does this actually depend on?
}
}
// BETTER: Explicit dependencies via parameters
public static class OrderCalculator
{
public static Money CalculateTotal(
IReadOnlyList<OrderItem> items,
decimal taxRate,
decimal discountPercent)
{
// All inputs visible
}
}
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.
// BAD: Premature materialization
public IReadOnlyList<Order> GetActiveOrders()
{
return _orders
.Where(o => o.IsActive)
.ToList() // Materialized!
.OrderBy(o => o.CreatedAt) // Another iteration
.ToList(); // Materialized again!
}
// GOOD: Defer until the end
public IReadOnlyList<Order> GetActiveOrders()
{
return _orders
.Where(o => o.IsActive)
.OrderBy(o => o.CreatedAt)
.ToList(); // Single materialization
}
// GOOD: Return IEnumerable if caller might not need all items
public IEnumerable<Order> GetActiveOrders()
{
return _orders
.Where(o => o.IsActive)
.OrderBy(o => o.CreatedAt);
// Caller decides when to materialize
}
Async Enumeration
Be careful with async and IEnumerable:
// BAD: Async in LINQ - hidden allocations
var results = orders
.Select(async o => await ProcessOrderAsync(o)) // Task per item!
.ToList();
await Task.WhenAll(results);
// GOOD: Use IAsyncEnumerable for streaming
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);
}
}
// GOOD: Batch processing for parallelism
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.
// DO: ValueTask for cached/synchronous paths
public ValueTask<User?> GetUserAsync(UserId id)
{
if (_cache.TryGetValue(id, out var user))
{
return ValueTask.FromResult<User?>(user); // No allocation
}
return new ValueTask<User?>(FetchUserAsync(id));
}
// DO: Task for real I/O (simpler, no footguns)
public Task<Order> CreateOrderAsync(CreateOrderCommand cmd)
{
// This always hits the database
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.
// DO: Accept Span for synchronous operations
public static int ParseInt(ReadOnlySpan<char> text)
{
return int.Parse(text);
}
// DO: Accept Memory for async operations
public async Task WriteAsync(ReadOnlyMemory<byte> data)
{
await _stream.WriteAsync(data);
}
// DON'T: Force array allocation
public static int ParseInt(string text) // String allocated
{
return int.Parse(text);
}
Common Span Patterns
// Slice without allocation
ReadOnlySpan<char> span = "Hello, World!".AsSpan();
var hello = span[..5]; // No allocation
// Stack allocation for small buffers
Span<byte> buffer = stackalloc byte[256];
// Use ArrayPool for larger buffers
var buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
// Use buffer...
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
Collection Return Types
Return Immutable Collections from APIs
// DO: Return immutable collection
public IReadOnlyList<Order> GetOrders()
{
return _orders.ToList(); // Caller can't modify internal state
}
// DO: Use frozen collections for static data (.NET 8+)
private static readonly FrozenDictionary<string, Handler> _handlers =
new Dictionary<string, Handler>
{
["create"] = new CreateHandler(),
["update"] = new UpdateHandler(),
}.ToFrozenDictionary();
// DON'T: Return mutable collection
public List<Order> GetOrders()
{
return _orders; // Caller can modify!
}
Internal Mutation is Fine
public IReadOnlyList<OrderItem> BuildOrderItems(Cart cart)
{
var items = new List<OrderItem>(); // Mutable internally
foreach (var cartItem in cart.Items)
{
items.Add(CreateOrderItem(cartItem));
}
return items; // Return as IReadOnlyList
}
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>
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
// DON'T: Unsealed class without reason
public class OrderService { } // Seal it!
// DON'T: Mutable struct
public struct Point { public int X; public int Y; } // Make readonly
// DON'T: Instance method that could be static
public int Add(int a, int b) => a + b; // Make static
// DON'T: Multiple ToList() calls
items.Where(...).ToList().OrderBy(...).ToList(); // One ToList at end
// DON'T: Return List<T> from public API
public List<Order> GetOrders(); // Return IReadOnlyList<T>
// DON'T: ValueTask for always-async operations
public ValueTask<Order> CreateOrderAsync(); // Just use Task
Resources
Performance Best Practices: https://learn.microsoft.com/en-us/dotnet/standard/performance/
Span Guidance: https://learn.microsoft.com/en-us/dotnet/standard/memory-and-spans/
Frozen Collections: https://learn.microsoft.com/en-us/dotnet/api/system.collections.frozen
1d:[don't have the plugin yet? install it then click "run inline in claude" again.
added explicit inputs, numbered procedure steps with inputs/outputs, decision points covering sealed classes/structs/static methods/materialization/valuetask/span/collections, output contract defining success properties, and outcome signal for verifying effectiveness. preserved all original code examples and domain logic.
this skill teaches you how to design .NET types for maximum performance. use it when building new APIs, reviewing code for perf bottlenecks, choosing between class/struct/record, or working with collections and enumerables. the core moves are: seal classes by default, use readonly structs for immutable value types, prefer static pure functions, defer enumeration until necessary, and return immutable collections from API boundaries. follow these patterns and you'll eliminate vtable lookups, defensive copies, hidden allocations, and premature materializations.
no external connections required. all guidance applies to local .NET compilation and runtime behavior.
identify type intent: determine if you are designing a class, struct, or record. ask: is this a reference type with identity semantics, or a value type with value semantics? write the answer down.
apply sealing rule to classes: if the type is a class and is not explicitly designed as an extension point, mark it sealed. sealing enables JIT devirtualization and signals intent.
sealed class X { } or documented reason why it's unsealedconvert immutable value types to readonly structs: if the type is small (≤16 bytes), short-lived, and immutable, use readonly record struct or public readonly struct with init-only properties.
convert instance methods to static pure functions where possible: for any method with no side effects and no dependency on instance state, move it to a static class method. pass dependencies as parameters.
audit enumeration and materialization: scan LINQ chains for multiple .ToList() or .ToArray() calls. consolidate to a single materialization at the end. if the caller might not need all items, return IEnumerable<T> instead of a materialized list.
.ToList() at end, or return IEnumerable<T> if lazy evaluation is acceptablechoose collection return type for API boundary: if returning from a public method, use IReadOnlyList<T>, IReadOnlyCollection<T>, or (for static lookup data) FrozenDictionary<K, V> or FrozenSet<T>. never return mutable List<T>.
apply ValueTask only for hot paths: use ValueTask<T> only for methods that synchronously complete most of the time (e.g., cache hits). for real I/O, use Task<T> to avoid ValueTask pitfalls.
ValueTask<T> or Task<T> with rationaleuse Span and Memory for byte-level operations: accept ReadOnlySpan<char> for synchronous parsing and ReadOnlyMemory<byte> for async I/O. avoid forcing array allocations.
stackalloc or ArrayPool for buffersdocument mutable internals vs immutable boundaries: it is fine to use List<T> internally while building a result. convert to IReadOnlyList<T> or IEnumerable<T> before returning.
if designing a class and no explicit polymorphism requirement: seal it. if you find a legitimate reason to inherit later, remove the seal then. default to sealed.
if the type is small, immutable, and allocated frequently: use readonly struct. if it is larger than 16 bytes or mutable, use class.
if an instance method has no instance state dependency and only transforms inputs: convert it to static. if it needs instance state or polymorphic dispatch, keep it instance.
if a LINQ chain has multiple materializations: collapse to a single .ToList() at the outermost boundary. if the caller might iterate only partially, return IEnumerable<T> and let them decide.
if the method sometimes completes synchronously and sometimes awaits: use ValueTask<T> with profile data showing ≥50% synchronous completion. otherwise use Task<T> for simplicity.
if the method always performs I/O (database, network, file): use Task<T>, not ValueTask<T>. the allocation overhead is negligible compared to I/O latency.
if accepting array or string data in low-level code: accept ReadOnlySpan<T> or ReadOnlyMemory<T> instead. this avoids forcing callers to allocate.
if building a collection internally then returning it: mutate a List<T> internally. return as IReadOnlyList<T>, IEnumerable<T>, or IReadOnlyCollection<T> to prevent external mutation.
if data is static and looked up frequently: use FrozenDictionary<K, V> or FrozenSet<T> (.NET 8+). initialize once, freeze, and enjoy faster reads.
if async enumeration is needed: use IAsyncEnumerable<T> with [EnumeratorCancellation] CancellationToken for streaming. if you need parallelism, batch with Task.WhenAll().
success means your code exhibits these properties:
sealedreadonly record struct or public readonly struct.ToList() calls)IReadOnlyList<T>, IReadOnlyCollection<T>, IEnumerable<T>, or frozen collectionsValueTask<T> only for hot paths with ≥50% synchronous completion; otherwise Task<T>Span<T> and Memory<T> instead of arrays or stringsno file output required. these are code-level changes verified by compilation and code review.
you know this skill worked when: