Writing clean models in C#

Bug Buster Bruno - Aug 19 - - Dev Community

Photo by Kevin Ku on Unsplash.

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 along 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
}
Enter fullscreen mode Exit fullscreen mode

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 the foundational principles of object-oriented programming. The S in SOLID stands for “single responsibility principle” (SRP), which means that a class (or other units 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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Always think about “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;
    }
}
Enter fullscreen mode Exit fullscreen mode

In the example above, interaction with the sensitive password data is handled safely by methods. While I did mention methods being out of place in the Single responsibility section, 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.

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; }
}
Enter fullscreen mode Exit fullscreen mode
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; }
}
Enter fullscreen mode Exit fullscreen mode

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

Null reference types

Have you ever been in a situation where an object could eventually 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.

Null reference types is a feature which was introduced in C# 8 / .NET Core 3.0. It indicates which object can be null or not, and it warns developers when they try assigning a null value to something that is not supposed to be null. This feature is enabled by default in the project file, and 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; }
}
Enter fullscreen mode Exit fullscreen mode

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 null reference types feature from the previous section, assigning a null value to those non-nullable properties will warn the developer.

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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

The use of 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.

In the examples 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"
};
Enter fullscreen mode Exit fullscreen mode

The required keyword is not to be confused with the [Required] data annotation, which is only an indication for frameworks or libraries (e.g. ASP.NET, Entity Framework, Blazor) that the data must be present during data mapping. If your model is a data transfer object (DTO) and will be checked 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; }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

There are multiple tools at your disposal to make your models clean, maintainable, and safer to use. Here’s what a clean User model could be, 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 mull, 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Do you disagree with any of these practices, or do you have any other tips to write cleaner code? Please let me know in the comments!

.