Writing clean models in C#

Potentially outdated

This post was originally published on August 19, 2024, on a different website, so its contents might be outdated.

In C# as well as other programming languages, creating clean and maintainable models is important for preserving the health of software applications. A well-designed model can ease development and limit potential bugs. This post delves into several best practices that I have acquired over my years working as a .NET developer.


public class User
{
    // A bunch of properties
    public string Name { get; set; }
    public string Email { get; set; }
    public string Password { get; set; }
    public string Description { get; set; }

    // Maybe some methods too
}

In the User class above, each property can be read and set at any time. This is the easiest way to create a model: just create a class and add properties to it. While this is a very flexible approach, it’s not very safe to use. Think about the next developers (or yourself in a year) who might use the model in ways that were not intended when you designed the rest of the system. This can lead to low code quality and potential bugs.

In this article, we will explore different ways to make such a model clean and maintainable.

Single responsibility

Ah, that dreaded interview question! SOLID is an acronym that represents five design principles for object-oriented programming. The S in SOLID stands for “single responsibility principle” (SRP), which means that a class (or any other unit of code) should only do one thing, and only have one reason to change. If you would like to know more about the SOLID principles, feel free to read Solid Principles in C# – A Complete Guidance.


// BAD: multiple responsibilities
public class User
{
    public string Name { get; set; }
    public string Email { get; set; }

    public void SaveUser()
    {
        // Interaction with a database, 
        // not directly related to the user
    }

    public void AuthenticateUser()
    {
        // Interaction with an authentication system, 
        // not directly related to the user
    }
}

In the example above, the User class has multiple responsibilities. It contains logic which interacts with the database and authentication systems. If you had to introduce changes to these external systems, then you would have to modify the User class as well. Thus, the User class has reasons to change that are outside its concern. The User class should only be concerned with the user’s data.


// GOOD: single responsibility
public class User
{
    public string Name { get; set; }
    public string Email { get; set; }
}

// GOOD: single responsibility
public class UserRepository
{
    public void SaveUser(User user)
    {
    }
}

// GOOD: single responsibility
public class AuthenticationService
{
    public void AuthenticateUser(User user)
    {
    }
}

The single responsibility principle doesn’t mean that models must be devoid of all logic. As we will explore further, integrating behavior in models can still comply with clean architecture principles when done correctly.

Access control

In C#, your model should only expose the properties and methods that are needed. In the example below, the password was not intended to be seen or changed by any code that can access the User model.


public class User
{
    // BAD: not intended to be seen or changed
    public string Password { get; set; }

    // BAD: same as above, and you should not use public fields anyway
    public string password;
}

Always ask yourself “what is the minimum access level that this requires?”, then use public or private access modifiers to achieve that. Please look at the Access modifiers documentation to see all the access modifiers available. According to Microsoft’s guidelines for fields and properties, you should generally use private fields and public properties. Using public fields is not recommended, and using private properties is uncommon, but it could be useful in some special cases.

If you need to get or set the password, you should use methods, or define property getters and setters that match your requirements.


public class User
{
    // GOOD: password can only be read and set by the User model itself
    private string? password;

    public void SetPassword(string newPassword)
    {
        // add validation logic
        password = newPassword;
    }

    public bool CheckPassword(string passwordToCheck)
    {
        // simplified example
        return password == passwordToCheck;
    }
}

In the example above, interaction with the sensitive password data is handled safely by methods. While the Single responsibility section warned against methods that reach outside the model, the two methods above fit well in this example and don’t violate the single responsibility principle. They directly interact with the model’s data and nothing else outside the model.

Note that these methods are kept simple to focus on access control. Real code should store and compare a hash of the password, never the plain text.

Immutability

If the email address is not intended to be changed after user creation, it should be immutable. By choosing immutable data by default, you can prevent potential misuses of the model.


public class User
{
    public string Name { get; set; }

    // BAD: Email can freely be set here
    public string Email { get; set; }
}

public class User
{
    public string Name { get; set; }

    // GOOD: Email can only be defined during object creation, 
    // and is immutable after that
    public string Email { get; init; }
}

Moreover, if you need immutable fields, you should use readonly modifiers. Immutable collections are also possible with types such as ReadOnlyCollection or one of the types of the System.Collections.Immutable namespace.

Nullable reference types

Have you ever been in a situation where an object could possibly be null, but there was no code to check for that? This is a common cause of bugs, as developers don’t always think about the effect of null values in their code.

Nullable reference types is a feature which was introduced in C# 8 / .NET Core 3.0. It indicates whether an object can be null, and it warns developers when they try assigning a null value to something that is not supposed to be null. New projects have enabled it by default since .NET 6 (<Nullable>enable</Nullable> in the project file), and older projects can opt in the same way. I strongly recommend using it to properly identify what can be null or not in your models.


public class User
{
    // These two properties should never be null
    public string Name { get; set; }
    public string Email { get; set; }

    // This property can be null, notice the ? sign
    public string? Description { get; set; }
}

You might ask yourself: “How do I ensure that Name and Email are not null when creating a User object?”. The next sections will answer that question.

Constructors

Constructors ensure that the object is created in a specific way. In the example below, the constructor forces the developer to provide values for both the Name and the Email properties. Since we’re using the nullable reference types feature from the previous section, passing a null value for those non-nullable parameters produces a compiler warning.


public class User
{
    public string Name { get; set; }
    public string Email { get; set; }
    public string? Description { get; set; }

    public User(string name, string email)
    {
        Name = name;
        Email = email;
    }
}

The Name and Email now need to be provided as parameters of the constructor:


User user = new("John Doe", "john.doe@example.com");

Constructors may seem restrictive and verbose, but they are a standard way to ensure that the object is created with correct values.

Required properties

The required keyword, introduced in C# 11 / .NET 7, will cause a compilation error if a value is not provided for a property during the object’s initialization. This ensures that these mistakes are discovered while developing, instead of becoming costly bugs in a production environment. It also clears the CS8618 warnings that the simplified examples earlier in this post produce, since the compiler can now prove that those properties are always set.

In the example below, the developer is forced to provide a value for the Name and Email properties. In this case, the required keyword removes the need for a constructor and makes the code easier to read.


public class User
{
    public required string Name { get; set; }
    public required string Email { get; set; }
    public string? Description { get; set; }
}

User user = new()
{
    Name = "John Doe",
    Email = "john.doe@example.com"
};

The required keyword is not to be confused with the [Required] data annotation, which is checked at runtime by frameworks and libraries rather than by the compiler: ASP.NET Core reports it as a model validation error, and Entity Framework Core uses it to make the column non-nullable. If your model is a data transfer object (DTO) and will be validated by a framework supporting data annotations, you should use both the required keyword and the [Required] annotation together.


public class UserDto
{
    // required for both object initialization and data mapping
    [Required]
    public required string Name { get; set; }

    // required for both object initialization and data mapping
    [Required]
    public required string Email { get; set; }

    public string? Description { get; set; }
}

Conclusion

There are multiple tools at your disposal to make your models clean, maintainable and safe to use. Here’s what a clean User model could look like, combining what we learned in the previous sections:


public class User
{
    // required, not null, can be modified freely
    public required string Name { get; set; }

    // required, not null, immutable
    public required string Email { get; init; }

    // not required, can be null, can be modified freely
    public string? Description { get; set; }

    // not required during creation, can be null until it's set, 
    // only read and written inside the model
    private string? password;

    // newPassword is not null
    public void SetPassword(string newPassword)
    {
        // add validation logic
        password = newPassword;
    }

    // passwordToCheck can be null
    public bool CheckPassword(string? passwordToCheck)
    {
        // simplified example
        return password == passwordToCheck;
    }
}

And here is an example of how you could use the model in your application:


User user = new()
{
    Name = "John Doe",
    Email = "john.doe@example.com"
};

user.Description = "Nobody knows him";
user.Name = "John Smith";
user.Description = null;
user.SetPassword("T0p53cr3t!");
Console.WriteLine(user.CheckPassword("1234") 
    ? "Correct password" 
    : "Wrong password");

Do you disagree with any of these practices, or do you have any other tips to write cleaner code? Feel free to get in touch and let me know.

AI disclaimer

This article has been written with the help of AI text generation. While I used AI to improve the clarity and flow of my writing, the core ideas, knowledge and opinions expressed here are entirely my own. I believe that AI is a wonderful help for writing, but it should not be used as a source of content.