Techniques For Effective Models in C#

c# techniques

Large, complex data models are ubiquitous today.  Whether destined for a database or a service API, constructing valid data is essential.   Having a clear and concise way of defining data models, as well as populating and validating them, can greatly increase productivity and reliability.  When building models with hundreds of fields and several levels, clarity and conciseness are not just matters of elegance;  They are necessary.

It is also important that our models be immutable.  As instances of models are passed around from function to function, the guarantee that they will not be altered is of immense value.

This article details a strategy for accomplishing this in C#.  Our specific goals are

  1. Clear and concise model definition
  2. Clear and concise instance creation
  3. Immutability
  4. Arbitrarily strict validation with a concise syntax

Envision a Result

Let’s start by looking at an example from a language that does this very well, in order to better envision a desired result.  The construct used for models in Scala is the case class.  Below is an example of a simplified book model defined as a case class.

case class Book(
    id          : Option[UUID]   = None,
    title       : String,
    titlePrefix : Option[String] = None,
    authors     : List[String],
    country     : Option[String] = None
) {
    require(title.nonEmpty, "Title cannot be blank.")
    require(titlePrefix.forall(_.nonEmpty), "Title cannot be blank.")
    require(authors.forall(_.nonEmpty), "No author name can be blank.")
    require(
        country.forall("^[A-Z]{2}$".r matches _),
        "Country is not a valid ISO 3166-1 alpha-2 value."
    )
}

Here we have several validations, including a non-empty string, a list of strings where all are non-empty, and a regular expression match.  The first argument to require is just a boolean variable, allowing for arbitrarily complex validations, inline when appropriate or by creating separate functions when they are too large or for re-usability.  If the first argument to require is false, then an exception is thrown with the second argument as the message.

Case classes in Scala are by default immutable (although this can be overridden by tagging a field as var).  Also, the Scala List class is by default immutable.

Note that id is optional because it is common to create an object and then let the database assign the ID.  Also, with id typed as UUID, no validation is necessary.

Below is an example of creating an instance.  Note that by specifying all fields by name, all values with defaults can be omitted, regardless of order.

val book = Book(
    title       = "Martian Chronicles",
    titlePrefix = Some("The"),
    authors     = List("Ray Bradbury"),
    country     = Some("US")
)

The book is immutable, but a new instance can be created as a modified copy of it.

val book2 = Book.copy(
    title       = "Fahrenheit 451",
    titlePrefix = None
)

What we have seen here has very little boilerplate, could not be much more clear or concise, and the validation has unlimited flexibility.  In C# we can accomplish something very similar, but some assembly is required.  We will go through each step of this process.

1. Enable nullable types

Starting with version 8.0, C# provides the parameterized type Nullable, which is analogous to Option, This feature has to be enabled, however.  To do so, add the following to the .csproj file.

<PropertyGroup>
    <Nullable>enable</Nullable>
</PropertyGroup>

Before, every variable was nullable and required explicit null checks.  With Nullable enabled, the compiler will only allow null values for variables of type Nullable.  For all other variables, variables which should never be null, the need for null-checking code has been eliminated.

A nullable variable can be defined using either the type-parameter syntax Nullable<T> (as in Java or Scala), or the shorthand T? (as in TypeScript).  There is no analog to Some in C#; The right-hand side of nullable and non-nullable assignments are the same.

string           title;               // required field
Nullable<string> titlePrefix = null;  // optional field using type-parameter syntax
string?          Country     = null;  // optional field using TypeScript syntax

2. Use C# records with positional syntax

In C#, the closest counterpart to a case class is the record.  A record in C# can be defined using a class syntax.

public record Book {
    public Guid?        id          { get; init; } = default!;
    public string       title       { get; init; } = default!;
    public string?      titlePrefix { get; init; } = default!;
    public List<string> authors     { get; init; } = default!;
    public string?      country     { get; init; } = default!;
}

or with positional syntax.

public record Book(
    Guid?        id,
    string       title,
    string?      titlePrefix,
    List<string> authors,
    string?      country
)

Since the class syntax has significantly more boilerplate, we opt for the positional syntax.

3. Use named and optional arguments

Despite having selected positional syntax for defining records, it is impractical to instantiate models using positional arguments.  For anything but trivial models, keeping the values in sync positionally is tedious and error-prone.  We need to use named arguments.

Named arguments are a general feature of function calls in C#, not specific to records, and regrettably not tied together in the C# record documentation.  But it can be used when instantiating records just as with any other function call.

To allow for instantiating records without specifying a lot of null values, we use optional arguments in our record definition just as with any function.  To do this, define the record providing a default value of null for all nullable fields, and sort all nullable fields to the end of the record definition.  When appropriate, non-nullable fields can also have defaults, of course.

4. Use immutable collections

The default C# collections are mutable.  For our models to be immutable, we must use immutable collections.  The C# immutable list is ImmutableList.

5. Create a model base class for validations

In the Scala case class, any statements in the body become part of the constructor.  To emulate this in C#, we create an abstract base class which forces the subclass to implement a validate method, and then takes care of calling that method on instantiation.  We also implement a require method that operates just as the Scala version described earlier.

using System;
public abstract record ModelValid {
    protected ModelValid() {
        validate();
    }
    public abstract void validate();
    protected static void require(bool requirement, string message) {
        if (!requirement) {
            throw new ArgumentException(message);
        }
    }
}

Putting it all together

Implementing all of what we outlined above, we have this definition of a book model equivalent to our example of a desired result.

using System;
using System.Collections.Immutable;
using System.Text.RegularExpressions;

public record Book(
    string                title,
    ImmutableList<string> authors,
    Guid?                 id          = null,
    string?               titlePrefix = null,
    string?               country     = null
) : ModelValid {
    public override void validate() {
        require(title.Length > 0, "Title cannot be blank.");
        require(titlePrefix is null || titlePrefix.Length > 0), "Title cannot be blank.");
        require(authors.TrueForAll(a => a.Length > 0), "No author name can be blank.");
        require(
            country is null || Regex.IsMatch(country, "^[A-Z]{2}$"),
            "Country is not a valid ISO 3166-1 alpha-2 value."
        );
    }
}

And this for creating an instance of the book model.

Book book = new(
    title       : "Martian Chronicles",
    titlePrefix : "The",
    authors     : ImmutableList.Create("Ray Bradbury"),
    country     : "US"
)

Finally, we have this for creating an instance from an existing instance.

Book book2 = book with {
    title       = "Fahrenheit 451",
    titlePrefix = null
}; book2.validate();

Note that with requires explicit validation (that is why validate is public).  There is no getting around this because with invokes a clone method that cannot be overridden.