Welcome back. In the previous lesson, you made nullability contracts truthful: optional data is declared nullable, expected absence is handled explicitly, and successful paths stay non-nullable. That foundation matters here because immutability and required construction are also forms of contract design.
This lesson focuses on three related but distinct tools in modern C#:
- records for data-centric types and value-based equality;
initaccessors for values that can be assigned only during construction;requiredmembers for values a C# caller must supply during construction.
You will apply them to two different concerns in the game backend: immutable HTTP-facing API contracts and domain value objects such as a validated player name.
Three guarantees that are easy to confuse
Consider this API request model:
public sealed class CreatePlayerRequest
{
public required string DisplayName { get; init; }
public string? PreferredRegion { get; init; }
}
It expresses three independent decisions:
| Feature | What it guarantees | What it does not guarantee |
|---|---|---|
string | The property is intended to be non-null. Nullable analysis warns C# callers who provide null. | That untrusted JSON actually contains a non-null, valid value at runtime. |
required | A C# caller must assign DisplayName in an object initializer or constructor that satisfies required members. | That the assigned value is non-empty, within length limits, or otherwise valid. |
init | The property can be assigned during object construction, but not subsequently reassigned. | That objects referenced by the property are themselves immutable. |
For example, this is valid:
var request = new CreatePlayerRequest
{
DisplayName = "Nova",
PreferredRegion = "West Europe"
};
But this fails to compile because DisplayName was omitted:
var request = new CreatePlayerRequest
{
PreferredRegion = "West Europe"
};
And this fails because initialization has finished:
request.DisplayName = "Rook";
This makes an API model a stable snapshot of received data. A service can safely pass it through validation, authorization, logging, and application logic without another part of the request pipeline silently rewriting it.
However, do not overstate what the keywords do. An external client does not compile C# before sending JSON. A client can omit a JSON property, provide null, or send whitespace. required is valuable compile-time protection for .NET callers and a clear declaration of intent, but API boundary validation remains necessary. You will implement consistent input validation and Problem Details responses in the ASP.NET Core API module.
The nullable contract from the previous lesson still applies:
public sealed class CreatePlayerRequest
{
public required string DisplayName { get; init; }
public string? ClanTag { get; init; }
}
A player name is mandatory, while a clan tag may genuinely be absent. Do not make everything required, and do not make everything nullable. Each property should describe the product rule.
Before applying these tools, read the selected parts of Microsoft Learn’s Records (C# reference). It establishes the syntax, the value-equality behavior, and the important limitation that record immutability is shallow.
Records - C# reference | Microsoft Learn
Read this Microsoft Learn reference to understand what the compiler supplies for records: generated properties, construction syntax, value equality, and the limits of immutability.
In the opening overview, read the record forms to distinguish record and record struct. Then, in “Positional syntax for property and field definition,” read positional properties, noting that a record class receives init-only properties by default. In “Immutability,” read the shallow-immutability warning. Finally, in “Value equality,” read the equality discussion, especially the contrast between ordinary classes, records, and EF Core entities.
Records: concise data shapes, not merely shorter classes
A positional record is the compact form of an immutable data model:
public sealed record AwardAchievementResponse(
Guid PlayerId,
string AchievementCode,
int TotalPoints,
DateTimeOffset AwardedAt);
For a record class, the compiler creates:
- a public constructor accepting the positional parameters;
- public init-only properties;
- value-based equality members;
- a useful
ToString()implementation; - support for
withexpressions.
The constructor means every C# caller must provide all four values:
var response = new AwardAchievementResponse(
playerId,
"FIRST_VICTORY",
100,
DateTimeOffset.UtcNow);
That makes positional records particularly useful for output contracts whose fields are all mandatory and whose shape is uncomplicated.
A property-based record is more explicit and is often clearer for request DTOs with optional fields:
public sealed record SubmitScoreRequest
{
public required string MatchId { get; init; }
public required int Score { get; init; }
public DateTimeOffset? OccurredAt { get; init; }
public string? ClientBuild { get; init; }
}
This style makes it easy to see which fields are optional, apply serialization attributes when needed, and add new optional fields later without changing a long constructor signature.
A useful working rule is:
- Prefer a positional record for a small, complete response or internal message.
- Prefer a property-based record with
requiredandinitwhen field optionality and construction requirements need to be prominent. - Prefer an ordinary class when the type has a meaningful mutable lifecycle or identity-based behavior.
The last point matters. A record is not automatically the right choice merely because a type has properties.
For a short, practical view of why transfer objects are best treated as immutable snapshots, watch these segments from Gui Ferreira’s Building better DTOs in C#.
Gui Ferreira explains the purpose of DTOs and why immutable transfer shapes reduce accidental changes as data moves through a system.
Watch DTO foundations for the distinction between a data-transfer shape and a type that embeds application behavior. Then watch record DTOs for the concise record-based approach. Keep the central distinction in mind: an API DTO transports data; validation and domain rules belong in the layers that interpret that data.
with creates a replacement, not a mutation
Records support nondestructive modification through with:
if (string.IsNullOrWhiteSpace(request.MatchId))
{
throw new ArgumentException(
"Match ID is required.",
nameof(request));
}
var normalizedRequest = request with
{
MatchId = request.MatchId.Trim()
};
normalizedRequest is a new record. request still preserves the received value, which can be useful for diagnostics or auditing. This is preferable to making public DTO properties writable just so that some downstream component can normalize them.
Use this carefully: if normalization is a domain concern rather than a transport concern, map the request to a validated domain value object instead. That is usually the stronger design.
API contracts and domain objects have different jobs
An API contract represents the shape of information crossing a boundary. It should be simple and unsurprising:
public sealed record CreatePlayerRequest
{
public required string DisplayName { get; init; }
public string? PreferredRegion { get; init; }
}
By contrast, a domain type represents a business concept and should protect its own invariants. A raw string can be absent, blank, padded, or excessively long. A PlayerName should not be.
public sealed record PlayerName
{
public string Value { get; }
private PlayerName(string value)
{
Value = value;
}
public static PlayerName Create(string? rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
throw new ArgumentException(
"Player name cannot be empty.",
nameof(rawValue));
}
var normalized = rawValue.Trim();
if (normalized.Length > 24)
{
throw new ArgumentOutOfRangeException(
nameof(rawValue),
"Player name cannot exceed 24 characters.");
}
return new PlayerName(normalized);
}
}
The private constructor prevents accidental construction of an invalid value:
var name = PlayerName.Create(" Nova ");
Console.WriteLine(name.Value); // Nova
The API layer can receive the flexible external representation, while the application/domain boundary converts it into a PlayerName. Once conversion succeeds, code no longer has to repeatedly ask whether the name is blank or too long. The type itself carries that guarantee.
This is the connection to the previous lesson:
- The API request accepts data from an untrusted boundary.
- The application validates and normalizes that data.
- A non-null, invariant-preserving domain value object is created.
- The successful path operates on the stronger type rather than repeatedly handling a raw nullable or invalid string.
Why records fit value objects
A value object is identified by the values it contains, not by a database or object identity. Two separately allocated PlayerName instances with the same normalized value represent the same player name:
var first = PlayerName.Create("Nova");
var second = PlayerName.Create("Nova");
var areEqual = first == second; // true
That is record value equality. With an ordinary class, == would normally test whether both variables refer to the exact same allocation.
The “Value Object within Aggregate” diagram illustrates the distinction visually:

For the moment, focus only on the contrast:
| Concept | Example | Primary identity |
|---|---|---|
| Entity | Player, GameSession, Order | A stable identifier such as PlayerId |
| Value object | PlayerName, EmailAddress, Address, Score | Its validated component values |
| API contract | CreatePlayerRequest, PlayerResponse | Its serialized shape at a system boundary |
The same C# feature can support a DTO and a value object, but their intent differs:
- A DTO should primarily carry data without domain behavior.
- A value object may contain behavior needed to preserve its invariant, such as validation, normalization, formatting, or safe comparisons.
Do not use records indiscriminately for persistence entities. Entity Framework Core commonly relies on identity tracking and reference equality for entity instances. Your later EF Core work will map entities deliberately; a value object such as PlayerName is a better record candidate than a mutable PlayerEntity.
Immutability is shallow: protect nested state too
This record prevents replacement of PlayerIds after construction:
public sealed record MatchmakingQueueSnapshot(
Guid QueueId,
string[] PlayerIds);
It does not prevent changing the array contents:
var snapshot = new MatchmakingQueueSnapshot(
Guid.NewGuid(),
["player-1", "player-2"]);
snapshot.PlayerIds[0] = "player-3";
The record itself was not reassigned, but state reachable through it changed. Also, a with expression would copy the array reference, so both records could expose the same mutable array.
That has two practical consequences:
- Do not expose
List<T>, arrays, or mutable dictionaries from a type you describe as immutable. - Do not assume record equality makes nested collections structurally comparable. Arrays, for example, retain their own reference-oriented equality behavior.
For small API contracts, a scalar-only record is simple and safe. When a public contract genuinely needs a collection, choose an immutable collection representation or defensively control ownership of the collection before exposing it. The important design question is not “does this type use record?” but “can any caller still mutate observable state after construction?”
A selection guide for production code
Use this compact checklist when choosing a model.
| Need | Prefer | Reason |
|---|---|---|
| Small immutable response with all fields present | Positional record | Concise constructor, init-only properties, useful equality |
| Input DTO with mandatory and optional fields | Property-based record with required and init | Explicit construction contract and optionality |
| Mandatory property assigned through object initialization | required plus init | Must be assigned, then cannot be reassigned |
| Domain concept with no identity and rules of validity | Sealed record value object with controlled creation | Value equality plus enforced invariant |
| Entity that changes over time and is tracked by EF Core | Usually a class | Identity and lifecycle matter more than value equality |
| Collection-bearing “immutable” type | An immutable collection or controlled copy | init alone does not freeze referenced objects |
Portfolio step: replace one raw value with a real type
Apply the pattern in a narrow slice of the game backend. Do not refactor every model at once.
-
Create an immutable request contract:
public sealed record CreatePlayerRequest { public required string DisplayName { get; init; } public string? PreferredRegion { get; init; } } -
Create the
PlayerNamevalue object with a private constructor andCreatefactory. -
At the application boundary, convert
request.DisplayNameintoPlayerNameonly after handling missing or whitespace input. -
Keep the domain model dependent on
PlayerName, not on the request DTO:public sealed class Player { public Guid Id { get; } public PlayerName Name { get; } public Player(Guid id, PlayerName name) { Id = id; Name = name; } } -
Build the solution with nullable warnings still enforced:
dotnet build GameBackend.sln --configuration Release
During review, look for two common design regressions:
- a
required stringthat has no runtime validation before it becomes a domain value; - a supposedly immutable record that exposes a mutable list, array, or dictionary.
Key takeaways
initcontrols when a property may be assigned: during construction only.requiredcontrols whether a C# caller must assign a member; it does not validate semantic correctness or replace boundary validation.- Records are concise data-centric types with generated members and value-based equality.
- Immutable API DTOs preserve a stable snapshot of transferred data; keep them separate from domain models.
- Value objects use component equality and should centralize validation and normalization through controlled construction.
- Record immutability is shallow. Nested mutable objects and collections can still undermine an otherwise immutable model.
- Records are usually strong candidates for DTOs and value objects, but not for EF Core entities with mutable, identity-based lifecycles.
Next, you will use modern C# pattern matching to make branching logic clearer and more exhaustive—particularly useful once request outcomes and domain states are represented with precise types.
Can't find a good explanation? Sign up and we'll make it for you
Sign up