Creating Read-Only Properties for Immutable Design in C#

Immutability Through Property Design

Myth: Read-only properties are just syntactic sugar with no real benefits. Reality: They enforce immutability at compile time, prevent accidental mutations, enable thread-safe designs, and communicate intent clearly to other developers. Immutable objects simplify reasoning about code and eliminate entire classes of bugs.

C# offers multiple ways to create read-only properties: get-only properties, init accessors (C# 9+), readonly fields with property wrappers, and records. Each approach has different trade-offs for initialization flexibility, immutability guarantees, and syntax convenience. Choosing the right one affects code maintainability and safety.

You'll learn how to create read-only properties using various techniques, use init accessors for flexible initialization, work with records for immutable types, understand the difference between readonly references and immutable objects, and apply these patterns to build robust domain models.

Get-Only Properties

The simplest read-only property has only a get accessor with no set. You can assign values in the constructor or use expression-bodied syntax for computed properties. This pattern is perfect when values are derived from other fields or set once during construction.

Customer.cs
public class Customer
{
    private readonly string _firstName;
    private readonly string _lastName;

    // Get-only properties backed by readonly fields
    public string FirstName => _firstName;
    public string LastName => _lastName;

    // Computed get-only property
    public string FullName => $"{FirstName} {LastName}";

    // Auto-property with only getter (C# 6+)
    public DateTime CreatedDate { get; }

    public Customer(string firstName, string lastName)
    {
        _firstName = firstName;
        _lastName = lastName;
        CreatedDate = DateTime.Now;
    }
}

Get-only auto-properties can only be assigned in constructors or field initializers. This provides strong immutability guarantees. The FullName property demonstrates computed properties that derive from other state without storing redundant data.

Init-Only Properties

C# 9 introduced init accessors that allow setting properties during object initialization but prevent modification afterward. This combines the flexibility of object initializers with the safety of immutability, making it easier to create immutable types without complex constructors.

Product.cs
public class Product
{
    // Init-only properties
    public string Name { get; init; } = string.Empty;
    public decimal Price { get; init; }
    public string Category { get; init; } = "Uncategorized";
    public DateTime CreatedAt { get; init; } = DateTime.Now;

    // Validation in init
    private string _sku = string.Empty;
    public string SKU
    {
        get => _sku;
        init
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("SKU cannot be empty");
            _sku = value.ToUpperInvariant();
        }
    }
}

// Usage with object initializer
var product = new Product
{
    Name = "Widget",
    Price = 29.99m,
    SKU = "wdg-001",
    Category = "Tools"
};

// This won't compile - properties are init-only
// product.Price = 19.99m;  // Error!

Init accessors run during object initialization, allowing validation or transformation logic. After construction completes, the properties become read-only. This pattern works beautifully for data transfer objects and configuration classes where you want convenient initialization without sacrificing immutability.

Records for Immutable Data

Records provide concise syntax for immutable reference types. They automatically implement value-based equality, ToString, and with-expressions for non-destructive mutation. Record parameters create init-only properties by default, making them ideal for domain models and DTOs.

Order.cs
// Positional record - creates init-only properties
public record Order(
    int OrderId,
    string CustomerName,
    decimal TotalAmount,
    DateTime OrderDate
);

// Usage
var order = new Order(1, "John Doe", 499.99m, DateTime.Now);

// Non-destructive mutation with 'with'
var updatedOrder = order with { TotalAmount = 599.99m };

Console.WriteLine(order.TotalAmount);        // 499.99
Console.WriteLine(updatedOrder.TotalAmount); // 599.99

// Record with additional members
public record Product(string Name, decimal Price)
{
    // Additional computed property
    public decimal PriceWithTax => Price * 1.08m;

    // Additional validation
    public bool IsExpensive => Price > 100m;

    // You can add methods too
    public Product ApplyDiscount(decimal percentage)
    {
        var discountedPrice = Price * (1 - percentage / 100);
        return this with { Price = discountedPrice };
    }
}

Records use structural equality instead of reference equality, making them perfect for value objects in domain-driven design. The with expression creates a shallow copy with specified properties modified, maintaining immutability while enabling easy updates.

Read-Only Collections

A readonly field or init-only property doesn't make the referenced object immutable—it only prevents reassigning the reference. For true collection immutability, use ImmutableList or expose read-only views through IReadOnlyList interfaces.

ShoppingCart.cs
using System.Collections.Immutable;

public class ShoppingCart
{
    private readonly List<string> _items = new();

    // Exposes read-only view
    public IReadOnlyList<string> Items => _items.AsReadOnly();

    public void AddItem(string item) => _items.Add(item);

    // Outside code can read but not modify
}

// Better: truly immutable collection
public class ImmutableCart
{
    // ImmutableList cannot be modified after creation
    public ImmutableList<string> Items { get; init; } = ImmutableList<string>.Empty;

    public ImmutableCart AddItem(string item)
    {
        // Returns new instance with added item
        return this with { Items = Items.Add(item) };
    }
}

// Usage
var cart = new ImmutableCart();
var cart2 = cart.AddItem("Widget");
var cart3 = cart2.AddItem("Gadget");

Console.WriteLine(cart.Items.Count);   // 0
Console.WriteLine(cart2.Items.Count);  // 1
Console.WriteLine(cart3.Items.Count);  // 2

IReadOnlyList prevents callers from modifying the collection but doesn't prevent the owner from changing it. ImmutableList creates a new collection on every modification, guaranteeing true immutability at the cost of allocations. Choose based on whether you need protection from external mutation or full immutability.

Avoiding Common Mistakes

A readonly reference to a mutable object is not immutable. If you mark a List property as readonly or init, you prevent reassigning the list but can still call Add and Remove. For immutable state, use immutable collection types or expose only read-only interfaces to callers.

Init accessors allow setting during object initializers, which means validation can't rely on other properties being set yet. If properties have interdependent validation, use a constructor with validation logic instead of relying solely on init accessors. Initialize all required properties before validation runs.

Records with reference-type properties perform shallow copies during with-expressions. If nested objects are mutable, they're shared between the original and copied record. For deep immutability, all nested types must also be immutable, or you need manual deep-copy logic.

Computed properties recalculate on every access. If calculation is expensive and the result doesn't change, consider storing the value in a readonly field set during construction instead of using a property getter. This trades initialization time for runtime performance.

Try It Yourself

Build a console app demonstrating different read-only property approaches and their immutability guarantees.

Steps

  1. Create project: dotnet new console -n ReadOnlyDemo
  2. Navigate: cd ReadOnlyDemo
  3. Update Program.cs
  4. Run: dotnet run
ReadOnlyDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
Program.cs
Console.WriteLine("=== Read-Only Property Demo ===\n");

// Init-only properties
var person = new Person
{
    FirstName = "Alice",
    LastName = "Smith",
    BirthYear = 1990
};

Console.WriteLine($"Person: {person.FirstName} {person.LastName}");
Console.WriteLine($"Age: {person.Age}");

// Record with immutability
var product = new Product("Laptop", 999.99m);
var discounted = product with { Price = 799.99m };

Console.WriteLine($"\nOriginal: {product}");
Console.WriteLine($"Discounted: {discounted}");

public class Person
{
    public string FirstName { get; init; } = string.Empty;
    public string LastName { get; init; } = string.Empty;
    public int BirthYear { get; init; }

    // Computed read-only property
    public int Age => DateTime.Now.Year - BirthYear;
}

public record Product(string Name, decimal Price);

Output

=== Read-Only Property Demo ===

Person: Alice Smith
Age: 35

Original: Product { Name = Laptop, Price = 999.99 }
Discounted: Product { Name = Laptop, Price = 799.99 }

How do I…?

What's the difference between init and readonly?

Init allows setting during object initialization (constructors and object initializers) but prevents modification afterward. Readonly fields can only be assigned in constructors or field initializers, never in object initializers. Use init for properties you want settable during construction, readonly for true compile-time immutability.

Can I modify a readonly collection?

Yes, readonly only prevents reassigning the reference. You can still Add or Remove items from a readonly List<T>. For true immutable collections, use ImmutableList<T> or expose IReadOnlyList<T>. Readonly protects the reference, not the object state.

When should I use get-only properties versus init?

Use get-only properties when values are computed or must be set via constructor. Use init when you want flexible object initializer syntax while maintaining immutability after construction. Init properties work well with records and modern C# patterns.

Do records automatically make properties read-only?

Yes, record classes with positional parameters create init-only properties by default. You can override this with set accessors if needed. Records also provide value-based equality and with-expressions for non-destructive mutation, making them ideal for immutable data models.

Back to Articles