Using Custom ID Classes

Using Custom ID Classes

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 and GetHashCode, 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:

  1. Separation of Concerns: The UserAccount constructor takes primitive data types (like strings) as parameters and then internally creates the UserID object. This separates the concern of creating UserID instances from the usage of the UserAccount class. The UserAccount class doesn’t need to know how UserID instances are created or validated, it just needs a valid UserID.
  2. 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 a UserID instance separately; the UserAccount class handles it.
  3. Validation Encapsulation: By accepting a string and internally instantiating the UserID, the UserAccount class can ensure that all UserID objects it works with are valid according to the UserID class’s validation rules. This prevents invalid or unvalidated UserID objects from being passed to UserAccount.
  4. Flexibility in Object Creation: In some cases, you might want to create a UserAccount without having a UserID 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:

  1. 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.
  2. Derived Classes: Both UserID and CustomerID inherit from EntityID 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.
  3. Type Safety and Comparison: The base class implements IEquatable<T> and provides overrides for Equals, 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

Stephen

Hi, my name is Stephen Finchett. I have been a software engineer for over 30 years and worked on complex, business critical, multi-user systems for all of my career. For the last 15 years, I have been concentrating on web based solutions using the Microsoft Stack including ASP.Net, C#, TypeScript, SQL Server and running everything at scale within Kubernetes.