Mastering C# Records: When, Why, and How to Use Them in Production

After years of working with C# in enterprise environments, I’ve seen developers struggle with the same question: when should I use a record instead of a class? The answer isn’t as straightforward as the documentation suggests. In this article, I’ll share my perspective on records—battle-tested patterns, surprising gotchas, and the decision framework I use in production systems.

Why Records Changed How I Think About Data

When C# 9 introduced records, I was skeptical. Another way to define types? We already had classes and structs. But after using records extensively in .NET 8 and now .NET 10, I’ve come to appreciate their elegance for specific use cases. Records aren’t a replacement for classes—they’re a tool for expressing intent.

A record says: “This type represents data, not behavior. Two instances with the same values are equal. Immutability is the default.”

The Decision Framework: Record vs Class vs Struct

Here’s the mental model I use when choosing between types:

Criteria Record Class Struct
Primary purpose Immutable data Behavior + state Small value types
Equality Value-based ✓ Reference-based Value-based
Heap/Stack Heap (reference) Heap (reference) Stack (value)
Inheritance Yes (records only) Yes No
with expression ✓ Built-in ✓ (.NET 10)
Deconstruction ✓ Automatic Manual Manual
💡
MY RULE OF THUMB

If you’re writing Equals() and GetHashCode() overrides for a class, you probably want a record. If you’re creating DTOs, API responses, or event payloads, start with a record.

Record Syntax: The Three Flavors

C# gives us three ways to define records. Each has its place:

1. Positional Records (My Default)

// Concise, immutable by default, perfect for DTOs
public record PatientSummary(
    string PatientId,
    string FullName,
    DateOnly DateOfBirth,
    string? Email);

// Usage
var patient = new PatientSummary("P001", "John Murphy", new DateOnly(1985, 3, 15), "john@email.com");

// Non-destructive mutation with 'with'
var updated = patient with { Email = "john.murphy@newmail.com" };

// Deconstruction
var (id, name, dob, email) = patient;

2. Standard Record Syntax

// When you need more control or computed properties
public record Appointment
{
    public required string AppointmentId { get; init; }
    public required string PatientId { get; init; }
    public required DateTime ScheduledAt { get; init; }
    public TimeSpan Duration { get; init; } = TimeSpan.FromMinutes(30);
    
    // Computed property
    public DateTime EndsAt => ScheduledAt.Add(Duration);
    
    // Validation in constructor
    public Appointment()
    {
        if (Duration <= TimeSpan.Zero)
            throw new ArgumentException("Duration must be positive");
    }
}

3. Record Struct (Value Type Record)

// For small, frequently-created values where allocation matters
public readonly record struct Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new(0, currency);
    
    public static Money operator +(Money a, Money b)
    {
        if (a.Currency != b.Currency)
            throw new InvalidOperationException("Currency mismatch");
        return new Money(a.Amount + b.Amount, a.Currency);
    }
}

// No heap allocation - lives on the stack
var price = new Money(99.99m, "EUR");
⚠️
RECORD STRUCT GOTCHA

Unlike record class, a record struct is mutable by default. Always use readonly record struct for true immutability. This catches many developers off guard.

Records in Domain-Driven Design

Records shine in DDD for Value Objects. Here's a pattern I use extensively:

// Value Object: Email Address
public record EmailAddress
{
    public string Value { get; }
    
    public EmailAddress(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new ArgumentException("Email cannot be empty");
        
        if (!value.Contains('@'))
            throw new ArgumentException("Invalid email format");
            
        Value = value.ToLowerInvariant().Trim();
    }
    
    public static implicit operator string(EmailAddress email) => email.Value;
    public override string ToString() => Value;
}

// Value Object: Money with currency safety
public record Money(decimal Amount, Currency Currency)
{
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new CurrencyMismatchException(Currency, other.Currency);
        return this with { Amount = Amount + other.Amount };
    }
}

// Usage in an Entity
public class Patient
{
    public PatientId Id { get; private set; }
    public EmailAddress Email { get; private set; }
    public PersonName Name { get; private set; }
    
    public void UpdateEmail(EmailAddress newEmail)
    {
        // Value equality makes this check trivial
        if (Email == newEmail) return;
        
        Email = newEmail;
        AddDomainEvent(new PatientEmailUpdated(Id, newEmail));
    }
}

Records with Entity Framework Core

This is where things get nuanced. EF Core and records can work together, but you need to understand the implications.

✅ DO: Use Records for Owned Types

public record Address(
    string Street,
    string City,
    string PostalCode,
    string Country);

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public Address BillingAddress { get; set; } = null!;
    public Address? ShippingAddress { get; set; }
}

// DbContext configuration
modelBuilder.Entity<Customer>()
    .OwnsOne(c => c.BillingAddress);
modelBuilder.Entity<Customer>()
    .OwnsOne(c => c.ShippingAddress);

❌ AVOID: Records as Entity Primary Keys

// This works but causes change tracking issues
public record CustomerId(Guid Value);

public class Customer
{
    public CustomerId Id { get; set; } // EF tracking problems
}

// Better: Use the primitive directly for entities
public class Customer
{
    public Guid Id { get; set; }
    // Use CustomerId as a Value Object in domain layer, map to Guid for persistence
}
📝
EF CORE 8+ IMPROVEMENT

EF Core 8 introduced better support for immutable types and records. Complex type mapping now handles records more gracefully, but I still recommend keeping entities as mutable classes and using records for value objects and DTOs.

Performance: When Records Cost You

Records aren't free. Here's what I've measured in production:

Operation Record Class Record Struct Recommendation
Creation (1M objects) ~45ms ~12ms Use struct for hot paths
Equality check ~5ns ~3ns Both excellent
'with' expression ~25ns (allocation) ~8ns (copy) Struct wins for frequent mutations
Dictionary key Excellent Excellent Both have good GetHashCode

Anti-Patterns I've Seen (and Made)

❌ Mutable Records

// DON'T: This defeats the purpose
public record MutablePatient
{
    public string Name { get; set; } = ""; // Mutable!
}

// DO: Use init or positional parameters
public record ImmutablePatient(string Name);

❌ Records with Collections

// DON'T: Collections break immutability
public record Order(string Id, List<OrderItem> Items);

var order1 = new Order("001", new List<OrderItem> { item1 });
var order2 = order1 with { }; // Same list reference!
order2.Items.Add(item2); // Mutates order1 too!

// DO: Use immutable collections
public record Order(string Id, ImmutableList<OrderItem> Items)
{
    public Order AddItem(OrderItem item) => 
        this with { Items = Items.Add(item) };
}

Key Takeaways

  • Use records for DTOs, API contracts, and Value Objects - They express intent clearly
  • Prefer positional syntax - Concise and ensures immutability
  • Use readonly record struct for hot paths - Avoid heap allocations
  • Records excel as Dictionary keys - Built-in value equality
  • ⚠️ Be careful with EF Core entities - Keep entities as classes
  • ⚠️ Watch for mutable collections - Use ImmutableList/ImmutableArray
  • Don't use mutable properties - Defeats the purpose of records

Conclusion

Records have become an essential tool in my C# toolkit. They're not a silver bullet, but for data-centric types—DTOs, events, value objects—they reduce boilerplate and make intent crystal clear. Start with positional records for new code, measure performance in hot paths, and remember: immutability is a feature, not a constraint.

The patterns I've shared here come from years of using records in production healthcare and financial systems. They work. But as with any tool, understand when to use it—and when a simple class is the right choice.

References


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.