Introduction
Managing identification fields (IDs) is crucial to data integrity and application design.
For years, I have done what most people do and defined most IDs as Ints, Longs, Strings, and GUIDs. However, we must have all suffered from instances where we set the wrong IDs. Perhaps we set the customerID to be the orderID as they are both Ints. So, I thought I would do something different on one of my current projects.
In C#, one elegant solution to handle IDs effectively is through Custom ID classes. This blog post explores the concept and advantages of using custom classes for ID management, illustrated using the UserID
class.
The Role of IDs in Software Development
IDs serve as the backbone for uniquely identifying records, objects, and entities in an application. Whether it’s a user ID in a database or an identifier for a business entity, IDs are ubiquitous in programming. However, handling them correctly is important to ensuring data consistency and reliability.
The Case for Custom ID Classes in C#
Using custom ID classes, as opposed to basic data types like strings or integers, offers numerous advantages:
- Type Safety: Prevents accidental misuse of IDs as regular data types.
- Validation Logic: Encapsulates ID-specific validation rules, ensuring IDs conform to required formats or standards.
- Readability and Maintenance: Enhances code clarity and makes maintenance easier.
Deep Dive into a UserID
Class
The first thing I did was to create a basic UserID class that looks like this: –
public class UserID : IEquatable<UserID>
{
private readonly string id;
public UserID(string id)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("ID cannot be null or whitespace.", nameof(id));
}
this.id = id;
}
public string Value => id;
public override bool Equals(object obj)
{
return Equals(obj as UserID);
}
public bool Equals(UserID other)
{
return other != null && id == other.id;
}
public override int GetHashCode()
{
return id.GetHashCode();
}
public static bool operator ==(UserID left, UserID right)
{
return EqualityComparer<UserID>.Default.Equals(left, right);
}
public static bool operator !=(UserID left, UserID right)
{
return !(left == right);
}
}
This class encapsulates a string ID and implements IEquatable<UserID>
for comparisons. Notable features include:
- Guard Clauses: Validates the ID, ensuring it’s not null or just whitespace.
- Immutability: The
readonly
modifier makes the class immutable, thus thread-safe. - Equality Members: Overrides
Equals
andGetHashCode
, and defines equality operators for reliable comparisons.
Implementing the UserID
Class in a Project
Integrating the UserID
class into a C# project is straightforward. It can replace plain string IDs in entities, providing a more structured and safer way to handle identification logic.
For instance, using UserID
in a user management system ensures that every user ID adheres to the established validation rules.
So, how would we use it?
We might use it like this: –
public class UserAccount
{
public UserID UserId { get; private set; }
public string UserName { get; private set; }
public string Email { get; private set; }
public UserAccount(string userId, string userName, string email)
{
this.UserId = new UserID(userId);
this.UserName = userName ?? throw new ArgumentNullException(nameof(userName));
this.Email = email ?? throw new ArgumentNullException(nameof(email));
// Additional validation or initialization logic can be added here
}
// Additional methods and properties related to the user account can be added here
// For example, methods to update the user's information, etc.
public override string ToString()
{
return $"UserID: {UserId.Value}, UserName: {UserName}, Email: {Email}";
}
}
Whenever an instance of the UserAccount class is created, it must be provided with a UserID as the first parameter, not just any old string.
This makes the code cleaner, too – I like the ‘intent’ that this brings.
Why use a string for a Custom ID Class?
The decision to use a string
rather than a UserID
type as the first parameter in the UserAccount
constructor is primarily based on design choice and the intended use case of these classes. Here are some considerations for this choice:
- Separation of Concerns: The
UserAccount
constructor takes primitive data types (like strings) as parameters and then internally creates theUserID
object. This separates the concern of creatingUserID
instances from the usage of theUserAccount
class. TheUserAccount
class doesn’t need to know howUserID
instances are created or validated, it just needs a validUserID
. - Ease of Use: Accepting a string simplifies the creation of
UserAccount
objects, especially in scenarios where the raw ID value comes from external sources like user input or database queries. The caller doesn’t need to create aUserID
instance separately; theUserAccount
class handles it. - Validation Encapsulation: By accepting a string and internally instantiating the
UserID
, theUserAccount
class can ensure that allUserID
objects it works with are valid according to theUserID
class’s validation rules. This prevents invalid or unvalidatedUserID
objects from being passed toUserAccount
. - Flexibility in Object Creation: In some cases, you might want to create a
UserAccount
without having aUserID
object already created (e.g., during user registration). Accepting a string allows for the creation of a new user account and its corresponding ID in one step.
However, if your application’s design and requirements are such that UserID
objects are always created and validated before creating a UserAccount
, you could certainly modify the constructor to accept a UserID
directly.
In summary, whether to use a string or a UserID
type in the constructor depends on your application’s specific requirements and design philosophy.
What next?
After that, I wanted to create more ID classes, so I created a base class.
To accommodate the use of a base class for handling common validation and functionality for different types of ID classes, such as UserID
and CustomerID
, I created an abstract base class. This base class will implement the shared logic, while derived classes can specify additional details or behaviours. Here’s an example of how this is structured:
Abstract Base Class: EntityID
public abstract class EntityID<T> : IEquatable<EntityID<T>> where T : EntityID<T>
{
protected readonly string id;
protected EntityID(string id)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("ID cannot be null or whitespace.", nameof(id));
}
// Additional shared validation can be added here
this.id = id;
}
public string Value => id;
public override bool Equals(object obj)
{
return Equals(obj as EntityID<T>);
}
public bool Equals(EntityID<T> other)
{
return other != null && id == other.id;
}
public override int GetHashCode()
{
return id.GetHashCode();
}
public static bool operator ==(EntityID<T> left, EntityID<T> right)
{
return EqualityComparer<EntityID<T>>.Default.Equals(left, right);
}
public static bool operator !=(EntityID<T> left, EntityID<T> right)
{
return !(left == right);
}
}
Then, I created the ID classes like this: –
Derived Class: UserID
public class UserID : EntityID<UserID>
{
public UserID(string id) : base(id)
{
// Additional UserID-specific validation can be added here
}
// Additional UserID-specific methods or properties
}
Derived Class: CustomerID
public class CustomerID : EntityID<CustomerID>
{
public CustomerID(string id) : base(id)
{
// Additional CustomerID-specific validation can be added here
}
// Additional CustomerID-specific methods or properties
}
Explanation:
- EntityID Base Class: This abstract class encapsulates the common validation logic and basic behaviours for ID management, and it uses generics to ensure type safety and proper equality comparison among different ID types.
- Derived Classes: Both
UserID
andCustomerID
inherit fromEntityID
and they can add specific validation rules or methods if needed. The base constructor is called to handle common validation and the derived classes can be extended to provide validation rules specific to them. - Type Safety and Comparison: The base class implements
IEquatable<T>
and provides overrides forEquals
,GetHashCode
, and equality operators, ensuring robust and type-safe comparisons.
I can add more kinds of IDs (like OrderID, ProductID, and others) to the model using this setup. This way, I can keep using the same method to make sure they are correct and to compare them. I can also expand all derived classes by extending the base class to add more common functionality as I see fit.
Conclusion
Employing custom classes for managing IDs in C# applications is a practice that yields significant benefits in terms of code quality, safety, and maintainability.
I have written related posts here: – HashSet<T> v List<T> and Building a Customer API with .NET Minimal API Framework.
Further information regarding IEquatable<T> can be found in the official dotnet documentation here: – https://learn.microsoft.com/en-us/dotnet/api/system.iequatable-1?view=net-8.0