Understanding JSON → C# record
A class, but the values mean it.
What C# 9's record keyword changed, the positional-constructor syntax, and why it's the right shape for JSON DTOs in 90 % of cases.
Records in one line.
A record is a class with value-equality semantics, automatic Equals/GetHashCode/ToString, and an optional positional-constructor shorthand. The positional form public record User(int UserId, string Name); generates a constructor, init-only properties, and value-based equality in one line. It's the same trade-off Java records introduced: collapse boilerplate to a single declaration.
Record vs class for DTOs.
Use records for JSON DTOs. They're immutable by default, which prevents the common bug of one part of the system mutating a deserialized payload before another part sees it. They serialize correctly with System.Text.Json (.NET 7+) and Newtonsoft. They model "data" instead of "behaviour", which is what a DTO actually is. Use classic classes only when you need inheritance from a non-record base, or when the object holds significant logic alongside its data.
A worked example.
From { "user_id": 7, "name": "Q", "avatar": null } a positional record: public record User(
[property: JsonPropertyName("user_id")] int UserId,
string Name,
string? Avatar
); The [property: ...] syntax is the funny bit — positional record parameters need it to attach attributes to the generated property, not the constructor parameter. Without that prefix, the attribute attaches to the wrong element and Jackson- I mean System.Text.Json - silently ignores it.
Positional record
public record User(int UserId, string Name);
Init-only props, value equality, automatic deconstruction.
3-field record = 3 lines including the closing semicolon
= Immutable DTO
With-expression mutation
user with { Name = ... }
Creates a new record with one field changed.
var u2 = u with { Name = "R" };
= Functional update style
The with-expression.
Records have a unique syntax for "make a copy with one thing different": var updated = user with { Name = "R" };. The result is a new record with every field copied from user except Name. This makes immutable records ergonomic for the cases where you'd otherwise want a mutator — you can update one field without writing a setter. The pattern works recursively for nested records.
Value equality, surprises included.
Two records with the same field values are equal — even if they came from different sources, even if they're not the same object reference. This is usually what you want for DTOs (compare two API responses for equality without writing per-field checks). It can surprise you when the record contains a mutable collection: two records holding "the same" list compare equal only if the lists are reference equal, not value equal. For lists/dicts inside records, use immutable collection types (ImmutableArray<T>) or accept the gotcha.
Sealed by default.
Records are not sealed by default — they support inheritance from other records, with the same value-equality rules extended. For DTOs you almost always want sealed record: prevents accidental subclassing that would break serializer assumptions and is closer to the data-only intent. Codegen tools that emit sealed record by default are picking the right convention.
When to fall back to classes.
Records aren't perfect for everything. Inheritance hierarchies with deep value chains, types that integrate with Entity Framework (EF Core 7+ supports records, older versions don't), DTOs with reference cycles, types that need to overrideEquals for domain-specific reasons — all of these are better as classes. For 90 % of JSON-shape needs, records are the right shape; for the 10 % where they're not, classes still exist.