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.
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.
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.
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, andsealed. - Interface implementation.
- Encapsulation using access modifiers such as
public,private,protected, andinternal. - 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#:
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:
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
sealedwhen inheritance is not intended. - Override
EqualsandGetHashCodeonly 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.
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:
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:
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.
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.
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 structfor 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:
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:
public record UserProfile(int Id, string DisplayName);
The compiler generates useful members for records:
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.
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.
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.
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.
public readonly record struct Coordinate(double Latitude, double Longitude);
Choosing between them:
- Use
record classwhen you want data-focused behavior and reference-type semantics. - Use
record structwhen the data is small and value-copy semantics are desired. - Use
readonly record structfor 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.
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.
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.
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:
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:
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:
public readonly record struct CustomerId(int Value);
Important points:
initallows a property to be assigned during object initialization, but not changed afterward.readonly structprevents instance members from modifying struct state.readonly record structis 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:
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.
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:
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:
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:
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:
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.
int number = 42;
object boxed = number; // Boxing
int unboxed = (int)boxed; // Unboxing
Boxing can also happen with structs:
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, readableToString, 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
withsupport 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:
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
withperforms a deep copy. - Assuming records are automatically deeply immutable.
- Forgetting that
recordmeansrecord classby default. - Forgetting that
record structpositional properties are mutable by default. - Forgetting that structs always have a default value.
- Overriding
Equalswithout overridingGetHashCode. - 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:
// 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.