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 |
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");
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 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 structfor 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
- Microsoft Docs: Records (C# Reference)
- C# 10: Record Structs
- EF Core 8: Complex Types
- Martin Fowler: Value Object Pattern
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.