$npx -y skills add wshaddix/dotnet-skills --skill csharp-type-design-performanceDesign .NET types for performance. Covers struct vs class decision matrix, sealed by default, readonly structs, ref struct and Span/Memory selection, FrozenDictionary, ValueTask, and collection return types. Use when designing new types and APIs, reviewing code for performance is
| 1 | # Type Design for Performance |
| 2 | |
| 3 | ## When to Use This Skill |
| 4 | |
| 5 | Use this skill when: |
| 6 | - Designing new types and APIs |
| 7 | - Reviewing code for performance issues |
| 8 | - Choosing between class, struct, and record |
| 9 | - Working with collections and enumerables |
| 10 | |
| 11 | ## Core Principles |
| 12 | |
| 13 | 1. **Seal your types** - Unless explicitly designed for inheritance |
| 14 | 2. **Prefer readonly structs** - For small, immutable value types |
| 15 | 3. **Prefer static pure functions** - Better performance and testability |
| 16 | 4. **Defer enumeration** - Don't materialize until you need to |
| 17 | 5. **Return immutable collections** - From API boundaries |
| 18 | |
| 19 | --- |
| 20 | |
| 21 | ## Struct vs Class Decision Matrix |
| 22 | |
| 23 | Choosing between `struct` and `class` at design time has cascading effects on allocation, GC pressure, and API shape. |
| 24 | |
| 25 | ### Decision Criteria |
| 26 | |
| 27 | | Criterion | Favors `struct` | Favors `class` | |
| 28 | |-----------|----------------|----------------| |
| 29 | | Size | Small (<= 16 bytes ideal, <= 64 bytes acceptable) | Large or variable size | |
| 30 | | Lifetime | Short-lived, method-scoped | Long-lived, shared across scopes | |
| 31 | | Identity | Value equality (two instances with same data are equal) | Reference identity matters | |
| 32 | | Mutability | Immutable (`readonly struct`) | Mutable or complex state transitions | |
| 33 | | Inheritance | Not needed | Requires polymorphism or base class | |
| 34 | | Nullable semantics | `default` is a valid zero state | Needs explicit null to signal absence | |
| 35 | | Collection usage | Stored in arrays/spans (contiguous memory) | Stored via references (indirection) | |
| 36 | |
| 37 | ### Size Guidelines |
| 38 | |
| 39 | ``` |
| 40 | <= 16 bytes: Ideal struct -- fits in two registers, passed efficiently |
| 41 | 17-64 bytes: Acceptable struct -- measure copy cost vs allocation cost |
| 42 | > 64 bytes: Prefer class -- copying cost outweighs allocation avoidance |
| 43 | ``` |
| 44 | |
| 45 | ### Common Types and Their Correct Design |
| 46 | |
| 47 | | Type | Correct Choice | Why | |
| 48 | |------|---------------|-----| |
| 49 | | Point2D (8 bytes: two floats) | `readonly struct` | Small, immutable, value semantics | |
| 50 | | Money (16 bytes: decimal + currency) | `readonly struct` | Small, immutable, value equality | |
| 51 | | DateRange (16 bytes: two DateOnly) | `readonly struct` | Small, immutable, value semantics | |
| 52 | | Matrix4x4 (64 bytes: 16 floats) | `struct` (with `in` parameters) | Performance-critical math | |
| 53 | | CustomerDto (variable: strings, lists) | `class` or `record` | Contains references, variable size | |
| 54 | | HttpRequest context | `class` | Long-lived, shared across middleware | |
| 55 | |
| 56 | --- |
| 57 | |
| 58 | ## Sealed by Default |
| 59 | |
| 60 | ### Why Seal Library Types |
| 61 | |
| 62 | For library types (code consumed by other assemblies), seal classes by default: |
| 63 | |
| 64 | 1. **JIT devirtualization** -- sealed classes enable the JIT to replace virtual calls with direct calls, enabling inlining |
| 65 | 2. **Simpler contracts** -- unsealed classes imply a promise to support inheritance |
| 66 | 3. **Fewer breaking changes** -- sealing a class later is a binary-breaking change |
| 67 | |
| 68 | ```csharp |
| 69 | // GOOD -- sealed by default for library types |
| 70 | public sealed class WidgetService |
| 71 | { |
| 72 | public Widget GetWidget(int id) => new(id, "Default"); |
| 73 | } |
| 74 | |
| 75 | // Only unseal when inheritance is an intentional design decision |
| 76 | public abstract class WidgetValidatorBase |
| 77 | { |
| 78 | public abstract bool Validate(Widget widget); |
| 79 | protected virtual void OnValidationComplete(Widget widget) { } |
| 80 | } |
| 81 | ``` |
| 82 | |
| 83 | ### When NOT to Seal |
| 84 | |
| 85 | | Scenario | Reason | |
| 86 | |----------|--------| |
| 87 | | Abstract base classes | Inheritance is the purpose | |
| 88 | | Framework extensibility points | Consumers need to subclass | |
| 89 | | Test doubles in non-mockable designs | Mocking frameworks need to subclass | |
| 90 | | Application-internal classes | Sealing adds no value | |
| 91 | |
| 92 | --- |
| 93 | |
| 94 | ## Readonly Structs |
| 95 | |
| 96 | Mark structs `readonly` when all fields are immutable. This eliminates defensive copies the JIT creates when accessing structs through `in` parameters or `readonly` fields. |
| 97 | |
| 98 | ### The Defensive Copy Problem |
| 99 | |
| 100 | ```csharp |
| 101 | // NON-readonly struct -- JIT must defensively copy on every method call |
| 102 | public struct MutablePoint |
| 103 | { |
| 104 | public double X; |
| 105 | public double Y; |
| 106 | public double Length() => Math.Sqrt(X * X + Y * Y); |
| 107 | } |
| 108 | |
| 109 | public double GetLength(in MutablePoint point) |
| 110 | { |
| 111 | return point.Length(); // Hidden copy here! |
| 112 | } |
| 113 | ``` |
| 114 | |
| 115 | ```csharp |
| 116 | // GOOD -- readonly struct: JIT knows no mutation is possible |
| 117 | public readonly struct ImmutablePoint |
| 118 | { |
| 119 | public double X { get; } |
| 120 | public double Y { get; } |
| 121 | |
| 122 | public ImmutablePoint(double x, double y) => (X, Y) = (x, y); |
| 123 | |
| 124 | public double Length() => Math.Sqrt(X * X + Y * Y); |
| 125 | } |
| 126 | |
| 127 | public double GetLength(in ImmutablePoint point) |
| 128 | { |
| 129 | return point.Length(); // No copy, direct call |
| 130 | } |
| 131 | ``` |
| 132 | |
| 133 | ### Readonly Struct Checklist |
| 134 | |
| 135 | - All fields are `readonly` or `{ get; }` / `{ get; in |