Records in Depth — Immutability, Equality and Inheritance

Records were introduced in C# 9 as a first-class way to define immutable, value-equality data types. They have evolved significantly across C# 9, 10, and 11. Understanding records thoroughly — their equality semantics, inheritance rules, deconstruction, and the crucial with-expression — is essential for designing clean DTOs, CQRS commands/queries, domain events, and value objects in ASP.NET Core applications. The choice between a record and a class communicates intent: a record signals “this is data that represents a value,” a class signals “this is an object with identity and behaviour.”

Positional vs Non-Positional Records

// ── Positional record — concise, auto-generates constructor, properties, deconstruction
public record CreatePostRequest(
    string  Title,
    string  Body,
    string  Slug,
    string[] Tags
);

// Equivalent to a class with:
//   public string  Title  { get; init; }
//   public string  Body   { get; init; }
//   public string  Slug   { get; init; }
//   public string[] Tags  { get; init; }
//   public CreatePostRequest(string Title, string Body, string Slug, string[] Tags)
//   + value equality on all properties
//   + ToString(), GetHashCode(), Deconstruct()

// ── Non-positional record — more control over properties ──────────────────
public record PostDto
{
    public int    Id          { get; init; }
    public string Title       { get; init; } = string.Empty;
    public string AuthorName  { get; init; } = string.Empty;
    public int    ViewCount   { get; init; }

    // Computed property — not part of equality
    public bool IsPopular => ViewCount > 1000;
}

// ── Record struct — value-type record (C# 10) ─────────────────────────────
public readonly record struct Money(decimal Amount, string Currency);
// Stack-allocated, no heap allocation for small value objects
Money price = new Money(19.99m, "USD");
Money sale  = price with { Amount = price.Amount * 0.9m };  // with expression
Note: Record equality is structural — two records are equal if all their properties are equal. This is different from class equality which is referential by default. For records, new Point(1, 2) == new Point(1, 2) is true. For classes, two separately constructed objects are never == unless you override Equals(). This makes records ideal for dictionary keys, deduplication with HashSet, and comparing CQRS commands for idempotency.
Tip: Use positional records for DTOs and command/query models that are primarily data carriers: record GetPostQuery(int Id);, record CreatePostCommand(string Title, string Body, string AuthorId);. Use non-positional records when you need more control — optional properties with defaults, computed properties, or you want to add methods. Use record struct for small value objects that are allocated frequently (like Money, Coordinate, DateRange) where heap allocation overhead matters.
Warning: Records with mutable properties ({ get; set; } instead of { get; init; }) are possible but defeat the purpose of records. Mutable records have value equality semantics but mutable state — two records that start equal can become unequal after mutation, which breaks hash-based collections and comparisons. Use records for immutable data; use classes with explicit Equals() overrides for mutable objects that need value equality.

With Expressions and Inheritance

// ── With expression — non-destructive copy with changes ───────────────────
var original = new CreatePostRequest("Hello World", "Body text", "hello-world", new[] { "csharp" });

// Create a modified copy — original is unchanged
var withTags = original with { Tags = new[] { "csharp", "dotnet" } };
var retitled = original with { Title = "New Title", Slug = "new-title" };

Console.WriteLine(original.Title);  // "Hello World" — unchanged
Console.WriteLine(retitled.Title);  // "New Title" — new record

// ── Record inheritance ─────────────────────────────────────────────────────
public record Animal(string Name, int AgeYears);
public record Dog(string Name, int AgeYears, string Breed) : Animal(Name, AgeYears);

var dog1 = new Dog("Rex", 3, "Labrador");
var dog2 = new Dog("Rex", 3, "Labrador");
var dog3 = new Dog("Rex", 3, "Poodle");

Console.WriteLine(dog1 == dog2);  // true — all properties equal (including Breed)
Console.WriteLine(dog1 == dog3);  // false — Breed differs

// ── Sealed records — prevent further inheritance ──────────────────────────
public sealed record EmailAddress(string Value)
{
    public EmailAddress(string value) : this(value.Trim().ToLowerInvariant()) { }
}

// ── Deconstruction ────────────────────────────────────────────────────────
var (name, age, breed) = dog1;
Console.WriteLine($"{name}, {age} years, {breed}");

Common Mistakes

Mistake 1 — Using a record for an EF Core entity (breaks change tracking)

❌ Wrong — EF Core’s identity map uses reference equality for entities:

public record Post(int Id, string Title);   // record — value equality breaks EF Core!

✅ Correct — use classes for EF Core entities; records for DTOs and value objects only.

Mistake 2 — Comparing a derived record to its base type (equality pitfall)

Records use their runtime type in equality — a Dog is never equal to an Animal with the same base properties, because the types differ. This is correct behaviour but can surprise developers expecting base-class equality comparison.

🧠 Test Yourself

Given record Money(decimal Amount, string Currency), what does new Money(100m, "USD") == new Money(100m, "USD") return, and what does new Money(100m, "USD") == new Money(100m, "GBP") return?