Nrts And Dtos

Published on Sunday, December 24, 2023

NRTs and DTOs

For the purposes of this discussion, Data Transfer Object (DTO) is a behaviorless collection of data, frequently meant to be serialized and sent to or received from a server.

Before turning on Nullable Reference Types (NRT), all of a DTO's reference type members are assumed to be nullable, even if that is not a legal state, as illustrated below:

var createCustomerRequest = new CreateCustomerDto
{ 
	FirstName = txtFirstName.Text,
	LastName = txtLastName.Text
};
var newCustomer = _service.CreateCustomer(createCustomerRequest);

However, the CreateCustomerDto might well look like this:

public class CreateCustomerDto
{ 
	string FirstName;
	string LastName;
	string Address;
};

Alas, the address field is unfilled and there is no warning or error.

After turning on NRT, the safest thing to do (not the recommended thing to do) to existing DTO members is to declare them as nullable. This may have the effect of requiring changing the type to nullable in method signatures.

You can address this problem to some extent by adding a "required" keyword, but my go to solution is for DTOs is to make the constructor private and add one or more static factory methods that ensure complete and correct construction. Consider the following class:

public class SomeDto
{
  public int IntValue { get; set; }
  public string StrValue { get; set; }
}

Is the IntValue required to be non-zero? Positive? Is the StrValue required? Non-null? Not whitespace or empty? It�s impossible to say. Adding a static factory function fixes that:

public class SomeDto
{
  private SomeDto() // Can only be constructed from factory methods
  {
  }

  public static SomeDto IntDto(int value)
  {
    if (value < 1)
    {
      throw new ArgumentException(@"{nameof(value)} can not be less than one");
    }
    return new SomeDto() { IntValue = value, StrValue = "" };
  }

  public static SomeDto StrDto(string value)
  {
    if (string.IsNullOrWhiteSpace(value))
    {
      throw new ArgumentException(@"{nameof(value)} must contain a value");
    }
    return new SomeDto() { IntValue = 0, StrValue = value };
  }

  public int IntValue { get; }
  public string StrValue { get; }
}

Hey, look at that! We now know that the DTO is intended to store either a positive, non-zero int or a non-null, non-empty string, and the object can never be invalid. In the properties, �set� has been removed, which prevents the fields from being set outside of the constructor.

If you know that a DTO is going to be populated in full by an object mapper, or other similar process, and its value doesn�t need to change, use a record:

public record LoginInputDto(string Username, string Password);

On the same note, avoid returning null in general; return an empty list, not null; return a Result type object that contains either the desired result, or the reason why the desired result was not returned, a stand-in �null object�, etc.