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
readonlymodifier makes the class immutable, thus thread-safe. - Equality Members: Overrides
EqualsandGetHashCode, 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
UserAccountconstructor takes primitive data types (like strings) as parameters and then internally creates theUserIDobject. This separates the concern of creatingUserIDinstances from the usage of theUserAccountclass. TheUserAccountclass doesn’t need to know howUserIDinstances are created or validated, it just needs a validUserID. - Ease of Use: Accepting a string simplifies the creation of
UserAccountobjects, 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 aUserIDinstance separately; theUserAccountclass handles it. - Validation Encapsulation: By accepting a string and internally instantiating the
UserID, theUserAccountclass can ensure that allUserIDobjects it works with are valid according to theUserIDclass’s validation rules. This prevents invalid or unvalidatedUserIDobjects from being passed toUserAccount. - Flexibility in Object Creation: In some cases, you might want to create a
UserAccountwithout having aUserIDobject 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
UserIDandCustomerIDinherit fromEntityIDand 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




