DEV_NET_CORE
GET_STARTED
.NETC# Language Foundations

Classes, structs, records

Overview

Classes, structs, and records are the main ways to define custom types in C#. They all let you group data and behavior, but they have different semantics for identity, copying, equality, inheritance, mutability, memory usage, and performance.

A class defines a reference type. Classes are the standard choice for domain entities, services, controllers, repositories, dependency-injected components, and objects that have identity or behavior that changes over time. When you assign a class instance to another variable, both variables usually refer to the same object.

A struct defines a value type. Structs are useful for small, lightweight values such as coordinates, money values, measurements, ranges, strongly typed IDs, or date/time-like values. When you assign a struct to another variable, the value is copied. This makes structs useful for immutable value objects, but dangerous when they are large or mutable.

A record is a data-focused type modifier that can be applied to a class or a struct. Records are designed for data models where value-based equality, readable ToString() output, deconstruction, and nondestructive mutation with with expressions are useful. A record or record class is still a reference type, while a record struct is still a value type.

This topic matters because many C# interview questions test whether you understand the difference between reference semantics and value semantics. Interviewers commonly ask when to use a class, struct, record class, or record struct; how equality works; why mutable structs are problematic; how with expressions behave; how boxing affects performance; and why records are not always a replacement for classes.

In real applications, choosing the wrong type can cause bugs, unexpected mutations, unnecessary allocations, poor performance, broken equality logic, or difficult-to-maintain domain models. A strong candidate should be able to explain the trade-offs clearly and choose the correct type based on behavior, identity, size, mutability, and equality requirements.

Core Concepts

Type Categories at a Glance

Classes, structs, and records are all user-defined types, but they are not interchangeable.

Type formCategoryDefault equality styleCopy behaviorInheritanceCommon use
classReference typeReference equality unless overriddenCopies the referenceSupports class inheritanceEntities, services, mutable objects, behavior-heavy models
structValue typeField-based ValueType.Equals, but custom equality is often betterCopies the whole valueCannot inherit from another struct or class, but can implement interfacesSmall immutable values, numeric-like values, lightweight data
record / record classReference typeCompiler-generated value equalityCopies the reference; with creates a copied objectCan inherit from another record classDTOs, response models, immutable data, value-like reference objects
record structValue typeCompiler-generated value equalityCopies the whole valueCannot inherit from another type, but can implement interfacesSmall value objects with generated equality and with support
readonly record structValue typeCompiler-generated value equalityCopies the whole valueCannot inherit from another type, but can implement interfacesSmall immutable value objects

The most important distinction is this:

  • Classes and record classes use reference-type semantics.
  • Structs and record structs use value-type semantics.
  • Records add data-focused behavior, but they do not change whether the underlying type is a reference type or value type.

Classes

A class is a reference type. A class instance is an object, and variables of a class type usually store references to that object rather than storing the full object data directly.

Code
public class Customer
{
    public int Id { get; init; }
    public string Name { get; set; } = string.Empty;

    public void Rename(string newName)
    {
        if (string.IsNullOrWhiteSpace(newName))
            throw new ArgumentException("Name is required.", nameof(newName));

        Name = newName;
    }
}

Classes are best when the type has identity, lifecycle, behavior, or shared mutable state.

Code
var customer1 = new Customer { Id = 1, Name = "Alice" };
var customer2 = customer1;

customer2.Name = "Bob";

Console.WriteLine(customer1.Name); // Bob
Console.WriteLine(ReferenceEquals(customer1, customer2)); // True

Both variables refer to the same object. This is why classes are natural for domain entities such as Customer, Order, User, or Invoice. Two customers with the same name are not necessarily the same customer; identity matters.

Important class features include:

  • Instance members, static members, fields, properties, methods, events, and constructors.
  • Inheritance with base, virtual, override, abstract, and sealed.
  • Interface implementation.
  • Encapsulation using access modifiers such as public, private, protected, and internal.
  • Nullable reference type analysis when enabled.
  • Finalizers, although finalizers should be rare and are mainly for unmanaged resource cleanup.

A class can also use a primary constructor in modern C#:

Code
public class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
    public async Task SubmitAsync(int orderId)
    {
        logger.LogInformation("Submitting order {OrderId}", orderId);
        var order = await repository.GetByIdAsync(orderId);
        order.Submit();
        await repository.SaveAsync(order);
    }
}

For non-record classes, primary constructor parameters are parameters, not automatically generated public properties. If you want a property, you must declare one:

Code
public class Product(string name)
{
    public string Name { get; } = name;
}

Best practices for classes:

  • Use classes for entities and behavior-rich objects.
  • Keep fields private and expose behavior through methods or controlled properties.
  • Prefer immutability where possible for simple data models.
  • Use sealed when inheritance is not intended.
  • Override Equals and GetHashCode only when the class should use value equality.
  • Avoid large inheritance hierarchies when composition or interfaces are simpler.

Structs

A struct is a value type. A struct variable contains the value itself. Assigning a struct to another variable copies the value.

Code
public readonly struct Money
{
    public Money(decimal amount, string currency)
    {
        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentException("Currency is required.", nameof(currency));

        Amount = amount;
        Currency = currency;
    }

    public decimal Amount { get; }
    public string Currency { get; }

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

Example of value copy behavior:

Code
var price1 = new MutablePoint { X = 10, Y = 20 };
var price2 = price1;

price2.X = 99;

Console.WriteLine(price1.X); // 10
Console.WriteLine(price2.X); // 99

public struct MutablePoint
{
    public int X { get; set; }
    public int Y { get; set; }
}

The two variables are independent copies. This is useful for values, but it can surprise developers when a struct is mutable.

A common interview trap is saying "structs are always stored on the stack." That is too simple and often wrong. A value type can be stored inline in another object, in an array, in a local variable, or boxed on the managed heap when converted to object or an interface. The key idea is not "stack vs heap"; the key idea is value semantics.

Structs are useful when the type is:

  • Small.
  • Immutable or effectively immutable.
  • Value-like rather than identity-like.
  • Frequently created.
  • Not intended for inheritance.
  • Safe to copy.

Examples include DateTime, TimeSpan, Guid, coordinates, ranges, IDs, and small domain value objects.

Structs have several important rules and trade-offs:

  • They implicitly inherit from System.ValueType.
  • They cannot inherit from another class or struct.
  • They can implement interfaces.
  • They can have constructors, methods, properties, fields, operators, and static members.
  • They cannot declare a finalizer.
  • They are always defaultable. default(MyStruct) creates a zero/default value.
  • Large structs can be expensive because copying copies the whole value.
  • Mutable structs are error-prone because copies may be modified instead of the original.
  • Boxing a struct creates an object allocation.

Modern C# supports readonly struct to express immutability:

Code
public readonly struct Percentage
{
    public Percentage(decimal value)
    {
        if (value < 0 || value > 100)
            throw new ArgumentOutOfRangeException(nameof(value));

        Value = value;
    }

    public decimal Value { get; }
}

A readonly struct communicates that instance state should not change after construction. This can reduce defensive copying and make the type safer to use.

Struct Default Values and Constructors

Every struct has a default value. Default initialization sets fields to their default values, such as 0, false, or null for reference-type fields.

Code
public struct ReportId
{
    public int Value { get; }

    public ReportId(int value)
    {
        if (value <= 0)
            throw new ArgumentOutOfRangeException(nameof(value));

        Value = value;
    }
}

ReportId id1 = new ReportId(10);
ReportId id2 = default;

Console.WriteLine(id1.Value); // 10
Console.WriteLine(id2.Value); // 0

This is important because constructors cannot prevent default(T) from existing for structs. If 0 is invalid for a domain concept, a struct can still be default-initialized to that invalid state unless the code guards against it.

Modern C# allows parameterless constructors in structs, but default(T) still produces the default zero-initialized value. This matters when designing domain value objects.

Code
public struct Counter
{
    public int Value { get; }

    public Counter()
    {
        Value = 1;
    }
}

var a = new Counter(); // Calls parameterless constructor
var b = default(Counter); // Default value; Value is 0

Best practices for structs:

  • Prefer readonly struct for value objects.
  • Keep structs small.
  • Avoid mutable public properties on structs.
  • Implement IEquatable<T> for custom value equality and better performance.
  • Avoid using structs for complex domain entities.
  • Be careful with default values.
  • Avoid boxing in performance-sensitive code.

Records

A record is a type designed to model data. The record keyword adds compiler-generated functionality such as value equality, GetHashCode, ToString, deconstruction for positional records, and support for with expressions.

There are three common forms:

Code
public record CustomerDto(int Id, string Name); // Same as record class

public record class ProductDto(int Id, string Name); // Explicit record class

public record struct PointDto(int X, int Y); // Value type record

public readonly record struct MoneyDto(decimal Amount, string Currency); // Immutable value type record

A record by itself means record class, so it is a reference type:

Code
public record UserProfile(int Id, string DisplayName);

The compiler generates useful members for records:

Code
var user1 = new UserProfile(1, "Alice");
var user2 = new UserProfile(1, "Alice");

Console.WriteLine(user1 == user2); // True
Console.WriteLine(user1); // UserProfile { Id = 1, DisplayName = Alice }

var renamed = user1 with { DisplayName = "Alicia" };
Console.WriteLine(renamed); // UserProfile { Id = 1, DisplayName = Alicia }

Records are good for:

  • DTOs.
  • API request and response models.
  • Query result models.
  • Configuration snapshots.
  • Immutable data transfer.
  • Value objects where equality is based on data.

Records are not always good for:

  • Entity Framework Core entities that rely on identity and change tracking.
  • Mutable domain entities with lifecycle and behavior.
  • Types where reference identity matters.
  • Data containing mutable reference-type properties if deep immutability is expected.

A common mistake is assuming records are always immutable. Records encourage immutability, but they do not guarantee deep immutability.

Code
public record Team(string Name, List<string> Members);

var team1 = new Team("Core", new List<string> { "Alice" });
var team2 = team1 with { Name = "Platform" };

team2.Members.Add("Bob");

Console.WriteLine(team1.Members.Count); // 2

The with expression copied the record, but both records still reference the same List<string>. This is a shallow copy.

Record Class vs Record Struct

A record class is a reference type with value-based equality.

Code
public record EmployeeDto(int Id, string Name);

var e1 = new EmployeeDto(1, "Alice");
var e2 = e1;
var e3 = new EmployeeDto(1, "Alice");

Console.WriteLine(ReferenceEquals(e1, e2)); // True
Console.WriteLine(e1 == e3); // True because records use value equality

A record struct is a value type with value-based equality.

Code
public record struct Coordinate(double Latitude, double Longitude);

var c1 = new Coordinate(10, 20);
var c2 = c1;

c2.Latitude = 99;

Console.WriteLine(c1.Latitude); // 10
Console.WriteLine(c2.Latitude); // 99

By default, positional record struct properties are mutable. Use readonly record struct when you want immutable value semantics.

Code
public readonly record struct Coordinate(double Latitude, double Longitude);

Choosing between them:

  • Use record class when you want data-focused behavior and reference-type semantics.
  • Use record struct when the data is small and value-copy semantics are desired.
  • Use readonly record struct for small immutable value objects.

Equality: Reference Equality, Value Equality, and Generated Equality

Equality is one of the most important interview topics for classes, structs, and records.

For a normal class, two variables are equal by default only when they refer to the same object.

Code
public class PersonClass
{
    public string Name { get; init; } = string.Empty;
}

var p1 = new PersonClass { Name = "Alice" };
var p2 = new PersonClass { Name = "Alice" };

Console.WriteLine(p1 == p2); // False

For a record class, two values are equal when the record type and values are equal.

Code
public record PersonRecord(string Name);

var r1 = new PersonRecord("Alice");
var r2 = new PersonRecord("Alice");

Console.WriteLine(r1 == r2); // True

For a struct, default equality is inherited from ValueType, but for production code you often implement IEquatable<T> to define equality explicitly and avoid slower default behavior.

Code
public readonly struct ProductCode : IEquatable<ProductCode>
{
    public ProductCode(string value)
    {
        Value = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Product code is required.", nameof(value))
            : value;
    }

    public string Value { get; }

    public bool Equals(ProductCode other) =>
        string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase);

    public override bool Equals(object? obj) =>
        obj is ProductCode other && Equals(other);

    public override int GetHashCode() =>
        StringComparer.OrdinalIgnoreCase.GetHashCode(Value);

    public static bool operator ==(ProductCode left, ProductCode right) => left.Equals(right);
    public static bool operator !=(ProductCode left, ProductCode right) => !left.Equals(right);
}

When overriding equality, always keep Equals, GetHashCode, and equality operators consistent. This matters for dictionaries, hash sets, LINQ Distinct, grouping, caching, and tests.

Mutability and Immutability

Mutability means the state of an object can change after creation. Immutability means the state cannot be changed after construction.

Classes are often mutable:

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

    public IReadOnlyList<string> Items => _items;

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

Records are often used as immutable data models:

Code
public record OrderSummary(int OrderId, decimal Total, string Status);

var submitted = new OrderSummary(1, 100m, "Draft");
var approved = submitted with { Status = "Approved" };

Structs should usually be immutable:

Code
public readonly record struct CustomerId(int Value);

Important points:

  • init allows a property to be assigned during object initialization, but not changed afterward.
  • readonly struct prevents instance members from modifying struct state.
  • readonly record struct is concise for immutable value objects.
  • Immutability makes code safer for concurrency and easier to reason about.
  • Immutability can require more object creation, so consider performance in hot paths.
  • Records with mutable reference-type properties are only shallowly immutable.

Primary Constructors and Positional Syntax

Records commonly use positional syntax:

Code
public record CustomerResponse(int Id, string Name, string Email);

For records, positional parameters generate public properties. For non-record classes and structs, primary constructor parameters do not automatically become public properties.

Code
public class CustomerService(ICustomerRepository repository)
{
    public Task<Customer?> GetAsync(int id) => repository.GetAsync(id);
}

In this class, repository is a constructor parameter in scope within the type. It is not a public property named Repository.

For a record, the parameters become properties:

Code
public record CustomerResponse(int Id, string Name);

var response = new CustomerResponse(1, "Alice");
Console.WriteLine(response.Id); // 1

This difference is a common interview question because the syntax looks similar but generates different members.

Inheritance and Interfaces

Classes support inheritance:

Code
public abstract class PaymentMethod
{
    public abstract void Pay(decimal amount);
}

public class CreditCardPayment : PaymentMethod
{
    public override void Pay(decimal amount)
    {
        Console.WriteLine($"Paid {amount} by credit card.");
    }
}

Structs do not support class-style inheritance, but they can implement interfaces:

Code
public readonly struct Meter : IComparable<Meter>
{
    public Meter(decimal value) => Value = value;

    public decimal Value { get; }

    public int CompareTo(Meter other) => Value.CompareTo(other.Value);
}

Record classes can inherit from other record classes:

Code
public record Person(string Name);
public record Employee(string Name, int EmployeeId) : Person(Name);

But record structs cannot inherit because structs cannot inherit from other structs or classes.

Prefer inheritance only when there is a true substitutable relationship. For many applications, interfaces and composition produce simpler designs.

Boxing and Unboxing

Boxing occurs when a value type is converted to object or an interface type. The runtime wraps the value in a reference-type object.

Code
int number = 42;
object boxed = number;     // Boxing
int unboxed = (int)boxed;   // Unboxing

Boxing can also happen with structs:

Code
public readonly struct CustomerId
{
    public CustomerId(int value) => Value = value;
    public int Value { get; }
}

CustomerId id = new(10);
object boxedId = id; // Boxing allocation

Boxing matters because it can create allocations and reduce performance in tight loops or high-throughput systems. Generic interfaces such as IEquatable<T> and generic collections such as List<T> help avoid boxing.

Choosing Between Class, Struct, Record Class, and Record Struct

Use a class when:

  • The type has identity.
  • The type has behavior and lifecycle.
  • The type is large or expensive to copy.
  • The type is used with dependency injection.
  • The type needs inheritance.
  • The type is an EF Core entity or aggregate root.

Use a struct when:

  • The type is small and value-like.
  • Copying the value is safe and cheap.
  • The type should not have identity.
  • You want to reduce allocations in carefully chosen scenarios.
  • The type can be immutable or effectively immutable.

Use a record class when:

  • You want reference-type semantics but value-based equality.
  • The type is primarily data.
  • The type is useful as a DTO, query result, or immutable model.
  • You want with, readable ToString, and deconstruction.
  • The type may be too large for struct copying.

Use a record struct when:

  • You want value-type semantics and generated equality.
  • The type is small and self-contained.
  • You want a concise value object.
  • You want with support without writing equality boilerplate.

Use a readonly record struct when:

  • You want a small immutable value object.
  • You want generated equality.
  • You want to avoid accidental mutation.
  • You want concise syntax for strongly typed IDs or simple values.

Example strongly typed ID:

Code
public readonly record struct CustomerId(int Value);

public class Customer
{
    public CustomerId Id { get; init; }
    public string Name { get; set; } = string.Empty;
}

This avoids accidentally passing an OrderId where a CustomerId is expected, even if both wrap an int.

Common Mistakes

Common mistakes include:

  • Using a mutable struct with settable properties.
  • Using a large struct that gets copied frequently.
  • Assuming all structs are allocated on the stack.
  • Using records for EF Core entities without understanding tracking and identity implications.
  • Assuming with performs a deep copy.
  • Assuming records are automatically deeply immutable.
  • Forgetting that record means record class by default.
  • Forgetting that record struct positional properties are mutable by default.
  • Forgetting that structs always have a default value.
  • Overriding Equals without overriding GetHashCode.
  • Using class inheritance when interfaces or composition would be simpler.
  • Treating DTOs, entities, and value objects as the same kind of model.

Practical Design Examples

A typical Web API might use all of these type kinds together:

Code
// Entity: identity and lifecycle matter
public class Customer
{
    public CustomerId Id { get; private set; }
    public string Name { get; private set; }

    public Customer(CustomerId id, string name)
    {
        Id = id;
        Name = name;
    }

    public void Rename(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Name is required.", nameof(name));

        Name = name;
    }
}

// Small immutable value object
public readonly record struct CustomerId(int Value);

// API response DTO
public record CustomerResponse(int Id, string Name);

// Service: behavior and dependencies matter
public class CustomerService(ICustomerRepository repository)
{
    public async Task<CustomerResponse?> GetAsync(CustomerId id)
    {
        var customer = await repository.GetAsync(id);
        return customer is null
            ? null
            : new CustomerResponse(customer.Id.Value, customer.Name);
    }
}

This design uses:

  • A class for the domain entity because identity and behavior matter.
  • A readonly record struct for a small strongly typed ID.
  • A record class for the API response because it is data-focused.
  • A class for the service because it has dependencies and behavior.

Interview Practice

Next UpCollection Choices in C#