C# has evolved significantly with new features for working with data. Records, introduced in C# 9 and enhanced in C# 10 and 11, provide a concise way to create immutable data types with value-based equality. This guide covers records and other modern data features that help you write cleaner, more maintainable code.

Understanding Records

Records are reference types designed for immutable data. They provide value-based equality, a built-in ToString() implementation, and easy cloning with modifications:

// Positional record (primary constructor syntax)
public record Person(string FirstName, string LastName, DateTime DateOfBirth);

// Usage
var person = new Person("John", "Doe", new DateTime(1990, 5, 15));
Console.WriteLine(person); // Person { FirstName = John, LastName = Doe, DateOfBirth = 5/15/1990 }

Records generate several things automatically:

  • Primary constructor with parameters
  • Public init-only properties
  • Value-based Equals() and GetHashCode()
  • ToString() with property values
  • A copy constructor and Clone method
  • Deconstruct method for positional records

Record Syntax Variations

There are multiple ways to define records:

// Positional record (most concise)
public record Product(int Id, string Name, decimal Price);

// Standard record with explicit properties
public record Customer
{
    public int Id { get; init; }
    public string Name { get; init; } = string.Empty;
    public string Email { get; init; } = string.Empty;
}

// Positional record with additional members
public record Order(int Id, DateTime OrderDate, decimal Total)
{
    // Additional property
    public OrderStatus Status { get; init; } = OrderStatus.Pending;

    // Computed property
    public bool IsRecent => OrderDate > DateTime.Now.AddDays(-30);

    // Method
    public decimal CalculateTax(decimal rate) => Total * rate;
}

// Record with validation in constructor
public record Email
{
    public string Value { get; }

    public Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new ArgumentException("Email cannot be empty");

        if (!value.Contains('@'))
            throw new ArgumentException("Email must contain @");

        Value = value.ToLowerInvariant();
    }
}

Record Structs

C# 10 introduced record structs for value-type records:

// Record struct (value type)
public readonly record struct Point(double X, double Y);

// Mutable record struct
public record struct MutablePoint(double X, double Y);

// Usage
var point1 = new Point(3.0, 4.0);
var point2 = new Point(3.0, 4.0);
Console.WriteLine(point1 == point2); // True (value equality)

// Record structs are good for small, frequently created values
public readonly record struct Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new(0, currency);

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot add different currencies");
        return new Money(Amount + other.Amount, Currency);
    }

    public override string ToString() => $"{Amount:N2} {Currency}";
}

Value-Based Equality

Records use value-based equality by default:

public record Person(string Name, int Age);

var person1 = new Person("Alice", 30);
var person2 = new Person("Alice", 30);
var person3 = new Person("Bob", 25);

// Equality comparison
Console.WriteLine(person1 == person2);      // True
Console.WriteLine(person1 == person3);      // False
Console.WriteLine(person1.Equals(person2)); // True

// Works with collections
var people = new HashSet<Person>();
people.Add(person1);
people.Add(person2); // Not added (duplicate)
Console.WriteLine(people.Count); // 1

// Reference equality still available
Console.WriteLine(ReferenceEquals(person1, person2)); // False

You can customize equality behavior:

public record Person(string Name, int Age)
{
    // Override to ignore case in Name comparison
    public virtual bool Equals(Person? other)
    {
        return other is not null &&
               string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase) &&
               Age == other.Age;
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Name?.ToUpperInvariant(), Age);
    }
}

The With Expression

Create modified copies of records using the with expression:

public record Person(string FirstName, string LastName, int Age);

var original = new Person("John", "Doe", 30);

// Create a copy with one property changed
var older = original with { Age = 31 };
Console.WriteLine(older); // Person { FirstName = John, LastName = Doe, Age = 31 }

// Create a copy with multiple properties changed
var married = original with { LastName = "Smith", Age = 31 };
Console.WriteLine(married); // Person { FirstName = John, LastName = Smith, Age = 31 }

// Create an exact copy
var copy = original with { };
Console.WriteLine(original == copy);               // True
Console.WriteLine(ReferenceEquals(original, copy)); // False

The with expression is useful for immutable update patterns:

public record ShoppingCart(IReadOnlyList<CartItem> Items, decimal Total)
{
    public ShoppingCart AddItem(CartItem item)
    {
        var newItems = Items.Append(item).ToList();
        return this with
        {
            Items = newItems,
            Total = newItems.Sum(i => i.Price * i.Quantity)
        };
    }

    public ShoppingCart RemoveItem(int productId)
    {
        var newItems = Items.Where(i => i.ProductId != productId).ToList();
        return this with
        {
            Items = newItems,
            Total = newItems.Sum(i => i.Price * i.Quantity)
        };
    }
}

public record CartItem(int ProductId, string Name, decimal Price, int Quantity);

Deconstruction

Positional records automatically support deconstruction:

public record Point(double X, double Y);

var point = new Point(3.0, 4.0);

// Deconstruct into variables
var (x, y) = point;
Console.WriteLine($"X: {x}, Y: {y}");

// Use in pattern matching
var description = point switch
{
    (0, 0) => "Origin",
    (0, _) => "On Y-axis",
    (_, 0) => "On X-axis",
    (var px, var py) when px == py => "On diagonal",
    _ => "General point"
};

// Deconstruct in foreach
public record NamedValue(string Name, int Value);

var items = new List<NamedValue>
{
    new("First", 1),
    new("Second", 2),
    new("Third", 3)
};

foreach (var (name, value) in items)
{
    Console.WriteLine($"{name}: {value}");
}

Init-Only Properties

The init accessor allows properties to be set only during initialization:

public class Configuration
{
    public string ConnectionString { get; init; } = string.Empty;
    public int MaxRetries { get; init; } = 3;
    public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}

// Can set during object initialization
var config = new Configuration
{
    ConnectionString = "Server=localhost;Database=MyDb",
    MaxRetries = 5
};

// Cannot modify after initialization
// config.MaxRetries = 10; // Compile error

Required Members (C# 11)

Mark properties as required to ensure they are set during initialization:

public class User
{
    public required string Username { get; init; }
    public required string Email { get; init; }
    public string? DisplayName { get; init; }
}

// Must provide required properties
var user = new User
{
    Username = "johndoe",
    Email = "john@example.com"
    // DisplayName is optional
};

// This would be a compile error:
// var invalid = new User { Username = "test" }; // Missing Email

// Using with constructor
public class Product
{
    public required string Name { get; init; }
    public required decimal Price { get; init; }

    [SetsRequiredMembers]
    public Product(string name, decimal price)
    {
        Name = name;
        Price = price;
    }
}

Record Inheritance

Records support inheritance:

public abstract record Shape(string Name);

public record Circle(string Name, double Radius) : Shape(Name)
{
    public double Area => Math.PI * Radius * Radius;
    public double Circumference => 2 * Math.PI * Radius;
}

public record Rectangle(string Name, double Width, double Height) : Shape(Name)
{
    public double Area => Width * Height;
    public double Perimeter => 2 * (Width + Height);
}

// Usage
Shape shape = new Circle("My Circle", 5.0);

var result = shape switch
{
    Circle c => $"Circle with area {c.Area:F2}",
    Rectangle r => $"Rectangle with area {r.Area:F2}",
    _ => "Unknown shape"
};

// Equality respects inheritance
var circle1 = new Circle("Circle", 5.0);
var circle2 = new Circle("Circle", 5.0);
Console.WriteLine(circle1 == circle2); // True

Records for Domain Modeling

Records are excellent for domain modeling and value objects:

// Value objects
public record EmailAddress
{
    public string Value { get; }

    public EmailAddress(string value)
    {
        if (!IsValid(value))
            throw new ArgumentException("Invalid email address", nameof(value));
        Value = value.ToLowerInvariant();
    }

    private static bool IsValid(string email) =>
        !string.IsNullOrWhiteSpace(email) && email.Contains('@');

    public override string ToString() => Value;
}

public record Money(decimal Amount, Currency Currency)
{
    public static Money USD(decimal amount) => new(amount, Currency.USD);
    public static Money EUR(decimal amount) => new(amount, Currency.EUR);

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Currency mismatch");
        return this with { Amount = Amount + other.Amount };
    }

    public override string ToString() => $"{Currency} {Amount:N2}";
}

public enum Currency { USD, EUR, GBP }

// Domain events
public abstract record DomainEvent(DateTime OccurredAt);

public record OrderPlaced(Guid OrderId, Guid CustomerId, Money Total, DateTime OccurredAt)
    : DomainEvent(OccurredAt);

public record OrderShipped(Guid OrderId, string TrackingNumber, DateTime OccurredAt)
    : DomainEvent(OccurredAt);

public record OrderCancelled(Guid OrderId, string Reason, DateTime OccurredAt)
    : DomainEvent(OccurredAt);

// DTOs
public record CreateOrderRequest(
    Guid CustomerId,
    IReadOnlyList<OrderItemRequest> Items,
    ShippingAddressRequest ShippingAddress);

public record OrderItemRequest(Guid ProductId, int Quantity);

public record ShippingAddressRequest(
    string Street,
    string City,
    string State,
    string PostalCode,
    string Country);

Records vs Classes

Choose records when:

  • You need value-based equality
  • The data is primarily immutable
  • You want concise syntax for data types
  • You need easy copying with modifications

Choose classes when:

  • You need reference-based equality
  • The object has significant behavior beyond data
  • You need mutable state
  • You are modeling entities with identity
// Use record: Value object with no identity
public record Address(string Street, string City, string PostalCode);

// Use class: Entity with identity and behavior
public class Customer
{
    public Guid Id { get; private set; } = Guid.NewGuid();
    public string Name { get; private set; }
    public List<Order> Orders { get; } = new();

    public Customer(string name)
    {
        Name = name;
    }

    public void PlaceOrder(Order order)
    {
        Orders.Add(order);
        // Raise domain event, etc.
    }
}

// Use record struct: Small, frequently allocated value types
public readonly record struct Vector2(float X, float Y)
{
    public static Vector2 Zero => new(0, 0);
    public float Length => MathF.Sqrt(X * X + Y * Y);
}

Primary Constructors (C# 12)

C# 12 extends primary constructors to classes and structs:

// Primary constructor for class
public class UserService(IUserRepository repository, ILogger<UserService> logger)
{
    public async Task<User?> GetUserAsync(int id)
    {
        logger.LogInformation("Getting user {Id}", id);
        return await repository.GetByIdAsync(id);
    }
}

// Primary constructor for struct
public struct Temperature(double celsius)
{
    public double Celsius => celsius;
    public double Fahrenheit => celsius * 9 / 5 + 32;
    public double Kelvin => celsius + 273.15;
}

Conclusion

Records and modern data types in C# provide powerful tools for writing clean, maintainable code. They reduce boilerplate, enforce immutability, and make your intent clear.

Key takeaways:

  • Use records for data-centric types with value semantics
  • Use record structs for small, frequently allocated value types
  • The with expression enables immutable update patterns
  • Init-only and required members enforce proper initialization
  • Records work well for DTOs, value objects, and domain events
  • Choose between records and classes based on identity and behavior needs

With these patterns, you can write code that is more concise, safer, and easier to understand and maintain.