In this article, we’ll look at the StronglyTypedId NuGet package and how it helps us enhance type safety and code readability in our .NET projects by generating strongly-typed IDs for our entities.
Let’s get started.
Benefits of Using StronglyTypedId
Before we look at how to install and utilize the StronglyTypedId NuGet package, let’s consider why we need it in the first place.
We commonly use data types like string
, integer
, or GUID
to represent unique identifiers in our application. While these solutions are often effective, they can result in primitive obsession and major problems in our program in the future.
Using primitive data types as unique identifiers is extremely vulnerable to misuse and lacks context within our code. Consider mistakenly entering a UserId
where a CommentId
is expected. This can result in runtime errors, which are difficult to identify during development. In addition, while GUIDs
are unique, they have no inherent meaning in our code.
In contrast, StronglyTypedId is a custom type that serves as a unique identifier for our entity. It ensures type safety by defining separate classes for each identifier type, preventing unintentional ID mixing, and reducing runtime issues. Aside from preventing unintended ID mixing, it indicates their role clearly within our code. This makes the code more self-documenting and understandable to everyone.
Installing StronglyTypedId
To begin using the package, we have to install it first:
dotnet add package StronglyTypedId --version 1.0.0-beta08
Alternatively, we can install the package using Visual Studio’s NuGet package manager. As of this writing, the most recent stable version is 1.0.0-beta08
.
Using StronglyTypedId
In a moment, we will see how to use the StronglyTypedId package. But first, let’s look at an example of how using primitive objects as unique identifiers in our program might lead to problems:
public class User { public Guid Id { get; set; } public string? Name { get; set; } public List<Comment>? Comments { get; set; } } public class Comment { public Guid Id { get; set; } public string? Text { get; set; } public Guid UserId { get; set; } public User? User { get; set; } }
Here, we specify identifiers for the basic User
and Comment
entities using the Guid
primitive. This is a common practice.
Now let’s create a basic method to retrieve a single user’s comment:
public class CommentService { private static List<Comment> _comments = [ new () { Id = Guid.Parse("5c6a8f59-02c1-4f5b-bc85-bfea9ed3a6e4"), Text = "First comment made by user.", UserId = Guid.Parse("3b3b1a5d-6cd1-42f2-9331-fc6f716e9a2c") } ]; public Comment GetSingleUserComment(Guid commentId, Guid userId) { var comment = _comments .FirstOrDefault(comment => comment.Id == userId && comment.UserId == userId); return comment ?? throw new NullReferenceException(); } }
Notice the highlighted line in our GetSingleUserComment()
method and the way userId
is passed instead of commentId
. This is done intentionally to check whether the program throws an error during compilation.
Unfortunately, this code compiles successfully and fails to throw any errors when running it, which is not the behavior we want in our program.
Now let’s see how we can resolve this issue, using the StronglyTypedId package to modify our User
and Comment
classes:
[StronglyTypedId] public partial struct UserId { } public class User { public UserId Id { get; set; } public string? Name { get; set; } public List<Comment>? Comments { get; set; } } [StronglyTypedId] public partial struct CommentId { } public class Comment { public CommentId Id { get; set; } public string? Text { get; set; } public UserId UserId { get; set; } public User? User { get; set; } }
Here, we’re creating a struct
for the Id
properties of the User
and Comment
entities, and decorate both with the [StronglyTypedId]
attribute, to ensure that our IDs are unique.
Using our strongly typed IDs, let’s now modify the GetSingleUserComment()
method:
public class CommentService { private static List<Comment> _comments = [ new () { Id = new CommentId(), Text = "First comment made by user.", UserId = new UserId() } ]; public Comment GetSingleUserComment(CommentId commentId, UserId userId) { var comment = _comments .FirstOrDefault(comment => comment.Id == userId && comment.UserId == userId); return comment ?? throw new NullReferenceException(); } }
By assigning our unique IDs as types to the commentId
and userId
parameters, the editor will now throw an error if we mix up the IDs, leading to a compilation error:
This happens because the package source generator generates a method that compares the values of the IDs rather than the reference. This helps provide type safety and increased code readability when compared to just using a primitive data type.
Advanced Features of StronglyTypedId
Although the basic usage demonstrates the core functionality, the StronglyTypedId package offers additional features that enhance the flexibility and customization of our IDs. Let’s see some of these features.
Using Different Types as Backing Fields for IDs
One of the key features of the package is the ability to use different data types as backing fields for IDs. By default, the package uses the Guid
type for the underlying identifier but also allows us to choose the most suitable data type for storing ID values. We can specify the backing type using the string
, long
, and int
types as well.
Here’s how we can modify the previous User
entity to use a string
as a backing field:
[StronglyTypedId(Template.String)] public partial struct UserId { }
By giving a value from the Template
enum in the constructor, we specify that the type of our UserId
should be a string
instead of the default Guid
type. This allows us complete autonomy over the way we represent our IDs.
The built-in backing fields supported include int
, long
, Guid
, and the string
type we just saw. We can also benefit from templates built by others, by installing the StronglyTypedId templates package:
dotnet add package StronglyTypedId.Templates --version 1.0.0-beta08
Installing this package will allow us to reference all the templates that come with it.
Changing the Default Backing Field Globally
If we decide that the backing field for each ID in our project that uses the [StronglyTypedId]
attribute should be the same, we can configure our project to do so:
[assembly: StronglyTypedIdDefaults(Template.String)] [StronglyTypedId] public partial struct UserId { } [StronglyTypedId] public partial struct CommentId { }
Here, we use the StronglyTypedIdDefaults
assembly attribute one time to change the default backing field to type string
for each ID.
We can override the global default by providing a backing field type for any StronglyTypedId
attribute, as we did previously.
Conclusion
This article provides a quick overview of the StronglyTypedId NuGet package. We began by discussing some of the difficulties of utilizing primitive data types for entity identifiers. Then we covered how to use the package and define backing field types for individual IDs as well as globally.
To learn more about this package’s entire capability, visit the StronglyTypedId GitHub repository.