Better Code with custom Strong-Id Types
In many software architectures the problem exists that there are methods, which are specified a multiplicity of type-same parameters, whose meaning is however fundamentally different. In principle, this includes all handling of Ids or other essential values that have a logical meaning.
For example, we have a large number of IDs, which in this case have the same type, but have a completely different meaning.
public Task Add(int userId, int newsId)
The risk now is that the developer mixes up the order of the parameters, this leads to logic as well as data problems - and this is not immediately obvious. There is no compiler error. In the end, this potential swapping of values can only be covered by unit tests.
The solution: Strong-IDs.
Strong IDs are achieved by defining custom types that replace the default types in their usage, but under the hood is still the previous data type; here a Guid. The implementation corresponds to the wrapper pattern.
public Task Add(PlatformUserId userId, NewsId newsId)
If the developer swaps the parameters, there is a compiler error. This not only reduces the test effort, but also increases the software quality.
C# Limitations
In many languages there are built-in features in the form of alias support. For example, F# knows Type Abbreviations which gives me compiler-side support for type aliases without having to write my own wrappers - including errors in case of incorrect usage.
C# does not have such a feature. You have to write your own wrappers by force - unfortunately.
The wrapper implementation
There are several ways to implement such a String-ID class as a wrapper; however, the possibilities of C# 10 ease the effort a bit.
As a basis I create an interface and an abstract class, so that I have certain advantages in the use of generic methods, and on the other hand I can use inheritance to have as little effort as possible in the implementation. Here as an example on the basis of an Int implementation.
using System;
namespace MyCSharp.Models;
/// <summary>
/// Interface for integer-based strong IDs on the platform.
/// </summary>
/// <typeparam name="T">The type of the implementing ID.</typeparam>
public interface IPlatformIntegerStrongId<T> : IComparable<T>
{
/// <summary>
/// Gets the integer value of the ID.
/// </summary>
int Value { get; }
}
/// <summary>
/// Abstract class for integer-based strong IDs on the platform.
/// </summary>
/// <typeparam name="T">The type of the implementing ID.</typeparam>
public abstract record class PlatformIntegerStrongId<T>(int Value)
: IPlatformIntegerStrongId<T> where T : IPlatformIntegerStrongId<T>
{
/// <inheritdoc/>
public int CompareTo(T? other) => Value.CompareTo(other?.Value);
/// <inheritdoc/>
public sealed override string ToString() => Value.ToString();
}
The implementation of my specific Strong ID class can now inherit from PlatformIntegerStrongId
.
/// <summary>
/// Represents a strong ID for a news item.
/// </summary>
public sealed record class NewsId(int Value)
: PlatformIntegerStrongId<NewsId>(Value)
{ }
I have all my strong-ids currently defined as class, as struct has some limitations, including e.g. model binding in ASP.NET Core, some hurdles with json converters etc.
The usage
I can now use my Strong Id class - here now NewsId - in the method and have compiler support in case I swap parameters.
public Task Add(PlatformUserId userId, NewsId newsId)
Conclusion
Strong Ids are not quite as easy and efficient to implement in C# as they are in F#. However, they bring enormous advantages during development, are widely usable and prevent problems very simply.