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
}
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
}
}
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 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;
}
}
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; }
}
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 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; }
}
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;
}
}
The Name
and Email
now need to be provided as parameters of the constructor:
User user = new("John Doe", "john.doe@example.com");
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"
};
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; }
}
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;
}
}
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? Please let me know in the comments!