Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 2

Implementing Immutable DTOs with Records and Init-Only Setters

Recommended resources

Welcome back! In our last lesson, we established the crucial difference between reference and value semantics by comparing classes and structs. This foundation is essential as we now turn to a more modern and specialized tool in C#'s type system.

Today's lesson focuses on a feature introduced in C# 9 that elegantly solves a common problem you've likely encountered many times as a full-stack developer: creating simple data-carrying objects. We will be working towards the learning outcome: to implement immutable data transfer objects (DTOs) using records and init-only setters. You'll discover how records dramatically reduce boilerplate code, promote immutability, and make your data models more robust and expressive.

1. The Problem: Boilerplate and DTOs

In many applications, especially in the context of APIs or messaging systems, you need types whose primary purpose is to hold data. These are often called Data Transfer Objects (DTOs). Before C# 9, you would typically define a DTO using a class, which involves a surprising amount of ceremony.

Consider creating a simple UserResponse DTO. The traditional class-based approach would look something like the top half of this image:

This image contrasts the verbose syntax of a traditional C# class for a DTO with the highly concise syntax of a C# record, which achieves the same result in a single line.

To make this class truly robust, you'd also need to:

  • Write a constructor to initialize the properties.
  • Override Equals() and GetHashCode() to compare instances by their data, not by reference.
  • Overload the == and != operators for intuitive equality checks.
  • Override ToString() to get a meaningful representation for logging and debugging.

All of this for a simple data container! This is where records come in.


2. Introducing Records: Data-Centric Types Made Easy

Records are a special kind of class (or struct) designed specifically for modeling data. By default, record creates a reference type (record class) but imbues it with value-like behaviors, giving you the best of both worlds for data-centric scenarios.

Let's explore the core features that make records so powerful.

Choosing Between Classes, Records, and Structs in C# – BigO ...

The article "Choosing Between Classes, Records, and Structs in C#" by Omar Besiso provides an excellent introduction to what record classes are and why they are so well-suited for DTOs.

First, read the introduction to record classes under the heading Records – Modern C# Data Classes. This section covers the core concepts of value equality and default immutability. Then, jump to the section When to use record classes, which directly addresses their use as DTOs and value objects.

The compiler does a significant amount of work on your behalf when you declare a record. Let's break down what you get for free.

The Ultimate Guide to C# Records

The video "The Ultimate Guide to C# Records" by Zoran on C# provides a fantastic visual walkthrough of how a single-line record declaration expands into a full-featured class.

Watch the segment from the introduction where the presenter manually implements what the compiler generates from a record definition. This clearly shows the purpose of the primary constructor and init-only setters. Then, watch the section covering compiler-generated members to see the automatic implementation of IEquatable, Equals, GetHashCode, equality operators, and ToString.

To summarize, for a simple declaration like public record User(string FirstName, string LastName);, the compiler generates:

  • A Primary Constructor: User(string FirstName, string LastName).
  • init-only Properties: Public properties for FirstName and LastName with get; init; accessors. An init-only setter allows the property to be set only during object initialization (in the constructor or using an object initializer), making the object effectively immutable after creation.
  • Value-based Equality: Overrides for Equals() and GetHashCode(), plus == and != operators, that compare two record instances based on the values of their properties.
  • A ToString() Override: A clean, formatted string showing the type name and its property values, which is invaluable for debugging.
  • Deconstruction: A Deconstruct method that allows you to easily extract the values into variables, e.g., var (fn, ln) = user;.

This table provides a concise comparison of the features:

This table summarizes the key differences between a standard class, a positional record, and a record class with named properties, highlighting aspects like mutability, equality, and syntax.

3. Working with Immutable Data: Non-Destructive Mutation

A key principle when working with immutable objects is that you don't change them; you create new ones based on the old ones. Records make this pattern, known as non-destructive mutation, extremely elegant with the with expression.

The with expression creates a shallow copy of a record instance, allowing you to specify new values for one or more properties. The original record remains completely unchanged.

var user1 = new UserResponse(1, "test@example.com", "John", "Doe");

// Create a new record instance with an updated email
var user2 = user1 with { Email = "john.doe@example.com" };

Console.WriteLine(user1); // Prints: UserResponse { Id = 1, Email = test@example.com, ... }
Console.WriteLine(user2); // Prints: UserResponse { Id = 1, Email = john.doe@example.com, ... }
Console.WriteLine(ReferenceEquals(user1, user2)); // Prints: False

The video below explains this process, including the crucial concept of "shallow immutability".

The Ultimate Guide to C# Records

The same video from Zoran on C# explains how with expressions work and the important distinction between shallow and deep immutability.

Watch the short segment on non-destructive mutation to see how the with expression uses a copy constructor under the hood. Then, pay close attention to the final note on shallow immutability. This is a critical consideration for professional development.

As the video explains, with performs a shallow copy. If your record contains properties that are reference types (like a List<T> or another class), the with expression will copy the reference, not the object itself. Both the old and new record instances will point to the same underlying object. For true, deep immutability, you must ensure that any nested objects within your record are also immutable.


4. Putting It All Together: The Ideal DTO

With their conciseness, built-in immutability, and value semantics, records are the modern, idiomatic choice for DTOs in C#.

modern-csharp-coding-standards | Ski... · LobeHub

This resource on modern C# coding standards provides practical, opinionated guidance on when and how to use records, particularly for DTOs.

First, review the examples under the heading Records for Immutable Data. Note the different ways records can be used, including for simple DTOs and more complex ones with collections. Then, review the anti-pattern DON'T: Use mutable DTOs. This directly contrasts the old, mutable approach with the preferred immutable record.

Using a record for a DTO:

  • Reduces Errors: Prevents accidental modification of data as it passes through different layers of your application.
  • Improves Readability: The intent is clear—this is a simple data container.
  • Simplifies Testing: Value equality makes asserting the contents of a DTO trivial.

5. Beyond the Basics: required Members and record struct

While init properties prevent modification after creation, they don't force the caller to provide a value when using an object initializer. This could lead to objects being created in an incomplete state. C# 11, a version you're focusing on, introduced the required modifier to solve this.

The required keyword ensures that a property or field must be set in the object initializer. The compiler will produce an error if it's omitted.

New C# 11 Keyword - required - and How To Use It Right

In this video, Zoran on C# explains the problem that the required keyword solves and how it works.

First, watch the segment that explains the weakness of init-only setters when used with object initializers. Then, see how the required keyword provides the solution in the next segment, from introducing required.

You can combine this with records that don't use a primary constructor to enforce initialization while still allowing a flexible syntax:

public record ProductDto
{
    public required int Id { get; init; }
    public required string Name { get; init; }
    public decimal Price { get; init; } // Optional
}

// COMPILER ERROR: Required properties 'Id' and 'Name' must be set.
// var product = new ProductDto { Price = 99.99m }; 

// This is valid.
var product = new ProductDto { Id = 1, Name = "Widget", Price = 99.99m };

Finally, it's worth noting that just as you can have record class, you can also have a record struct. This brings the benefits of records (concise syntax, value equality) to value types. A common pattern is public readonly record struct, which creates a truly immutable value type, ideal for small value objects where you want to avoid heap allocations. We explored the class/struct trade-offs in the last lesson, and the same logic applies to record class versus record struct.


Conclusion

In this lesson, we've seen how C# records provide a powerful and concise way to define immutable data-centric types, making them a perfect fit for DTOs.

Here are the key takeaways:

  • Records reduce boilerplate: The compiler automatically generates constructors, properties, value-based equality methods, and a ToString override.
  • Records promote immutability: Positional records use init-only setters by default, preventing properties from being changed after an object is created.
  • The with expression enables non-destructive mutation: It allows you to create a new record instance from an existing one with modified properties, which is the standard pattern for working with immutable data.
  • The required keyword ensures robust initialization: It forces callers to set essential properties, preventing the creation of incomplete objects.
  • Records are the idiomatic choice for DTOs in modern C# applications due to their safety, clarity, and convenience.

In our next lesson, we will delve into sealed classes. You'll learn how to use them to prevent inheritance, which can be a powerful tool for controlling your class hierarchies and enabling certain compiler optimizations.