C# Records vs Classes vs Structs in .NET: Which Should Your Team Use and When?

When a pull request introduces a record where a class used to be, a struct tucked into a hot loop, or a mix of all three across a service boundary β most teams just merge and move on. The choice between C# records, classes, and structs in .NET feels minor until it creates subtle bugs around equality, unexpected allocations in performance-sensitive paths, or confusing semantics in your domain model. In enterprise .NET teams, these decisions compound fast.
π Want implementation-ready .NET source code you can drop straight into your project? Join Coding Droplets on Patreon for exclusive tutorials, premium code samples, and early access to new content. π https://www.patreon.com/CodingDroplets
This comparison cuts through the noise on C# records vs classes vs structs. You will get a clear picture of what each type does differently, where each belongs in ASP.NET Core applications, and how to make the call confidently on a team where preferences vary.
What Makes Each Type Different at the Core
All three are user-defined types in C#, but they answer a different question.
A class is a reference type with identity semantics. Two class instances are equal only if they reference the same object in memory β unless you override Equals manually. Classes support full inheritance hierarchies, mutable state, and rich behaviour. They live on the heap.
A struct is a value type. It is copied on assignment, stored on the stack when local, and compared by value automatically (with some cost caveats). Structs have no inheritance chain (beyond System.ValueType), cannot be null without wrapping in Nullable<T>, and are best suited to small, short-lived, logically atomic data.
A record is a type modifier, not an entirely different kind of type. You can have a record class (heap-allocated, reference semantics) or a record struct (value-type, stack-friendly). What records add over plain classes or structs is compiler-generated value-based equality, a ToString() override, with-expression support for non-destructive mutation, and positional syntax for concise declarations. Records signal intent: this type is primarily about data, not behaviour.
Side-by-Side Comparison
| Feature | Class | Struct | Record (class) | Record Struct |
|---|---|---|---|---|
| Memory | Heap | Stack / inline | Heap | Stack / inline |
| Default equality | Reference | Value (field-by-field) | Value (compiler-generated) | Value (compiler-generated) |
| Immutability | Manual | Manual | Default (init-only props) | Opt-in |
| Null assignment | β | β (needs Nullable<T>) |
β | β (needs Nullable<T>) |
| Inheritance | β Full | β | Limited (sealed or open record) | β |
with expression |
β | β | β | β |
| Deconstruction | Manual | Manual | β (positional) | β (positional) |
| Performance | Baseline | High (small types) | Same as class | Same as struct |
| Best fit | Domain entities, services | Value objects, small data | DTOs, API contracts, events | High-throughput value payloads |
When to Use Classes
Classes remain the default for anything that has identity, behaviour, and mutable state. Domain entities are the clearest example. An Order that changes state throughout its lifecycle, an Invoice that accumulates lines, a UserSession that tracks activity β all of these are classes. They are not interchangeable by value; each instance is distinct, and your code relies on that identity.
Services, repositories, controllers, middleware, handlers β all classes. If you are reaching for DI, inheritance, or complex lifecycle management, you are working with a class.
Anti-patterns to watch for:
- Using a class for a DTO and then manually implementing
EqualsandGetHashCodeβ that is what records are for. - Adding mutable properties to a class that semantically represents a value β consider a record or a value object pattern instead.
- Creating
abstractbase classes in a hierarchy where only one or two behaviours actually differ β composition usually wins.
When to Use Records
Records shine when the type represents data you want to compare by value without writing boilerplate. The most common use cases in ASP.NET Core:
API request and response DTOs. A CreateOrderRequest or ProductSummaryResponse is pure data. Records give you immutability by default, structural equality for testing, and the with expression for building modified versions in tests or validation pipelines β all without a single line of manual override.
Domain events. Events are immutable facts. An OrderPlacedEvent carries data that should never change after creation. Records communicate that intent explicitly and protect it structurally.
Query parameters and filter objects. Passing a ProductFilterQuery record through a CQRS query pipeline is cleaner than a class β equality comparisons in caching layers work correctly without extra code.
Value objects in DDD. Records make excellent lightweight value objects for things like Money, Address, or EmailAddress when the type does not need a full class-based encapsulation. The with expression supports immutable transformation naturally.
Where records cause friction:
- When you need deep inheritance hierarchies (records support limited inheritance).
- When the type has significant mutable state β records can be mutable, but it fights the intent.
- When you need to serialise to and from JSON and the default deserialization needs
init-only setter support (check your serializer version βSystem.Text.Jsonhandles this well from .NET 6+, but older Newtonsoft configurations may not).
When to Use Structs
Structs are a performance tool, not a default choice. They are appropriate when:
- The type is logically a single value: a coordinate (
Point), a 2D vector, a colour (Color), a currency-amount pair. - The data is small β Microsoft guidance is under 16 bytes, though this is a soft rule.
- Instances are frequently created and discarded in tight loops β avoiding heap allocation and GC pressure matters.
- The type will live on the stack (local variable, parameter, field of another value type) rather than being boxed repeatedly.
In ASP.NET Core APIs, the most practical struct candidates are internal computational types β things used inside a hot path, not as API contracts. Avoid structs at service boundaries where boxing, copying overhead, or nullable coercion will eat any performance benefit.
Struct traps that matter in production:
- A struct larger than ~16β24 bytes passed by value repeatedly has worse performance than a reference type due to copy overhead.
- Default value equality uses reflection for
System.ValueType.Equalsunless you implement it β a significant performance issue in high-frequency use. - Structs are not polymorphic. You cannot use them as base types or in inheritance-based dispatch.
- Mutable structs accessed via interfaces are boxed silently, turning a performance win into a heap allocation.
The Type Selection Decision Framework
Use this flow for your team:
Does the type represent a real-world entity with identity and lifecycle?
β Class. It will be tracked, mutated, persisted, and referenced by ID.
Does the type represent an immutable data payload (request, response, event, query parameter)?
β Record (class). You get equality, immutability, and clarity for free.
Does the type represent a logically atomic value (a coordinate, a measurement, an amount)?
β Consider a record struct if it is small and short-lived, or a plain struct if you need the absolute minimum overhead.
Is the type used in a hot computation path with thousands of allocations per second?
β Profile first. If allocation is confirmed as the bottleneck, struct or record struct. Otherwise, do not optimise prematurely.
Does the type need to participate in DI, polymorphism, or inheritance?
β Class. Structs and records cannot serve as base types in meaningful inheritance chains.
Entity Framework Core and Serialization Considerations
EF Core's change tracking works by reference identity β DbContext tracks class instances. Using records as EF Core entities is technically possible but fights the framework's conventions: Equals based on value rather than reference identity can confuse change tracking when you have multiple instances with the same key. Keep EF Core entities as classes.
For owned types and value objects within EF Core (a Money value object inside an Order entity), records mapped as owned entities can work, but test the equality behaviour explicitly β EF Core's owned entity tracking has its own expectations.
For JSON serialisation with System.Text.Json, records with positional constructors or init-only properties work correctly from .NET 6+. If you use [JsonConstructor] on a primary constructor, ensure the parameter names match the property names (case-insensitive by default).
Governing Type Choices Across a Team
Left undirected, .NET teams end up with inconsistent choices: records used as domain entities, classes used as DTOs, structs used where records would be cleaner. A few governance rules go a long way:
- Prefer
recordfor all DTOs and API contracts by default. Make classes the exception that requires justification, not the default. - Require code review discussion for new
structtypes. The developer should articulate the performance reason. - Use
sealedon records where inheritance is not intended. This signals intent and avoids accidental extension. - Add an architectural decision record (ADR) the first time your team picks a convention on records vs classes for domain events or value objects. Future team members will thank you.
Internal links worth reading alongside this: ASP.NET Core Request Validation: FluentValidation vs DataAnnotations vs Built-In and ASP.NET Core DI Lifetimes: Singleton vs. Scoped vs. Transient β both areas where type semantics interact directly with your choices here.
For the official language specification and deeper compiler behaviour, Microsoft's C# reference on records and the type system overview are the authoritative sources.
β Prefer a one-time tip? Buy us a coffee β every bit helps keep the content coming!
FAQ
Should I use records for all DTOs in ASP.NET Core?
For the vast majority of cases, yes. Records give you immutability by default, structural equality that makes unit testing straightforward, and the with expression for building test variations without boilerplate. The only exceptions are DTOs bound to frameworks that require mutable properties with parameterless constructors and no init-only support β check your serialiser and model-binding configuration before committing.
Do records perform differently than classes in .NET?
A record class compiles to a class with compiler-generated members β at runtime, it has identical allocation behaviour to an equivalent class. The only overhead is the generated Equals, GetHashCode, and ToString methods, which are fast and usually faster than manually written alternatives. A record struct has the same cost profile as a regular struct.
Can I use records as EF Core entities?
Technically yes, but it is not recommended. EF Core's change tracking relies on reference identity. Records override Equals with value-based equality, which can confuse the context when you load the same entity twice and compare instances. Keep EF Core entities as plain classes, and use records for owned types and value objects only with careful testing.
What is the difference between a record and a record struct?
A record (without struct) is a reference type allocated on the heap β it behaves like a class with compiler-generated value equality. A record struct is a value type that lives on the stack (when local), is copied on assignment, and cannot be null. Use record struct when you want the semantic clarity and with-expression support of a record but for a small, stack-friendly value type in a performance-sensitive context.
When does a struct actually improve performance over a class?
Only when three conditions hold simultaneously: the type is small (under ~16β24 bytes), instances are created and discarded at high frequency (tight loops, per-request allocations in critical paths), and the instances stay on the stack without boxing. Profile before switching β structs can hurt performance if they are larger than expected, passed to interfaces (causing boxing), or copied repeatedly in generic collections.
Is it safe to mix records and classes in a CQRS pipeline?
Yes, and it is a common, sensible pattern. Commands and queries are natural records β they are immutable data that represent intent. Handlers, repositories, and services are classes. The boundary is clean: records cross service boundaries as data, classes own behaviour within them. Just be mindful of serialisation requirements if commands travel over a message bus.
Can records be mutable?
Yes. You can declare a record with set (not init) properties, making it mutable. However, this undermines the primary reason to choose a record over a class β immutability and the semantics it communicates. Mutable records are a code smell. If you need mutable state, use a class and make the intent explicit.






