Create your own
Lesson illustration

Designing Immutable API Contracts and Value Objects

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;
  • init accessors for values that can be assigned only during construction;
  • required members 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:

FeatureWhat it guaranteesWhat it does not guarantee
stringThe 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.
requiredA 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.
initThe 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 with expressions.

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 required and init when 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#.

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:

  1. The API request accepts data from an untrusted boundary.
  2. The application validates and normalizes that data.
  3. A non-null, invariant-preserving domain value object is created.
  4. 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:

The diagram shows an `Order` with an identity and an `Address` represented by its component fields. Two addresses with the same street and city can represent the same value, while two orders remain distinct entities even if their fields happen to match.

For the moment, focus only on the contrast:

ConceptExamplePrimary identity
EntityPlayer, GameSession, OrderA stable identifier such as PlayerId
Value objectPlayerName, EmailAddress, Address, ScoreIts validated component values
API contractCreatePlayerRequest, PlayerResponseIts 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:

  1. Do not expose List<T>, arrays, or mutable dictionaries from a type you describe as immutable.
  2. 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.

NeedPreferReason
Small immutable response with all fields presentPositional recordConcise constructor, init-only properties, useful equality
Input DTO with mandatory and optional fieldsProperty-based record with required and initExplicit construction contract and optionality
Mandatory property assigned through object initializationrequired plus initMust be assigned, then cannot be reassigned
Domain concept with no identity and rules of validitySealed record value object with controlled creationValue equality plus enforced invariant
Entity that changes over time and is tracked by EF CoreUsually a classIdentity and lifecycle matter more than value equality
Collection-bearing “immutable” typeAn immutable collection or controlled copyinit 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.

  1. Create an immutable request contract:

    public sealed record CreatePlayerRequest
    {
        public required string DisplayName { get; init; }
    
        public string? PreferredRegion { get; init; }
    }
    
  2. Create the PlayerName value object with a private constructor and Create factory.

  3. At the application boundary, convert request.DisplayName into PlayerName only after handling missing or whitespace input.

  4. 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;
        }
    }
    
  5. Build the solution with nullable warnings still enforced:

    dotnet build GameBackend.sln --configuration Release
    

During review, look for two common design regressions:

  • a required string that has no runtime validation before it becomes a domain value;
  • a supposedly immutable record that exposes a mutable list, array, or dictionary.

Key takeaways

  • init controls when a property may be assigned: during construction only.
  • required controls 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