Modern C# Type System: Choosing the Right Type (Struct, Record, Class)
The mental model we apply to data in our systems shapes everything, from performance profiles to debugging nightmares. For years, the class was the default, often the only real choice for complex data. Then structs lingered in the shadows, mostly for primitive types or niche performance optimizations. Modern C# has dramatically expanded our toolkit, particularly with record types. The question is no longer “class or struct?”, but rather, “which of these finely-tuned instruments is right for the job?”
This isn’t about arbitrary preference; it’s about semantic intent, performance characteristics, and long-term maintainability. With the current stable .NET runtime and C# features, the nuances between class, record class, struct, and record struct are more pronounced and more impactful than ever before. Cloud-native architectures, with their emphasis on immutability, functional patterns, and efficient data transfer, make this decision a front-and-center architectural consideration, not an afterthought.
The Foundation: Identity vs. Value Semantics
At the core of this decision lies a fundamental philosophical distinction: Does this piece of data represent a unique identity that can be referenced and potentially mutated, or does it represent a value whose meaning is derived solely from its content, and which is interchangeable with any other piece of data containing the same content?
class: The Reference Identity
The venerable class type remains the workhorse of object-oriented programming in C#. It’s a reference type, meaning variables hold references to objects on the heap, and two variables can reference the same object.
- Identity: A
classinstance has a unique identity in memory.==by default checks for reference equality. This is crucial for entities where identity matters, like aUserin a database, aServiceinstance in a DI container, or any object whose lifecycle or side-effects are tied to its specific instance. - Mutability:
classinstances are mutable by default. While you can enforce immutability withreadonlyfields andinitproperties, it’s an opt-in discipline. - Inheritance & Polymorphism:
classtypes fully support inheritance and polymorphism, making them ideal for complex object hierarchies. - Memory: Allocated on the managed heap, subject to garbage collection. References consume 8 bytes (on 64-bit systems). Passing a class instance only copies the reference, not the entire object.
Use a class when: identity is important, you need inheritance, or the object inherently manages complex mutable state that is shared.
struct: The Value Copy
struct types are value types. Unlike classes, a variable of a struct type directly contains its data. When assigned or passed as an argument, the entire struct is copied.
- Value Semantics:
==by default performs a member-wise comparison. Twostructs are equal if their fields are equal, regardless of memory location. - Immutability (Crucial!): Always design
structs to be immutable. A mutablestructis a source of subtle bugs, as modifying a copy won’t affect the original. Usereadonly structto enforce this at compile time. - Memory: Typically allocated on the stack (for local variables) or inline within another object (if it’s a field of a class or another struct). This can reduce heap allocations and GC pressure, improving cache locality. However, large
structs (>16-32 bytes, a rule of thumb) can be detrimental, as copying them becomes expensive. Boxing (converting a value type to an object) can also negate performance benefits and introduce heap allocations. - No Inheritance:
structs cannot inherit from other types (besidesSystem.ValueType).
Use a readonly struct for small, immutable value types where identity is irrelevant, and copying them is inexpensive (e.g., Point, Guid, Money, RgbColor). They are perfect for truly independent “values.”
record: The Immutable Data Carrier
record types, introduced in C# 9, are a powerful abstraction for data-centric types. They come in two flavors: record class (reference type) and record struct (value type, C# 10+). Their primary purpose is to simplify the creation of immutable types with value equality semantics.
- Immutability by Default:
recordproperties areinit-only by default, encouraging immutable patterns. - Value Equality:
recordtypes automatically implement value-based equality (Equals,GetHashCode,operator ==) based on the values of their properties/fields. This is a significant boon for DTOs, messages, and domain value objects. - Non-Destructive Mutation: The
withexpression allows creating a new instance of arecordwith specific properties changed, leaving the original instance untouched. This is a cornerstone of functional programming patterns. - Concise Syntax: Positional records generate constructors, properties, and
ToString()automatically, significantly reducing boilerplate.
record class (Reference Type):
Behaves like a class under the hood regarding memory allocation (heap) and reference variables, but with record’s value equality, immutability, and with expression features. Ideal for:
- Data Transfer Objects (DTOs)
- Messages in messaging queues
- Immutable domain value objects (e.g.,
Address,ProductId) - Configuration objects
record struct (Value Type, C# 10+):
Combines the benefits of records (value equality, with expression, concise syntax) with the benefits of structs (value semantics, potential stack allocation, no GC pressure).
- Crucial: Like
structs,record structs should always bereadonlyand small. Usereadonly record structto enforce immutability. - Good for scenarios where a
structis appropriate, but you also want the niceties ofrecords, especially generated value equality and thewithexpression.
Architecting with Precision: A Practical Example
Consider a scenario in a modern ASP.NET Core application, perhaps a minimal API, where we manage user preferences. We need to define data structures for configuration, user preferences, and a custom value type.
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent; // Using ConcurrentDictionary for thread-safety in demo
using System.Threading.Tasks;
// 1. A readonly record struct for a small, immutable value type.
// Represents an RGB color. It's a fundamental value, identity doesn't matter, it's small.
// Using 'record struct' provides value equality and a concise declaration.
public readonly record struct RgbColor(byte R, byte G, byte B)
{
// Custom ToString for better readability in API responses/logs
public override string ToString() => $"#{R:X2}{G:X2}{B:X2}";
}
// 2. A record class for immutable data transfer objects (DTOs) and configuration.
// UserPreferences: Identity is less about the object's memory location and more about its UserId and content.
// Value equality is useful for comparing preference sets. Immutability prevents accidental changes.
public record UserPreferences(
Guid UserId,
RgbColor ThemePrimaryColor,
bool EmailNotificationsEnabled,
DateTime LastUpdatedUtc
);
// AppConfiguration: Immutable configuration bound from settings.
// A record class ensures it's treated as a value and easily compared or passed around.
public record AppConfiguration(string DefaultThemeColorHex, int MaxNotificationRetries);
// 3. A traditional class for a service layer.
// UserPreferencesService: This service has identity. It manages state (even if in-memory for this demo)
// and provides behavior. It will be dependency-injected.
public class UserPreferencesService
{
private readonly ILogger<UserPreferencesService> _logger;
private readonly AppConfiguration _config;
// Using ConcurrentDictionary for thread-safety in this demo's in-memory store
private readonly ConcurrentDictionary<Guid, UserPreferences> _preferencesStore = new();
public UserPreferencesService(ILogger<UserPreferencesService> logger, AppConfiguration config)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_config = config ?? throw new ArgumentNullException(nameof(config));
// Seed some data with default color from config
var defaultColor = ParseRgbColor(_config.DefaultThemeColorHex);
_preferencesStore.TryAdd(Guid.Parse("a1b2c3d4-e5f6-7890-1234-567890abcdef"),
new UserPreferences(
Guid.Parse("a1b2c3d4-e5f6-7890-1234-567890abcdef"),
defaultColor,
true,
DateTime.UtcNow
)
);
}
public Task<UserPreferences?> GetPreferencesAsync(Guid userId)
{
_logger.LogInformation("Attempting to retrieve preferences for user {UserId}", userId);
_preferencesStore.TryGetValue(userId, out var preferences);
return Task.FromResult(preferences);
}
public Task UpdatePreferencesAsync(UserPreferences preferences)
{
_logger.LogInformation("Update request for user {UserId} received.", preferences.UserId);
_preferencesStore.AddOrUpdate(
preferences.UserId,
// Add case: set LastUpdatedUtc on creation
(key) => preferences with { LastUpdatedUtc = DateTime.UtcNow },
// Update case: use 'with' expression for non-destructive mutation
(key, existing) => existing with
{
ThemePrimaryColor = preferences.ThemePrimaryColor,
EmailNotificationsEnabled = preferences.EmailNotificationsEnabled,
LastUpdatedUtc = DateTime.UtcNow // Always stamp modification time
}
);
_logger.LogInformation("Preferences for user {UserId} updated successfully.", preferences.UserId);
return Task.CompletedTask;
}
private RgbColor ParseRgbColor(string hex)
{
// Simple parsing for demo; production would have robust error handling
if (hex.StartsWith("#")) hex = hex[1..];
byte r = Convert.ToByte(hex.Substring(0, 2), 16);
byte g = Convert.ToByte(hex.Substring(2, 2), 16);
byte b = Convert.ToByte(hex.Substring(4, 2), 16);
return new RgbColor(r, g, b);
}
}
// Minimal API setup
var builder = WebApplication.CreateBuilder(args);
// Configure services
// Bind AppConfiguration using a record class. IConfiguration supports this seamlessly.
builder.Services.AddSingleton(builder.Configuration.GetSection("App").Get<AppConfiguration>()
?? throw new InvalidOperationException("App configuration not found."));
builder.Services.AddSingleton<UserPreferencesService>();
builder.Logging.AddConsole(); // Add console logger
var app = builder.Build();
app.MapGet("/preferences/{userId:guid}", async (Guid userId, UserPreferencesService service) =>
{
var preferences = await service.GetPreferencesAsync(userId);
return preferences is not null ? Results.Ok(preferences) : Results.NotFound();
})
.WithName("GetUserPreferences")
.WithOpenApi();
app.MapPut("/preferences", async (UserPreferences preferences, UserPreferencesService service) =>
{
await service.UpdatePreferencesAsync(preferences);
return Results.NoContent();
})
.WithName("UpdateUserPreferences")
.WithOpenApi();
app.Run();
// Example appsettings.json:
/*
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"App": {
"DefaultThemeColorHex": "#FF5733", // Example: a reddish-orange
"MaxNotificationRetries": 3
}
}
*/
Reasoning Behind the Choices:
RgbColorasreadonly record struct: AnRgbColoris a canonical example of a value type. Its identity is defined purely by its R, G, and B components. It’s small, immutable, and benefits from value semantics (copying, potential stack allocation). Therecord structsyntax provides automatic value equality comparison and aToString()implementation, reducing boilerplate compared to a plainstruct. Thereadonlymodifier guarantees immutability, preventing subtle bugs caused by mutable struct copies.UserPreferencesasrecord class: User preferences are data. While they are associated with aUserId, theUserPreferencesobject itself is best treated as an immutable snapshot of those preferences. Value equality is highly desirable here: twoUserPreferencesobjects are “the same” if their content is identical, regardless of where they sit in memory. Thewithexpression allows for elegant, non-destructive updates to specific fields, which aligns perfectly with modern immutable data patterns. Being arecord classmeans it lives on the heap, which is fine for a DTO that might be larger and passed through various layers.AppConfigurationasrecord class: Configuration bound fromappsettings.jsonis typically immutable after startup. Arecord classmakes this explicit and provides the same benefits asUserPreferencesin terms of value equality and concise definition. It’s a cohesive data bag.UserPreferencesServiceasclass: A service object inherently has identity. It holds dependencies (ILogger,AppConfiguration), manages mutable state (_preferencesStore), and orchestrates operations. It’s a singleton (in this demo) injected into other components. Reference semantics are entirely appropriate here. We expect one instance ofUserPreferencesServiceto manage preferences.
Pitfalls and Best Practices
The modern C# type system empowers us, but with power comes responsibility.
- Avoid Large
structs: The hard rule forstructs is that they must be small. While “small” is subjective, generally if astructis larger than 16-32 bytes (e.g., more than 2-4 fields ofintorlong), the cost of copying it around can outweigh any benefits of reduced GC pressure. Largestructs often lead to hidden performance penalties. - Embrace
readonlyforstructs: If you use astruct(orrecord struct), make itreadonly. Mutablestructs are a common source of difficult-to-diagnose bugs because copies are made implicitly, leading to modifications on a copy, not the original.readonly structforces immutability and ensures predictable behavior. - Don’t Over-Record Entities with Strong Identity: While
record classis fantastic for DTOs and value objects, be cautious using it for true domain entities (e.g., the primaryCustomerobject managed by an ORM) where object identity (memory address) is often implicitly relied upon for tracking, caching, or equality within specific contexts. Aclasswithinitproperties might still be a better choice for an entity ifEquals()based on content rather than identity could cause confusion in an identity-driven domain. - Distinguish between
initandreadonly:initaccessors allow properties to be set only during object initialization (constructor or object initializer).readonlyfields can only be assigned in the constructor. You can haveclasses withinitproperties to get similar immutability benefits torecord classwithout opting into allrecordfeatures (like generated equality). - Consider Boxing: When a
structis treated as anobject(e.g., passed to anobjectparameter, stored in a non-generic collection likeArrayList), it is “boxed” onto the heap. This negates the performance benefits ofstructs and creates GC pressure. Be mindful of this in performance-critical paths.
The rich type system of modern C# offers precise tools to model your domain. The choice between class, record class, struct, and record struct is a deliberate architectural decision. It’s about aligning the type’s inherent semantics—identity versus value—with its role in your system, considering immutability, performance, and the clarity it brings to your codebase. Embrace these distinctions, and your code will be more robust, more maintainable, and often, significantly faster.