Writing Custom Structs in C#: Performance, Boxing, and Layout Considerations

If you’ve been writing C# for a while, you’ve almost certainly seen struct used beside class and thought, “Okay… when exactly do I pick one over the other?” That’s a sensible question. Structs are value types, which sounds simple - and it is - until you hit the subtle places where copying, boxing, and lifetime change how your code behaves.

In this article we’ll fit those details into practical advice you can use in real projects: when to choose a struct, when to avoid it, and how to design them safely. We’ll keep things hands-on. Expect clear examples, short asides like “you might notice…”, and a few of the gotchas that make engineers scratch their heads later. If you’re implementing small domain types (think Point, Money, or a tiny Range), this is for you.

Quick Intuition: What a Struct Really Is

A struct is a value type. Assigning one struct variable to another copies the whole value. Contrast that with classes - assigning a class copies a reference. This difference is small in wording but large in consequences for semantics and performance.

public struct Point
{
    public int X;
    public int Y;
    public Point(int x, int y) { X = x; Y = y; }
}

var a = new Point(1, 2);
var b = a;   // full copy
b.X = 10;
Console.WriteLine(a.X); // 1
Console.WriteLine(b.X); // 10

That copy behavior is the hallmark of value semantics. It’s great when you want independent copies; it’s confusing when you expect shared, mutable state.

When to Prefer a Struct (Real-World Rules of Thumb)

  • Small, logically single values: Point, Color, small Money.
  • Immutable data where copying is cheap.
  • You want inline storage (arrays of structs are stored contiguously).
  • High-frequency, allocation-sensitive code (e.g., game engines, numeric libraries).

And when to avoid them:

  • Large objects (copying dozens or hundreds of bytes is expensive).
  • Shared mutable state - mutable structs often produce bugs.
  • When inheritance is required (structs can’t be base classes).

Keep Them Small - A Practical Size Guideline

A pragmatic guideline is to prefer structs that are small (think ≤ ~16 bytes) if they’ll be used heavily, especially inside arrays or spans. This is a heuristic - not a hard rule. The real advice is: measure in your actual workload. Copying costs can outstrip allocation costs for large structs.

Immutability: Your Friend for Safe Structs

Mutability plus implicit copies is a recipe for confusion. Prefer immutable, or at least readonly structs whenever possible. The compiler will help you avoid accidental mutation and sometimes enable optimizations.

public readonly struct Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public Money Add(Money other)
    {
        if (Currency != other.Currency) throw new InvalidOperationException("Currency mismatch");
        return new Money(Amount + other.Amount, Currency);
    }
}

Small aside: making a struct readonly communicates intent clearly - you’re declaring it’s a value with no in-place mutation.

The Pesky Default Value Problem

All value types have a default value - it’s the zeroed state. That means a freshly-defaulted struct will have all fields set to their default values (e.g., numbers = 0, references = null). If 0 is an invalid value for your domain, guard against it - constructors don’t run for default.

Boxing: When the Struct Loses Its Advantage

When you treat a struct as an object or a non-generic interface, the runtime boxes it: the struct is copied into a heap-allocated object. That defeats the allocation advantage.

object o = new Point(1,2); // boxing occurs
IFormattable f = new Point(1,2); // boxing if using non-generic interface

Tip: prefer generic collections (List<T>) and typed interfaces (like IEquatable<T>) to avoid boxing in hotspots.

Arrays, Spans, and Memory Layout - The Performance Win

An array of structs stores values inline and contiguously, which improves cache locality and can dramatically reduce allocations compared to arrays of reference types. This is why game engines and numeric code often favor structs for tiny value types.

Point[] pts = new Point[1000]; // 1000 Points stored inline - cache friendly

ref, in and Avoiding Heavy Copies

Passing a struct to a method copies it by default. For larger structs you can use:

  • ref - pass by reference and allow mutation.
  • in - pass by readonly reference (no mutation allowed; avoids copying).
public void Process(in BigStruct s) // no copy, readonly reference
{
    // can read fields, can't modify them
}

Use in when you want to avoid copies but keep the callee from mutating the caller’s data.

Interfaces and Potential Boxing

When a struct implements an interface, calling the interface method through an interface-typed variable can cause boxing unless the interface usage is generic. Implement IEquatable<T> for efficient typed equality checks.

public readonly struct Vector2 : IEquatable
{
    public float X { get; }
    public float Y { get; }

    public Vector2(float x, float y) { X = x; Y = y; }

    public bool Equals(Vector2 other) => X == other.X && Y == other.Y;
}

If you write IEquatable<Vector2> eq = new Vector2(...) in non-generic contexts, beware of boxing. Use typed comparisons when possible.

Common Gotchas (Read These Twice)

  • Mutating a struct returned from a property: properties return copies - mutating the returned struct doesn’t change the original.
  • Mutable struct as dictionary key: changing a struct after inserting it into a hash-based collection breaks lookups.
  • Closures and captures: capturing a struct in a lambda can capture a copy, which may be surprising when mutation is involved.
  • Boxing in collections: storing structs in non-generic collections (e.g., ArrayList) will box them.

Equality, Operators and GetHashCode

If your struct will be used in equality checks or as keys, implement IEquatable<T> and override Equals(object) and GetHashCode(). Prefer immutability to avoid hash-code changes after insertion.

public readonly struct Vector2 : IEquatable
{
    public readonly float X, Y;
    public Vector2(float x, float y) { X = x; Y = y; }
    public bool Equals(Vector2 other) => X == other.X && Y == other.Y;
    public override bool Equals(object? obj) => obj is Vector2 v && Equals(v);
    public override int GetHashCode() => HashCode.Combine(X, Y);
    public static bool operator ==(Vector2 a, Vector2 b) => a.Equals(b);
    public static bool operator !=(Vector2 a, Vector2 b) => !a.Equals(b);
}

Interop: Struct Layout for Native Calls

Structs are the natural shape for P/Invoke. You can control layout using [StructLayout]. Use LayoutKind.Sequential most of the time; use LayoutKind.Explicit and [FieldOffset] only when you need precise control.

[StructLayout(LayoutKind.Sequential)]
public struct NativePoint
{
    public int X;
    public int Y;
}

ref struct and Stack-Only Types

C# has ref struct (e.g., Span<T>) which can’t be boxed or captured by the heap. These are for low-level memory scenarios - useful, but advanced.

Record Structs and Modern Conveniences

Newer C# supports record struct, which reduces boilerplate while providing value semantics and structural equality. Handy when you want quick, immutable value types.

public readonly record struct ImmutablePoint(int X, int Y);

A Practical Example: Range

Here’s a concise, practical struct: small, immutable, and conceptually a single value.

public readonly struct Range
{
    public int Start { get; }
    public int Length { get; }
    public int End => Start + Length;

    public Range(int start, int length)
    {
        if (start < 0 || length < 0) throw new ArgumentOutOfRangeException();
        Start = start;
        Length = length;
    }

    public bool Contains(int index) => index >= Start && index < End;
    public override string ToString() => $"{Start}..{End - 1}";
}

This is the kind of struct that makes sense: tiny, immutable, and clearly a value concept.

A Short Checklist Before Making a Type a Struct

  • Is it small (cheap to copy)?
  • Is it logically a single value?
  • Can it be made immutable?
  • Will it be used in arrays or spans a lot?
If you answered “yes” to most of those, a struct is a strong candidate. If not, favor a class. Remember - clarity often beats micro-optimizations.

Performance: Measure, Don’t Guess

Structs can offer big wins in allocation-heavy scenarios, but they can also slow things down if you copy large structs frequently. Use real-world benchmarks (BenchmarkDotNet) on your workload before making sweeping changes.

Arrays of small structs are often more cache-friendly than arrays of references, which can lead to surprising throughput gains in numeric or game code.

Common Mistakes Even Experienced Devs Make

  • Returning a mutable struct from a property and mutating the returned copy.
  • Using mutable structs as dictionary keys (hash breakage).
  • Boxing structs by storing them in non-generic collections or casting to object.
  • Assuming constructors run for default(T) - they don’t.

Practical Tips from Experience

  • Prefer readonly struct for immutability and clearer intent.
  • Avoid public mutable fields; use properties or readonly fields instead.
  • Implement IEquatable<T> for typed equality and to avoid boxing.
  • Use in parameters to avoid copying without allowing mutation.
  • When in doubt, prefer the option that makes the API simpler and less error-prone.
Default to clarity. If a class simplifies your design and avoids subtle copy bugs, it’s often the better choice - even if the struct might be slightly faster in theory.

Summary

Structs are a tool, not a silver bullet. They give you value semantics and allocation advantages when used correctly - small, immutable, conceptually single-value types. Watch for implicit copies, boxing, and default-state surprises. Keep structs small, prefer immutability, implement typed equality, and always measure performance in real scenarios before optimizing.

If you remember one thing: prefer immutability and keep structs small. That will avoid most of the pitfalls and keep your code both fast and maintainable.