Chapter 4: Immutable design

Modified

September 1, 2026

Software development holds a truth that sounds almost contradictory: the harder your data is to change, the easier your software tends to be to maintain.

In traditional object-oriented programming, we often assume that “state changes” are both normal and desirable. However, this flexibility is also a common source of bugs: when an object’s state can be modified by any piece of code at any time, tracking down the root cause of a problem becomes much harder.

Imagine you put a document in a shared company folder and anyone can casually edit a few words. Can you still trust what the document says? That’s the chaos of mutable shared state.

Immutable design flips the default: once an object is created, you don’t modify it. If you need a different state, you create a new object.

This chapter explains how C# supports immutable design: we’ll start with the foundational choice between struct and class, then move on to modern C# features built for immutability, such as record types and init accessors.

4.1 Why immutable design?

Before we dive into syntax, let’s examine what mutable state breaks—and what immutability fixes.

Consider the following seemingly harmless code:

public class Customer
{
    public string Name { get; set; }
    public decimal Balance { get; set; }
}

public void ProcessPayment(Customer customer)
{
    customer.Balance -= 100;  // Directly mutates the passed-in object
}

This code has several potential issues:

  1. Hidden side effects: calling ProcessPayment mutates the passed-in customer, but the method signature doesn’t reveal this behavior. If callers don’t expect that side effect, they may later read data that has already been changed, leading to bugs.
  2. Hard to trace: if Balance is wrong, you must search the entire codebase for every place that might mutate it.
  3. Not thread-safe: multiple threads mutating the same object can cause race conditions.

Benefits of immutable design

Immutability—“no modification after creation”—brings these benefits:

  • Predictability: An object’s state doesn’t silently change behind your back, making code easier to reason about.
  • Thread safety: Immutable objects are naturally thread-safe because there is no mutation.
  • Fewer side effects: Functions don’t mutate their inputs, avoiding hidden behavior.
  • Better caching: Immutable objects can be cached and reused safely because they never change (for example, string interning, configuration objects).

Additional notes

  • String interning (string pool): the .NET runtime can store identical string literals (like "Hello") as a single shared instance to avoid duplicate allocations. See System.String.Intern.
  • Configuration objects: application settings (connection strings, endpoints, etc.). When immutable, they can be safely shared across the entire application. See Options pattern in .NET.

4.2 Struct vs. class: choosing the right type

In C#, struct and class are the two foundational user-defined types. A struct is a value type; a class is a reference type. They look similar—both can define properties, methods, and constructors—but their runtime behavior and memory semantics differ significantly. Understanding that difference is the first step to designing good types.

Value types vs. reference types

A core difference between value types and reference types is how they behave in memory. Consider the following example:

// struct is a value type
struct Point
{
    public int X;
    public int Y;
}

// class is a reference type
class Customer
{
    public string Name { get; set; }
}

Here we define Point as a value type and Customer as a reference type. Now compare how they behave:

// Value-type behavior: copy the entire value
Point p1 = new Point { X = 10, Y = 20 };
Point p2 = p1;  // p2 is a full copy of p1
p2.X = 100;     // only p2 is modified
Console.WriteLine(p1.X);  // prints 10 (p1 is unchanged)

// Reference-type behavior: copy the reference
Customer c1 = new Customer { Name = "Alice" };
Customer c2 = c1;  // c2 and c1 point to the same object
c2.Name = "Bob";   // mutates that same object
Console.WriteLine(c1.Name);  // prints "Bob" (c1 changed too)

This clearly shows the difference:

  • A value type (struct) is like a business card: you hand a card to a friend; if they scribble on it, your own copy remains untouched in your wallet.
  • A reference type (class) is like a shared document link: you share a link; if they edit the document, those changes are instantly visible to you too, because you’re both pointing to the same underlying content.

When should you use struct?

Use struct when:

  1. It’s a small data structure: Coordinates, RGB colors, time ranges, dictionary keys or key-value pairs, etc.
  2. It’s immutable: State does not change after creation.
  3. You don’t need polymorphism: No need for inheritance.

Here’s a typical example:

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("Currencies do not match.");

        return new Money(Amount + other.Amount, Currency);
    }
}

This Money struct represents an immutable monetary amount with two core pieces of information: Amount and Currency. This design keeps calculations safe—for example, Add prevents you from accidentally mixing currencies.

Note the readonly struct declaration on line 1: in this Money example, it ensures the struct’s state does not change after construction. Add returns a new Money value instead of mutating the existing one. However, keep in mind that readonly struct only guarantees that its fields cannot be reassigned; if a field refers to a mutable reference type, that still doesn’t imply deep immutability. For example:

public readonly struct Order
{
    public List<string> Items { get; }

    public Order(List<string> items) => Items = items;
}

var order = new Order(new List<string> { "A" });
order.Items.Add("B");  // Compiles fine! The Items reference didn't change, but its contents did.

readonly struct prevents you from pointing Items at a different List<string>, but it cannot stop you from calling Add on that list. For true deep immutability, use immutable collections (see Section 4.9).

When should you use class?

Use class when your type does not meet the criteria for a struct:

  1. You need inheritance.
  2. Instances are large: copying would be expensive.
  3. You need reference semantics: multiple variables should refer to the same instance.
  4. You need lifecycle tracking: creation/disposal patterns matter.

Common mistake: mutable struct

A common bad design is making a struct mutable. For example:

// ✗ Not recommended: a mutable struct
public struct MutablePoint
{
    public int X;
    public int Y;

    public void Move(int dx, int dy)
    {
        X += dx;
        Y += dy;
    }
}

This is a classic anti-pattern. When you’re accustomed to structs being value types, this mutability can lead to confusing behavior or unexpected consequences, as shown in the following example:  

MutablePoint p = new MutablePoint { X = 10, Y = 20 };
p.Move(5, 5);  // p becomes (15, 25)

// But...
MutablePoint[] points = new MutablePoint[1];
points[0] = new MutablePoint { X = 10, Y = 20 };
points[0].Move(5, 5);  // With arrays, this mutates the element
Console.WriteLine($"{points[0].X}, {points[0].Y}"); // "15, 25"

// And here's an even more confusing case
IList<MutablePoint> list =
    new List<MutablePoint> { new MutablePoint { X = 10, Y = 20 } };
list[0].Move(5, 5);  // This mutates a copy (then losing it)
Console.WriteLine($"{list[0].X}, {list[0].Y}"); // "10, 20"

With arrays, calling a method on an indexed element can mutate the element because the array indexer returns a reference to the element itself. But when you access a generic list through the IList<T> interface, the indexer returns a temporary copy of the struct, so Move mutates that copy while the original element in the list remains unchanged. This is a classic “silent failure” that’s incredibly painful to debug.

Source code: DemoMutablePoint

Here’s another case that often surprises people:

// ... (continuing the previous example)
points[0].X = 99;  // Compiles and runs fine

list[0].X = 99; // Compilation fails. Example compiler message:
// Cannot modify the return value of 'IList<MutablePoint>.this[int]' because it is not a variable

To avoid these issues, structs should almost always be immutable. Use readonly struct to have the compiler enforce that.

readonly struct: compiler-enforced immutability

readonly struct ensures that all fields of the struct are treated as read-only. This not only communicates intent but also enables further compiler optimizations:

readonly struct Point
{
    public readonly int X;
    public readonly int Y;

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

If you try to define a mutable field or write to a field inside a readonly struct, the compiler will produce an error. You can also mark specific methods as readonly, which means the method won’t mutate any fields:

struct Point
{
    public int X;
    public int Y;

    // A readonly method guarantees it won't mutate fields
    public readonly double GetDistance()
    {
        return Math.Sqrt(X * X + Y * Y);
    }

    // This method can mutate fields
    public void Reset()
    {
        X = Y = 0;
    }
}

Source code: DemoReadonlyStruct

Related Microsoft docs: The struct type, readonly (C# reference)

At this point, designing a readonly struct looks straightforward. But there’s still a hidden performance trap: if you don’t understand the compiler’s behavior, you can pay an extra cost without realizing it.

Defensive copies: a hidden performance trap

When you mix readonly and non-readonly methods on a struct, the compiler may introduce defensive copies. That can become a significant performance issue, and it’s easy to overlook.

What is a defensive copy?

If a readonly method calls a non-readonly method, the compiler can’t be sure the callee won’t mutate the struct. To preserve the readonly promise, the compiler takes a conservative approach: it copies the struct (a defensive copy) and calls the non-readonly method on that copy.

It’s like borrowing a rare book (readonly) and needing to highlight passages (call a method). To protect the original, the librarian makes a photocopy (defensive copy). You mark up the copy (mutate state), and the copy is thrown away afterward.

The original stays safe—but you paid the cost of copying.

Consider the following example:

struct Point
{
    public int X;
    public int Y;

    // readonly method
    public readonly void PrintInfo()
    {
        Console.WriteLine($"Point: ({X}, {Y})");
        LogState();  // This triggers a defensive copy, and the compiler will usually warn with CS8656
    }

    // Non-readonly method (even if it doesn't actually mutate state)
    private void LogState()
    {
        Console.WriteLine($"Current state: X={X}, Y={Y}");
    }
}

When PrintInfo calls LogState, even though LogState doesn’t mutate anything, the compiler will still:

  1. Copy the Point value.
  2. Call LogState on the copy.
  3. Discard the copy (because PrintInfo is readonly).

Source code: DemoReadonlyMethod

A note on readonly fields and in parameters

Defensive copies don’t happen only when a readonly method calls a non-readonly method.

More precisely, whenever a struct instance is in a readonly context and you call a non-readonly instance member on it, the compiler first creates a copy and performs the call on that copy in order to preserve readonly semantics.

A “readonly context” is any location where the compiler can statically determine that the instance cannot be reassigned—in other words, you cannot write myStruct = newValue; in that context. readonly fields and in parameters are common examples.

For example, in the following code, Value may look like a simple read, but accessing a property still means calling an instance member. If that member isn’t marked readonly, then accessing it through a readonly field or an in parameter will trigger a defensive copy:

public struct Measurement
{
    private int _value;

    // Getter not marked readonly
    public int Value
    {
        get { return _value; }
    }

    public Measurement(int value)
    {
        _value = value;
    }
}

public class Holder
{
    public readonly Measurement Data;

    public Holder(Measurement data)
    {
        Data = data;   // Ordinary value copy, not a defensive copy
    }

    public void Exec()
    {
        var value = Data.Value;   // Triggers a defensive copy
    }

    public static void Exec(in Measurement data)
    {
        Console.WriteLine(data.Value);   // Also triggers a defensive copy
    }
}

In this example, the constructor parameter data in Holder is an ordinary by-value parameter, and Data = data; is also just an ordinary value copy. Neither of them is a temporary copy created to preserve readonly semantics, so they do not count as defensive copies.

If you explicitly mark the getter for Value as readonly, like this:

    public readonly int Value
    {
        get { return _value; }
    }

or if you declare the entire Measurement type as a readonly struct, then the compiler no longer needs to create a defensive copy in this case.

Source code: DemoDefensiveCopy

Further reading

In Microsoft’s article readonly instance members under Structure types (C# reference), it says that when a readonly instance member calls a non-readonly member, the compiler creates a copy of the struct and calls the non-readonly member on that copy, so the original instance is not modified.

That explains the core mechanism behind defensive copies. readonly fields and in parameters are the same principle applied in other readonly contexts.

How can you verify whether a defensive copy occurred?

The first method is to look for compiler warnings. When a readonly member calls a non-readonly member, the compiler will often emit warning CS8656, indicating that the call creates an implicit copy of this. It’s best to use dotnet build -t:Rebuild so the project is actually recompiled and you don’t miss warnings because of incremental build behavior.

However, note this carefully: the absence of a warning does not mean the absence of a defensive copy. For example, when you call a non-readonly instance member through a readonly field or an in parameter, a defensive copy still occurs even if the compiler doesn’t report it. This is because CS8656 only diagnoses patterns the compiler detects directly inside a method body; for defensive copies that arise indirectly through fields or parameters, the C# design treats detection as the caller’s responsibility, and not all such paths have a corresponding diagnostic rule.

The second method is to create a test with an observable side effect. For example, add a non-readonly method to the struct that mutates a field:

public int IncrementAndGet()
{
    _value++;
    return _value;
}

If you call it twice on a normal variable, the result will keep increasing. But if you call it through a readonly field or an in parameter and get the same result both times, that strongly suggests each call operated on a fresh copy and the original state was not preserved.

For example:

var s = new Measurement(10);

Console.WriteLine(s.IncrementAndGet());  // 11
Console.WriteLine(s.IncrementAndGet());  // 12

Both calls here operate on the same local variable s, so the second call continues from the state modified by the first one.

By contrast, if you call it through a readonly field:

var holder = new Holder(new Measurement(10));

Console.WriteLine(holder.Data.IncrementAndGet());  // 11
Console.WriteLine(holder.Data.IncrementAndGet());  // 11

Here, Data is a readonly field, so each call to IncrementAndGet() is performed on a fresh copy created by the compiler. The original holder.Data is never mutated. You will observe the same behavior if you use an in parameter instead.

The third method is to inspect the IL (Intermediate Language). This is the most precise approach, because you can directly see whether the compiler generated extra copy operations. If you want complete implementation-level confirmation, you can inspect the IL with decompilation tools such as SharpLab or ILSpy.

Performance impact

If the struct is large—or this happens frequently—the cost adds up:

struct LargeStruct
{
    // Imagine this struct has many fields
    public int Field1;
    public int Field2;
    // ... many more fields

    public readonly void Process()
    {
        // Calling Helper copies the entire struct!
        Helper();
    }

    private void Helper()  // Forgot to add readonly
    {
        // Doesn't actually mutate fields
        Console.WriteLine("Processing...");
    }
}

If LargeStruct contains many fields, the implicit copy on every call can be pure waste.

Note that this example intentionally uses a regular struct, not a readonly struct. If the entire type were declared as readonly struct, its instance members (except constructors) would be implicitly treated as readonly, and this Helper example wouldn’t trigger a defensive copy.

How do you avoid it?

The fix is simple: mark methods that don’t mutate state as readonly:

struct Point
{
    public int X;
    public int Y;

    public readonly void PrintInfo()
    {
        Console.WriteLine($"Point: ({X}, {Y})");
        LogState();  // ✓ No defensive copy
    }

    // Explicitly mark as readonly
    private readonly void LogState()
    {
        Console.WriteLine($"Current state: X={X}, Y={Y}");
    }
}

Now the entire call chain is readonly, so you avoid hidden defensive copies.

Best practice

Explicitly add readonly to every struct method that doesn’t mutate state. It avoids defensive copies and makes your intent clear.

Related Microsoft docs: readonly (C# reference), IDE0251: Member can be made ‘readonly’

ref struct: stack-only types

C# 7.2 introduced ref struct. The opening sentence of the Microsoft documentation, ref structure types (C# reference), describes it like this:

Use the ref modifier when declaring a structure type. You allocate instances of a ref struct type on the stack, and they can’t escape to the managed heap.

In other words, an instance of a ref struct is itself subject to stack-only restrictions and cannot escape to the managed heap.

You rarely need to define your own ref struct, but understanding the concept helps explain high-performance .NET APIs such as Span<T> and ReadOnlySpan<T> (covered in Chapter 13).

Here’s a simple ref struct example:

ref struct StackOnlyPoint
{
    public int X;
    public int Y;
}

It looks similar to a normal struct, but with an extra ref keyword. However, because ref struct is a stack-only kind of struct, the compiler enforces a number of strict limitations to guarantee that behavior, including:

  • It cannot be a field of a class or a non-ref struct.
  • Before C# 13 it couldn’t implement interfaces; in C# 13 it can implement interfaces but can’t be cast to an interface type.
  • It cannot be boxed.
  • It cannot be used as an array element type.
  • It cannot be used across await in async methods.
  • It cannot be captured by lambdas or local functions, so it cannot survive across those closure boundaries.

The following examples show rule violations that fail to compile.

  1. It cannot be a field of a class or non-ref struct:
ref struct MyRefStruct { }

class MyClass
{
    private MyRefStruct field;  // ✗ Compile error
}
  1. It cannot be used in arrays:
ref struct MyRefStruct { }

var array = new MyRefStruct[10];  // ✗ Compile error
  1. It cannot be boxed:
ref struct MyRefStruct { }

object obj = new MyRefStruct();  // ✗ Compile error
  1. It cannot be used across await:
async Task ProcessAsync()
{
    Span<int> span = stackalloc int[10];
    await Task.Delay(100);
    span[0] = 42;  // ✗ Compile error: span cannot cross the await boundary
}

In other words, a ref struct can appear inside an async method, but it cannot be created before an await and then used after that await. The same idea applies to yield boundaries in iterator methods.

Also note that before C# 13, ref struct could not implement interfaces at all. Starting with C# 13, it can implement interfaces, but it still cannot be cast to an interface type (because that would require boxing).

But why does ref struct have so many restrictions, and where does it fit in practice?

In short, these restrictions exist to ensure a ref struct can never escape to the heap. To see why that matters, let’s first look at a key use case, then return to the technical reason.

A key use case: zero-allocation processing

As mentioned earlier, ref struct is mainly used for high-performance scenarios. The most common examples are Span<T> and ReadOnlySpan<T>—both declared as ref struct:

public readonly ref struct Span<T> { ... }
public readonly ref struct ReadOnlySpan<T> { ... }

For example, suppose you need to parse values from a string like "X:100,Y:200":

  1. Typical approach: use Substring or Split. This allocates temporary strings (heap allocations) and increases GC pressure.
  2. High-performance approach: use ReadOnlySpan<char>. You can slice the original string without allocating new strings.

Example:

string input = "X:100,Y:200";

// Typical approach: allocates strings (adds GC pressure)
string[] parts = input.Split(','); // Allocates an array and strings

// High-performance approach: zero allocation
ReadOnlySpan<char> span = input.AsSpan();
// ... operate directly on span; no new objects

This highlights a key trait of Span<T> and ReadOnlySpan<T>: they act like a window into existing memory without copying the underlying data. In other words, they create a view over existing data, not a copy of the data itself.

This window-into-memory design makes them efficient, but they do carry one important constraint: Span<T> and ReadOnlySpan<T> themselves are subject to stack-only restrictions and cannot escape to the heap, but the data they refer to does not have to be on the stack. It can come from a heap-allocated array or string, or even from unmanaged memory.

Why can’t they escape to the heap?

Because Span<T> and ReadOnlySpan<T> can point to many kinds of memory, they may internally reference stack memory (for example, a buffer created with stackalloc). If such a struct were allowed on the heap and the method returned, the stack memory would be reclaimed, and the reference would become a dangling pointer, leading to serious errors.

For more on Span<T>, stackalloc, and high-performance memory techniques, see Chapter 13.

Note

In most cases, readonly struct is enough. Consider ref struct only when you truly need to wrap stack memory or pursue extreme performance optimizations.

4.3 record: simplifying data type definitions

Some types exist mainly to store and carry data—for example, DTOs, domain events, and application settings. With these types, we usually care whether their data is the same, not whether two variables refer to the same object. We may also want them to display meaningful contents and make it easy to create a copy with only a few values changed.

With an ordinary class, you often have to implement these features yourself: override Equals, GetHashCode, and ToString, and write additional code for deconstruction or copying. None of these tasks is especially difficult on its own, but together they create a fair amount of repetitive boilerplate.

C# 9 introduced record to simplify the design of these data-centric types. We’ll first look at the basic declaration and the members generated by the compiler, then examine value equality, and finally compare when to use record class and record struct.

record declaration syntax

The syntax for defining a record is concise:

// Primary-constructor syntax
public record Person(string Name, int Age);

This form, with parameters written directly after the type name, is called a positional record. From that single line, the compiler generates several useful members based on Name and Age:

  • Corresponding init properties named Name and Age (see Section 4.4).
  • Generated value-equality members such as Equals, GetHashCode, and the == and != operators.
  • Useful ToString() output, such as Person { Name = Alice, Age = 30 }.
  • A Deconstruct method for deconstruction.
  • Compiler-generated members that support with expressions (see Section 4.5; the internal mechanism differs slightly between record class and record struct).

What is a primary constructor?

A primary constructor places constructor parameters directly after the type name. The syntax first appeared with records in C# 9 and was later extended to ordinary classes and structs in C# 12.

For an ordinary class or struct, primary-constructor parameters do not automatically become fields or properties. For a positional record, the compiler generates corresponding public properties. That is why the example above can define Name and Age in one line.

The following example shows several convenient features of records:

// 1. Properties with init accessors
var person = new Person("Alice", 30);
// person.Name = "Bob";  // ✗ Compile error: init-only

// 2. Meaningful ToString
Console.WriteLine(person);
// Output: Person { Name = Alice, Age = 30 }

// 3. Value equality: the same data means the values are equal
var person2 = new Person("Alice", 30);
Console.WriteLine(person == person2);  // True (same contents)

// 4. Deconstruct
var (name, age) = person;
Console.WriteLine($"{name} is {age} years old");  // Alice is 30 years old

// 5. with expression support (see Section 4.5)
var olderPerson = person with { Age = 31 };
Console.WriteLine(olderPerson);  // Person { Name = Alice, Age = 31 }

If you wrote the same behavior with a traditional class, you’d usually end up with many lines of boilerplate code.

Note

The central idea of record is language support for data-centric types; a record does not by itself guarantee immutability. The Person example is a positional record class, so the compiler generates init properties for Name and Age. If you instead declare ordinary properties with set accessors in the record body, the record can still be mutable.

Also, init prevents a property from being reassigned after initialization. If the property refers to a mutable object, that object’s contents can still change. Section 4.4 examines init accessors in more detail.

Value equality

Of the generated features listed above, value equality deserves a closer look first. The following example shows the behavioral difference between an ordinary class and a record:

// A class uses reference equality by default
class PersonClass
{
    public string Name { get; set; }
    public int Age { get; set; }
}

var p1 = new PersonClass { Name = "Alice", Age = 30 };
var p2 = new PersonClass { Name = "Alice", Age = 30 };
Console.WriteLine(p1 == p2);  // False (different instances)

// A record uses value equality by default
record PersonRecord(string Name, int Age);

var r1 = new PersonRecord("Alice", 30);
var r2 = new PersonRecord("Alice", 30);
Console.WriteLine(r1 == r2);  // True (same values)

Key differences:

  • Default class behavior: p1 == p2 compares references. Even if p1 and p2 have identical contents, they’re two different instances, so the result is False.
  • Default record behavior: The compiler generates value-equality members such as Equals, GetHashCode, and ==. For the PersonRecord(string Name, int Age) example above, two records are compared using the values of Name and Age, rather than merely checking whether both variables refer to the same object.

Note

Record value equality is not recursive deep equality. If a member is an array, collection, or another reference type, that member still follows its own equality rules. For example, two separately created arrays with identical elements may still compare as different values by default.

Source code: DemoRecord

For data-centric types, record equality often matches the requirement more closely: whether two instances are equal depends on the equality rules of their data members, not on object identity.

That makes records a good fit for:

  • DTOs (data transfer objects): to move data across layers
  • Domain events: to represent something that happened in the system
  • Configuration types: application settings

record class vs. record struct

The earlier Person example uses the most common form, record class. Starting with C# 10, you can also declare a record as the value type record struct. This lets you choose based on data size, copying cost, and how the type will be used:

// record class (default; same as writing record)
public record class PersonRecord(string Name, int Age);

// record struct (a value-type record)
public record struct Point(int X, int Y);

// readonly record struct (prevents fields and properties from being reassigned)
public readonly record struct ImmutablePoint(int X, int Y);

record class is the default. When you write record MyRecord, you’re effectively declaring a record class.

With positional record class syntax, primary-constructor parameters generate init-only properties by default. They can be set during initialization but cannot be reassigned afterward:

// Positional record class: primary-constructor parameters generate init-only properties
public record class PersonClass(string Name, int Age);

var p1 = new PersonClass("Alice", 30);
p1.Name = "Bob";  // ✗ Compile error: init-only properties can't be changed after initialization

With positional record struct syntax, primary-constructor parameters generate read-write properties by default, so they can be reassigned after construction:

public record struct PersonStruct(string Name, int Age);

var p2 = new PersonStruct("Alice", 30);
p2.Name = "Bob";  // ✓ Mutable (which may not be what you want)

If you want to prevent those positional properties from being reassigned after initialization, use readonly record struct:

// Positional readonly record struct: init-only properties
public readonly record struct PersonStruct(string Name, int Age);

var p3 = new PersonStruct("Alice", 30);
p3.Name = "Bob";  // ✗ Compile error

readonly prevents fields and properties from being reassigned. If a member refers to a mutable object, that object’s contents can still change.

When should you use record class?

record class is a reference type and is a good fit when you want to:

1. Define DTOs: pass data across layers or services

public record class UserDto(int Id, string Email, string DisplayName);

2. Model domain events: represent important events in the system

public record class OrderPlacedEvent(int OrderId, DateTime PlacedAt, decimal Total);

3. Represent larger data shapes: many properties or nested structures where copying would be expensive

public record class CustomerProfile(
    string Name,
    string Email,
    Address ShippingAddress,
    List<Order> RecentOrders
);

Because it’s a reference type, multiple variables can point to the same instance. When you use init-only properties and avoid exposing mutable internal objects, it works well for passing and sharing data throughout an application.

When should you use record struct?

record struct is a value type, so its use cases are largely the same as struct. For example:

// Small, independent value objects, such as coordinates or colors
public readonly record struct Point(int X, int Y);
public readonly record struct ColorRgb(byte R, byte G, byte B);

// Avoiding heap allocation: high-performance calculations or frequently created/collected values
public readonly record struct TimeRange(DateTime Start, DateTime End);

// Dictionary keys: use as a composite key
public readonly record struct CacheKey(string UserId, string Resource);
var cache = new Dictionary<CacheKey, string>();

Selection guidelines

Choosing record class vs. record struct follows the same general guidance as choosing class vs. struct:

  • Most of the time: use record class unless you have a specific performance reason.
  • Small value objects: consider readonly record struct.
  • When unsure: start with record class and optimize only when profiling proves it’s needed.

The following table summarizes key differences between class, struct, record class (i.e., record), and record struct:

Feature class struct record class record struct
Type category Reference type Value type Reference type Value type
Inheritance ✓ Supports inheritance ✗ No inheritance (can implement interfaces) ✓ Supports inheritance ✗ No inheritance (can implement interfaces)
Default equality Reference equality Value equality (Equals/GetHashCode) Value equality (generated) Value equality (generated)
Mutability Depends on member declarations Mutable by default (prefer readonly struct) Depends on member declarations; positional properties are init-only by default Depends on member declarations; positional properties are read-write by default
ToString() Type name by default Type name by default Generated (prints data member names and values) Generated (prints data member names and values)
with support No (manual) ✓ Yes (C# 10+) ✓ Yes (generated) ✓ Yes (generated)
Deconstruction Manual Deconstruct needed Manual Deconstruct needed Generated for positional declarations Generated for positional declarations
Typical use Inheritance, larger objects, reference semantics Small data, performance, avoid heap allocation DTOs, domain events, configuration, immutable data Small value objects, composite keys

4.4 init accessors: preventing reassignment after initialization

Before C# 9, if you wanted to prevent callers from reassigning properties after an object was created, the usual approach was get-only properties assigned in the constructor. However, this meant sacrificing the convenience of object initializers (introduced back in C# 3.0). You had to choose between restricting reassignment and using initializer syntax.

C# 9’s init accessors solve that by letting you set properties only during initialization. You can keep object initializer syntax while preventing those properties from being reassigned later. This restriction helps with immutable design, but it does not make an object deeply immutable when a property refers to a mutable object.

init accessor syntax

Compare the traditional approach with an init accessor:

// Traditional: constructor parameters
public class Person
{
    public string Name { get; }
    public int Age { get; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

var person = new Person("Alice", 30);  // Must use the constructor

This prevents those properties from being reassigned after construction, but you lose object initializer convenience—and constructor parameter lists can get unwieldy when there are many properties.

With init accessors:

public class Person
{
    public string Name { get; init; }
    public int Age { get; init; }
}

// Object initializer is allowed; properties cannot be reassigned afterward
var person = new Person { Name = "Alice", Age = 30 };

// Any later mutation fails at compile time
person.Name = "Bob";  // ✗ Compile error: init-only

init combines two goals: preventing property reassignment and retaining object initializer syntax.

You keep the flexibility of object initializers (set only what you need, in any order) while preventing those properties from being reassigned after initialization.

However, init has a small gap: it cannot force callers to set certain properties. If a caller omits Name, the object is still created—just with default values. The required modifier introduced in C# 11 fills exactly this gap.

Using the required modifier (C# 11)

C# 11 introduced the required modifier to force callers to provide certain properties during initialization:

public class Person
{
    public required string Name { get; init; }
    public int Age { get; init; }  // optional
}

// Name is required
var person = new Person { Name = "Alice" };  // ✓ OK

// Missing Name produces a compile error
var invalid = new Person { Age = 30 };  // ✗ Compile error: Name is required

required ensures that callers must initialize those required members when creating the object, which helps prevent a “missing entirely” kind of half-initialized state (for example, creating a Person and forgetting to provide Name at all).

However, be careful: required guarantees “must be initialized,” not “must be non-null.” If the member type is a non-nullable reference type, the compiler will typically report nullable warnings; if you need to strictly reject invalid values, you should still add appropriate validation logic.

Together, required + init gives you a practical balance: language-level checks during initialization, plus the convenience of object initializers.

Source code: DemoInitAccessor

4.5 with expressions: nondestructive mutation

With immutable design, you do not modify an existing object directly. Instead, you use it as the basis for a copy with selected values changed. A with expression provides concise syntax for this “copy and change” operation.

Here’s the basic idea:

public record Person(string Name, int Age);

var alice = new Person("Alice", 30);

// Use a with expression to create a new instance with specific changes
var olderAlice = alice with { Age = 31 };

Console.WriteLine(alice);       // Person { Name = Alice, Age = 30 }
Console.WriteLine(olderAlice);  // Person { Name = Alice, Age = 31 }

The semantics of with are: “create a copy, then set certain members on the copy.” The operation does not modify the original object, but it still provides convenient mutation-like syntax.

This pattern is often called nondestructive mutation: rather than changing the original object, you create a new version.

It’s like making a photocopy and correcting the copy: the original remains unchanged.

Modifying multiple properties

You can update multiple members in a single with expression:

var bob = alice with { Name = "Bob", Age = 25 };

The syntax is intuitive: “give me a copy of alice, but with these fields replaced.”

How with works under the hood

Conceptually, a with expression has two phases, but the first phase differs slightly between record class and record struct:

  1. Copy phase:
    • For a record class, the compiler uses the generated clone/copy mechanism to create a copy. This is a field-level shallow copy: it copies all fields (including private fields and backing fields), and it bypasses the init accessor logic. This design ensures that all internal fields are copied without interference from any logic (such as validation or computation) that may live in init accessors.
    • For a record struct, the copy is just an ordinary value copy. The compiler doesn’t call a custom copy constructor for with.
  2. Update phase: it then applies the member-initializer assignments, which do go through the init accessors. This means that any validation logic in init still applies to the properties that with modifies. Only properties that with does not specify are carried over from the copy phase, bypassing init.

For a record class, the compiler conceptually turns this:

var bob = alice with { Name = "Bob", Age = 25 };

You can think of it conceptually as a flow like this (the real implementation uses a compiler-generated clone/copy mechanism, not a public API you can write directly):

1. Create a field-level shallow copy of alice
2. Apply Name = "Bob" and Age = 25 during the initialization phase of that copy
3. Assign the finished new object to bob

In other words, this is not a normal “create an object first, then assign init-only properties later” sequence. For a record class, the compiler performs the copy and initialization inside the with workflow so that it can work with init properties.

This design offers several advantages:

  • Efficient copying (bypasses full initialization logic)
  • Property validation still applies (via init accessors)
  • All fields are copied correctly (including private fields)

For a record class, you can customize the copy constructor to adjust behavior—for example, reset cached fields or deep-copy certain structures:

public record Person(string Name, int Age)
{
    private int? _cachedValue;

    // Custom copy constructor
    protected Person(Person original)
    {
        Name = original.Name;
        Age = original.Age;
        // Don't copy the cached value; force recomputation
        _cachedValue = null;
    }
}

Ask AI

Explain how to achieve “deep copy” using C#’s record and with expression, and provide code examples.

What about non-record types?

with isn’t limited to record types: ordinary struct types also support with. For ordinary classes, however, with is not supported. A copy constructor or Clone method by itself does not enable with; if you need similar behavior, you must provide your own copying API and call it explicitly. Also note that record struct and ordinary struct both rely on ordinary value copying, not the hidden clone/copy-constructor machinery used for record class.

public class Person
{
    public string Name { get; init; } = "";
    public int Age { get; init; }

    public Person() { }

    protected Person(Person original)
    {
        Name = original.Name;
        Age = original.Age;
    }

    public Person Copy(string? name = null, int? age = null)
    {
        return new Person(this)
        {
            Name = name ?? Name,
            Age = age ?? Age
        };
    }
}

var alice = new Person { Name = "Alice", Age = 30 };
var bob = alice.Copy(name: "Bob");

Source code: DemoWithExpression

4.6 Derived properties and lazy evaluation

Immutable types often include derived (calculated) properties—values computed from other members. A common optimization is lazy evaluation: compute the value on first access, then cache the result for later.

Here’s a simple derived property:

public record Point(double X, double Y)
{
    public double DistanceFromOrigin => Math.Sqrt(X * X + Y * Y);
}

This is concise, but every access recalculates DistanceFromOrigin, which can be unnecessary overhead.

Lazy evaluation with caching

If the computation is expensive, you can cache the computed result:

public record Point(double X, double Y)
{
    private double? _distanceCache;

    public double DistanceFromOrigin
    {
        get
        {
            if (_distanceCache == null)
            {
                _distanceCache = Math.Sqrt(X * X + Y * Y);
            }
            return _distanceCache.Value;
        }
    }
}

You can make it even more concise with the null-coalescing assignment operator (??=):

public double DistanceFromOrigin => _distanceCache ??= Math.Sqrt(X * X + Y * Y);

This means: if _distanceCache is null, compute and assign it; otherwise return the cached value.

Operator evaluation order

The evaluation order of _distanceCache ??= expression is:

  1. Check whether _distanceCache is null.
  2. If it is null, evaluate the right-hand expression and assign it to _distanceCache.
  3. Return the value of _distanceCache (either the existing value or the newly computed one).

This syntax is concise, but it is not an atomic operation in the multithreading sense, and it does not by itself provide thread-safe lazy initialization. Two threads could both observe null, each compute the value independently, and both write their result—resulting in duplicate computation. If the computation is a pure function (like Math.Sqrt here), the duplicated work is harmless; if it has side effects, it could be a real bug. For thread-safe lazy initialization, consider using Lazy<T>.

For a deeper explanation of ??=, see Chapter 3.

Working with init properties

We can use the with expression’s internal behavior to ensure the cache is properly invalidated. Recall from Section 4.5 that with calls init accessors during its update phase. Therefore, if we clear the cache inside init, then a with { Y = 5 } expression will automatically trigger the init for Y, which clears the cache, so the next access to DistanceFromOrigin will recompute the value.

If inputs can change during initialization (such as through with expressions or init setters), you must remember to invalidate any cached values in those init accessors:

This example uses the C# 14 field keyword, so it requires a C# 14-capable compiler such as the .NET 10 SDK.

public record Point
{
    public Point(double x, double y) => (X, Y) = (x, y);

    public double X
    {
        get;
        init
        {
            field = value;
            _distanceCache = null;  // Invalidate the cache
        }
    }

    public double Y
    {
        get;
        init
        {
            field = value;
            _distanceCache = null;  // Invalidate the cache
        }
    }

    private double? _distanceCache;
    public double DistanceFromOrigin => _distanceCache ??= Math.Sqrt(X * X + Y * Y);
}

With that in place, when a with expression modifies X or Y, the cache automatically becomes invalid:

var p1 = new Point(3, 4);
Console.WriteLine(p1.DistanceFromOrigin);  // 5 (compute and cache)

var p2 = p1 with { Y = 5 };
Console.WriteLine(p2.DistanceFromOrigin);  // 5.83... (recompute)

Alternative: ignore the cache during cloning

Another option is to ignore the cache field when cloning:

public record Point(double X, double Y)
{
    private double? _distanceCache;
    public double DistanceFromOrigin => _distanceCache ??= Math.Sqrt(X * X + Y * Y);

    // Custom copy constructor: don't copy the cache
    protected Point(Point other) => (X, Y) = other;
}

Source code: DemoLazyEvaluation

The upside is that you can keep primary-constructor syntax and the code stays concise. The downside is that every time you create a new instance via with, the private cache field is reset—even if the change has nothing to do with the computation.

Design principle

Lazy evaluation is a safe and common optimization in immutable types. Technically, we “mutate” the _distanceCache field, but it doesn’t violate immutability because it doesn’t change the object’s logical state. This pattern is known as logical immutability.

4.7 Practical application: an immutable domain model

Suppose we’re building an order system and use records to define an immutable order:

using System.Collections.Immutable;

// Use a record to define an immutable order item
public record OrderItem(string ProductName, int Quantity, decimal UnitPrice)
{
    public decimal TotalPrice => Quantity * UnitPrice;
}

// Use a record to define an immutable order
public record Order(
    int OrderId,
    string CustomerName,
    ImmutableList<OrderItem> Items,
    DateTime CreatedAt)
{
    public decimal TotalAmount => Items.Sum(item => item.TotalPrice);

    // Use a with expression to "modify" the order
    public Order AddItem(OrderItem item)
    {
        return this with { Items = Items.Add(item) };
    }
}

This example deliberately uses ImmutableList<OrderItem> instead of wrapping a List<OrderItem> in IReadOnlyList<OrderItem>. The latter only hides the writable API; the underlying collection can still be mutated elsewhere. ImmutableList<T> is genuinely immutable. Section 4.9 covers immutable collections in more detail.

Next, let’s see how to use this immutable order model:

using System.Collections.Immutable;

var order = new Order(
    OrderId: 1,
    CustomerName: "Alice",
    Items: ImmutableList.Create(
        new OrderItem("Keyboard", 1, 2500)),
    CreatedAt: DateTime.Now
);

// "Modify" the order (this actually creates a new order object)
var updatedOrder = order.AddItem(new OrderItem("Mouse", 2, 800));

Console.WriteLine($"Original order total: {order.TotalAmount}");      // 2500
Console.WriteLine($"Updated order total: {updatedOrder.TotalAmount}");  // 4100

In this example, we never mutate an existing object. Instead, we create a new object to represent each new state. Because Items also uses a truly immutable collection, the original order cannot be changed later through some external reference to the collection.

Benefits of this design:

  1. History tracking: every “change” produces a new version, making undo/redo easy
  2. Thread safety: immutable objects are safe to use across threads
  3. Predictability: functions have fewer side effects and are easier to test

Source code: DemoOrderModel

AI collaboration: refactor toward immutability

The example above showed how to design an immutable model from scratch. In practice, though, you’re more likely to turn existing classes into immutable ones. This kind of refactoring involves type choices, initialization style, and API design, so it’s a good place to ask AI tools to help organize the direction:

Prompt

Refactor the core data objects in this project into immutable types. Requirements:

  1. Use record or readonly struct (choose appropriately)
  2. Use init accessors
  3. Provide an API that works well with with expressions
  4. Explain the reasoning behind your design choices

4.8 Equality comparison

In C#, determining whether two objects are “equal” is more nuanced than it first appears. This is closely related to immutable design because immutable objects are often treated as value objects, where equality should be based on contents (values) rather than memory location (references).

Reference equality vs. value equality

By default, a class uses reference equality: two variables are equal only if they point to the same instance on the heap. In other words, even if two objects have identical contents, they are still considered different objects by default if they live at different memory addresses.

Example:

var p1 = new Person("Alice", 30);
var p2 = new Person("Alice", 30);
Console.WriteLine(p1 == p2);      // False (different references)

That’s why, in unit tests, comparing two class instances often “fails” unless you implement custom equality logic.

In contrast, structs and records are both related to “value-based” comparison, but not at exactly the same level:

  • Ordinary structs inherit value-type equality semantics, so Equals compares them based on their field values. However, they do not automatically get == / != operators the way records do.
  • record / record struct get a more complete value-equality implementation from the compiler, including Equals, GetHashCode, and the == / != operators.

So for a custom struct, p1.Equals(p2) may be true, while p1 == p2 may not even compile unless you overload the operators yourself.

Ask AI

Can a custom C# struct use == for value-based equality the same way a record does? If not, what should I do instead?

Manually implementing value equality (the painful way)

If you must implement value equality on a class (without using records), you need to add several members to satisfy C#’s equality contracts:

  1. Implement IEquatable<T> (for performance and type safety).
  2. Override object.Equals(object).
  3. Override object.GetHashCode() (critical).
  4. Overload == and !=.

Why do you need to implement so many members?

C# equality can be evaluated through multiple entry points:

  • obj1.Equals(obj2) where obj2 is typed as object: non-generic, from object.
  • obj1.Equals(obj2) where obj2 is typed as Person: generic, from IEquatable<T>, typically faster.
  • obj1 == obj2: operator overload.
  • obj1.GetHashCode(): used by hash-based collections (Dictionary/HashSet).

To keep behavior consistent across all these paths, you need to implement them all. Missing one can lead to confusing inconsistencies.

public class Person : IEquatable<Person>
{
    public string Name { get; }
    public int Age { get; }

    public Person(string name, int age) => (Name, Age) = (name, age);

    // 1. Implement IEquatable<T>
    public bool Equals(Person? other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;
        return Name == other.Name && Age == other.Age;
    }

    // 2. Override object.Equals
    public override bool Equals(object? obj)
    {
        return Equals(obj as Person);
    }

    // 3. Override GetHashCode (must be consistent with Equals)
    public override int GetHashCode()
    {
        return HashCode.Combine(Name, Age);
    }

    // 4. Overload operators
    public static bool operator ==(Person? left, Person? right)
    {
        if (left is null) return right is null;
        return left.Equals(right);
    }

    public static bool operator !=(Person? left, Person? right) => !(left == right);
}

This is why, when the scenario fits, records are usually worth considering: the compiler generates most of this boilerplate for you.

Record equality (the easy way)

Records provide value equality out of the box, which is a good match for immutable types. The compiler generates the necessary members:

public record Person(string Name, int Age);

var p1 = new Person("Alice", 30);
var p2 = new Person("Alice", 30);

Console.WriteLine(p1 == p2);        // True (value equality)
Console.WriteLine(p1.Equals(p2));   // True
Console.WriteLine(ReferenceEquals(p1, p2));  // False (different instances)

If you need custom equality logic (for example, ignore certain fields), you can define a public virtual bool Equals(Person other) method:

public record Person(string Name, int Age)
{
    private int _ignored;

    // Custom equality: compare Name and Age only; ignore _ignored
    public virtual bool Equals(Person? other)
    {
        if (other is null) return false;
        return Name == other.Name && Age == other.Age;
    }
}

Note that this method must be virtual and the parameter type must be Person (the record type itself, not object). Once you define it, the compiler will use your implementation.

If you override Equals, you should also override GetHashCode() (next section). The good news is you don’t need to implement IEquatable<T> manually or overload ==/!=—records handle those details.

Why GetHashCode matters

Why must GetHashCode be consistent? Because Dictionary and HashSet use an object’s hash code to locate entries quickly.

Rule: if two objects are considered equal (Equals returns true), they must return the same GetHashCode value.

If you violate this rule, objects you put into a Dictionary may become impossible to retrieve, leading to very hard-to-debug issues.

Source code: DemoEquality

4.9 Immutable collections

So far we’ve focused on the immutability of individual objects. However, if your object contains a collection—and that collection can still be modified externally—your “immutable” design can still be undermined by external mutation.

That’s where .NET’s immutable collections come in, such as ImmutableArray<T> and ImmutableList<T>. These aren’t just “a List<T> viewed through IReadOnlyList<T>”; they are fundamentally different implementations.

The problem with mutable collections

Even if you mark a mutable collection (like List<T>) as readonly, or expose it as IReadOnlyList<T>, the underlying data can still be modified (for example, by casting it back to List<T>). Consider:

class NumberStore
{
  private readonly List<int> _numbers = new List<int> { 1, 2, 3 };

  public void Demo()
  {
    _numbers.Add(4);  // Still mutable! readonly protects the field reference, not the contents

    IReadOnlyList<int> readOnlyView = _numbers;
    ((List<int>)readOnlyView).Add(5);  // Can still mutate via cast
  }
}

In this example, readonly simply ensures that the field _numbers cannot be reassigned to a different List<int> after initialization. It does nothing to prevent someone from changing the elements inside that list.

System.Collections.Immutable

.NET provides the System.Collections.Immutable package, which contains truly immutable collection types such as ImmutableList<T> and ImmutableDictionary<TKey, TValue>.

The key principle is: every “mutation” returns a new collection, leaving the original unchanged.

It’s like taking a photo (a snapshot). The landscape can change, but the photo stays the same. If you want a new view, you take a new photo.

Example:

using System.Collections.Immutable;

var list1 = ImmutableList.Create(1, 2, 3);
var list2 = list1.Add(4);

Console.WriteLine(string.Join(", ", list1)); // 1, 2, 3 (unchanged)
Console.WriteLine(string.Join(", ", list2)); // 1, 2, 3, 4 (new)

// list1 is unchanged; list2 is a new list

Notice that list1 is unchanged. That makes it safe to pass around without worrying that another method will mutate it.

Performance considerations

Because every change returns a new object, you might worry about performance overhead. However, these collections use specialized data structures (such as balanced trees) to implement structural sharing. This means “copying” doesn’t duplicate every element—most of the internal nodes are reused.

Even so, write-heavy workloads are typically more expensive than with mutable collections. If you need to perform many updates (for example, building a list in a loop), use the builder pattern:

var builder = ImmutableList.CreateBuilder<int>();
builder.Add(1);
builder.Add(2);
// ... lots of updates ...
var list = builder.ToImmutable();

Builders reduce intermediate allocations during bulk updates and let you freeze the result into an immutable collection at the end.

How builders work

A builder uses a mutable internal representation during construction, and then converts (freezes) it into an immutable collection. This avoids creating a new immutable instance for every Add and can significantly improve performance for bulk operations.

ImmutableArray<T> vs. ImmutableList<T>

Another common option is ImmutableArray<T>. Internally it wraps an array, so it tends to have better memory usage and read performance than tree-based ImmutableList<T>.

  • ImmutableArray: Great read performance (\(O(1)\), contiguous memory), but each change copies the whole array (\(O(N)\)). Best for “build once, read many.”
  • ImmutableList: Tree-based with structural sharing. Updates like Add/Remove are typically \(O(\log N)\), but “read cost” depends on what you mean (for example, enumeration is still \(O(N)\), while indexed access has the extra overhead of tree traversal). Better when you need frequent updates while keeping immutable semantics.

So if your collection is mostly read-only after construction, prefer ImmutableArray<T>.

Source code: DemoImmutableCollections

Beyond the immutable collections provided by System.Collections.Immutable, if your scenario is “build once, then only read—never modify,” .NET 8+ offers another set of even better choices.

FrozenDictionary and FrozenSet

.NET 8 introduced the System.Collections.Frozen namespace, which includes FrozenDictionary<TKey, TValue> and FrozenSet<T>.

These types have different goals from the immutable collections above:

  • ImmutableDictionary: Supports non-destructive modifications (each mutation returns a new instance). Tree-based internally, so element insertion is relatively fast, but lookups are slower.
  • FrozenDictionary: Completely frozen after creation — no mutations whatsoever. Lookup performance is excellent (faster than a regular Dictionary), but construction has a higher upfront cost (slower to initialize). Best suited for “build once at startup, read many times” scenarios such as configuration lookup tables and keyword maps.

FrozenSet<T> is the read-optimized counterpart to ImmutableHashSet<T>, similarly tuned for lookup performance.

Example:

using System.Collections.Frozen;

var config = new Dictionary<string, string>
{
    ["timeout"] = "30",
    ["retries"] = "3"
}.ToFrozenDictionary();

// Lookups are faster than a regular Dictionary, and the collection can never be modified
string value = config["timeout"];

In summary, the right collection depends on your usage pattern: for frequent modifications with version history, use ImmutableList or ImmutableDictionary; for mostly-read data with occasional modifications, use ImmutableArray; for data that never changes after construction and demands the fastest lookups, use FrozenDictionary or FrozenSet.

Summary

  • Immutable design reduces mutation, making code more predictable, safer, and easier to maintain.
  • struct vs. class: Structs are value types and work best for small, immutable data; classes are reference types and work best for larger models and inheritance.
  • readonly struct lets the compiler enforce read-only data members and helps avoid defensive copies caused by instance members that aren’t correctly marked readonly.
  • ref struct is stack-only and enables high-performance APIs like Span<T>.
  • Records are data-centric types with compiler-generated value equality, ToString, and with support, but records do not by themselves guarantee immutability; you can choose record class or record struct.
  • init accessors make properties settable only during initialization; combined with required, they enforce required initialization.
  • with expressions provide nondestructive mutation by creating a new instance.
  • Derived properties + lazy evaluation can improve performance via caching without breaking logical immutability.
  • Equality: understand reference equality vs. value equality, and implement GetHashCode consistently (or just use records).
  • Immutable collections: use System.Collections.Immutable and builders for bulk updates. For collections that are built once and then read heavily, consider FrozenDictionary or FrozenSet from System.Collections.Frozen for faster lookups.

C# is no longer limited to traditional getters and setters. From readonly struct to records and init accessors, modern C# makes immutable design much easier to apply.

When designing a new type, consider making “immutable by default” your starting point. Spending less time chasing “who changed my data?” leaves more time for work that actually matters.

Glossary

Term Description
Copy constructor A special constructor used to copy an existing instance; record class can have one synthesized by the compiler as part of cloning.
Deep copy A recursive copy that duplicates nested objects and collections rather than sharing references.
Hash code A numeric value used by hash-based collections (like Dictionary/HashSet) for fast lookup.
Immutable An object whose logical state cannot be changed after creation.
Immutable collection A collection that never changes; “mutations” return a new collection (for example ImmutableList<T>).
init accessor A property accessor that can be set only during initialization.
Lazy evaluation Compute a value only when first needed, often caching the result for later reads.
Logical immutability A pattern where the logical state is immutable, even if internal caches are updated for performance.
Primary constructor Constructor parameter syntax declared with the type, available across the type body.
readonly struct A struct declaration that enforces non-mutation of instance fields.
Record A data-centric type introduced in C# 9 with generated value equality; it does not by itself guarantee immutability.
ref struct A stack-only struct type used for high-performance scenarios (for example Span<T>).
Reference equality Equality based on whether two variables refer to the same instance.
Reference type A type whose variables hold references to objects (for example class).
required modifier A modifier that forces callers to initialize required members during object initialization.
Shallow copy A copy that duplicates top-level fields but continues to share referenced sub-objects.
Structural equality Equality based on comparing an object’s members (structure/contents).
Structural sharing An immutable-collection technique that reuses internal nodes between versions to reduce copying.
Value equality Equality based on comparing member values rather than references.
Value type A type whose variables contain the data directly (for example struct).
with expression Syntax that creates a copy from an existing instance and assigns new values to selected members.