A Better Way to Handle Entity Identification in .NET with Strongly Typed IDs

antonmartyniuk

Anton Martyniuk

Posted on September 24, 2024

A Better Way to Handle Entity Identification in .NET with Strongly Typed IDs

Strongly Typed IDs are custom types that are used to represent entity identifiers (IDs) in your application instead of using primitive types like int, Guid, or string.
Instead of using these primitive types directly to represent IDs, you create a specific class or struct that encapsulates the ID value.
This approach helps to make the code more expressive, safer, and easier to maintain.

On my website: antondevtips.com I share .NET and Architecture best practices.
Subscribe to become a better developer.
Download the source code for this blog post for free.

Primitive Obsession Problem

In the previous blog post, we explored what is a Primitive Obsession and how it can be solved by using Value Objects.

In short, primitive obsession is a tendency to use basic data types to represent more complex concepts.
Primitive obsession occurs when basic data types (such as int, string, or DateTime) are overused to represent complex concepts in your domain.

It is a common anti-pattern that can lead to unclear code and harder-to-maintain systems.

The same problem goes with identifiers (IDs) if you have multiple entities in your project.
If you're using Guid for all entities identifiers, like CustomerId, OrderId, OrderItemId, it could lead to mistakenly passing an OrderId where a OrderItemId is expected.
All because the same types are used everywhere.

This primitive obsession problem can be solved by using Value Objects.
Value Objects represent a value in your domain that has no identity but is defined by its attributes.

A primitive obsession problem for entity identifiers is solved by using Strongly Typed IDs that are a sort of Value Objects, but only applied to entity identifiers.

Examples of Primitive Obsession with Entity Identifiers

Let's explore an application example that has Order and OrderItem entities.
Both entities have guids as their entity identifier type:

public class Order
{
    public Guid Id { get; set; }
}

public class OrderItem
{
    public Guid Id { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Imagine that you're calling the ProcessOrderAsync method:

public Task ProcessOrderAsync(Guid orderId, Guid orderItemId)
{
    // Logic to process the order and its item
}

// Correct usage
ProcessOrder(order.Id, orderItem.Id);

// Incorrect usage - No compile-time error
ProcessOrder(orderItem.Id, order.Id);
Enter fullscreen mode Exit fullscreen mode

This code compiles and executes successfully. Did you notice a problem?

As we use Guid type for both Order.Id and OrderItem.Id - you can pass parameters in the wrong order.
You will end up with wrong data in the database which can lead to serious problems.

The solution to this problem is to use Strongly Typed IDs, which encapsulate entity identifiers into a custom meaningful unit.

What Are Strongly Typed IDs?

Strongly Typed IDs are custom types that are used to represent entity identifiers (IDs) in your application instead of using primitive types like int, Guid, or string.
Instead of using these primitive types directly to represent IDs, you create a specific class or struct that encapsulates the ID value.
This approach helps to make the code more expressive, safer, and easier to maintain.

Key characteristics of Strongly Typed IDs:

  • Immutability: once created, a Strongly Typed Id cannot be changed. Any modification results in a new instance.
  • Equality: Strongly Typed IDs are compared based on their value, not by reference.

Benefits of Strongly Typed IDs:

  • Enhanced Type Safety: one of the most significant benefits of strongly typed IDs is that they enhance type safety. This reduces the chances of accidentally mixing up different types of IDs, leading to fewer bugs and runtime errors.
  • Improved Code Clarity: Strongly typed IDs make your code more expressive and self-documenting. When you see a method that accepts an OrderId, it's immediately clear what kind of ID is expected, as opposed to a generic Guid or int.
  • Better Domain Modeling: in Domain-Driven Design (DDD), strongly typed IDs help reinforce the concept of entities and their identities. This makes the domain model more robust and aligned with the real-world concepts it represents.
  • Support for Future Enhancements: if the requirements for an ID change (e.g., needing to change a type of entity identifier), a strongly typed ID class can be updated or extended to support these new requirements without breaking existing code.
  • Easier Refactoring: Strongly typed IDs make refactoring easier, as changes to ID-related logic can be made in a single place, reducing the possibility of introducing errors during the process.

Now let's explore what options do we have for creating Strongly Typed IDs in .NET.

An Example Application

Today I'll show you how to implement Strongly Typed IDs for a Shipping Application that is responsible for creating and updating customers, orders and shipments for ordered products.

This application has the following entities:

  • Customers
  • Orders, OrderItems
  • Shipments, ShipmentItems

I am using Domain Driven Design practices for my entities.
Let's explore an Order and Order Item entities that use primitive types for the entity identifiers:

public class Order
{
    public Guid Id { get; private set; }

    public OrderNumber OrderNumber { get; private set; }

    public Guid CustomerId { get; private set; }

    public Customer Customer { get; private set; }

    public DateTime Date { get; private set; }

    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

    private readonly List<OrderItem> _items = new();

    private Order() { }

    public static Order Create(OrderNumber orderNumber, Customer customer, List<OrderItem> items)
    {
        var order = new Order
        {
            Id = Guid.NewGuid(),
            OrderNumber = orderNumber,
            Customer = customer,
            CustomerId = customer.Id,
            Date = DateTime.UtcNow
        };

        order.AddItems(items);

        return order;
    }

    private Order AddItems(List<OrderItem> items)
    {
        _items.AddRange(items);
        return this;
    }
}
Enter fullscreen mode Exit fullscreen mode
public class OrderItem
{
    public Guid Id { get; private set; }

    public ProductName Product { get; private set; }

    public int Quantity { get; private set; }

    public Guid OrderId { get; private set; }

    public Order Order { get; private set; } = null!;

    private OrderItem() { }

    public OrderItem(ProductName productName, int quantity)
    {
        Id = Guid.NewGuid();
        Product = productName;
        Quantity = quantity;
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, both entities use Value Objects for their properties.
If you want to learn more about Value Objects — make sure to check out my corresponding blog post.

Now let's explore how to replace these primitive types for entity identifiers with Strongly Typed IDs.

Creating Strongly Typed Ids in .NET

I can list the following most popular options for creating Strongly Typed Ids:

  • using StronglyTypedId package written by Andrew Lock
  • using C# Records
  • using C# Record Structs

Let's explore each option more in-depth.

Creating Strongly Typed Ids with StronglyTypedId package

By using the StronglyTypedId package, you can generate strongly-typed ID structs for your entities.

First, you need to install the package:

dotnet add package StronglyTypedId
Enter fullscreen mode Exit fullscreen mode

You need to define a partial struct and add a StronglyTypedId attribute:

[StronglyTypedId]
public readonly partial struct OrderId { }

[StronglyTypedId]
public readonly partial struct OrderItemId { }
Enter fullscreen mode Exit fullscreen mode

StronglyTypedId package will use source generators to implement both OrderId and OrderItemId structs.
By default, the underline type is Guid.

Here is how you can create an OrderId:

var orderId = new OrderId(Guid.NewGuid());
var orderItemId = new OrderItemId(Guid.NewGuid());
Enter fullscreen mode Exit fullscreen mode

You can use a Value property to retrieve a value hidden inside a strongly typed id:

var guid = orderId.Value;
var guid2 = orderItemId.Value;
Enter fullscreen mode Exit fullscreen mode

If you need to change the type, specify a Template in the attribute:

[StronglyTypedId(Template.Int)]
public readonly partial struct OrderId { }
Enter fullscreen mode Exit fullscreen mode

Currently supported built-in backing types are:

  • Guid (default)
  • int
  • long
  • string

Creating Strongly Typed IDs with Records

You don't have to use external packages as C# records already have all you need for strongly typed ids.

Records are a really modern-way to create Strongly Typed IDs in .NET.

Records are immutable reference types and their support equality comparison out of the box.
They are compared based on their properties, not by reference.

Here's how you can define the same Strongly Typed IDs using records:

public record OrderId(Guid Value);

public record OrderItemId(Guid Value);
Enter fullscreen mode Exit fullscreen mode

Creating Strongly Typed IDs with Record Structs

Records are a wonderful choice for Strongly Typed IDs, but they are reference types.
If you care about memory allocations, you can use readonly record structs for Strongly Typed IDs.
They behave the same as records but they are value types and not allocated on the heap.

This is my personal choice for creating Strongly Typed IDs.

Here is how you can define the Strongly Typed IDs with record structs:

public readonly record struct OrderId(Guid Value);

public readonly record struct OrderItemId(Guid Value);
Enter fullscreen mode Exit fullscreen mode

Here is how the Order and OrderItem entities will look like with Strongly Typed IDs:

public class Order
{
    public OrderId Id { get; private set; }

    public OrderNumber OrderNumber { get; private set; }

    public CustomerId CustomerId { get; private set; }

    public Customer Customer { get; private set; }

    public DateTime Date { get; private set; }

    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

    private readonly List<OrderItem> _items = new();

    private Order() { }

    public static Order Create(OrderNumber orderNumber, Customer customer, List<OrderItem> items)
    {
        var order = new Order
        {
            Id = new OrderId(Guid.NewGuid()),
            OrderNumber = orderNumber,
            Customer = customer,
            CustomerId = customer.Id,
            Date = DateTime.UtcNow
        };

        order.AddItems(items);

        return order;
    }

    private Order AddItems(List<OrderItem> items)
    {
        _items.AddRange(items);
        return this;
    }
}
Enter fullscreen mode Exit fullscreen mode
public class OrderItem
{
    public OrderItemId Id { get; private set; }

    public ProductName Product { get; private set; }

    public int Quantity { get; private set; }

    public OrderId OrderId { get; private set; }

    public Order Order { get; private set; } = null!;

    private OrderItem() { }

    public OrderItem(ProductName productName, int quantity)
    {
        Id = new OrderItemId(Guid.NewGuid());
        Product = productName;
        Quantity = quantity;
    }
}
Enter fullscreen mode Exit fullscreen mode

If you try to misuse the entity identifiers, you will get a compilation error:

Screenshot_1

Mapping Strongly Typed IDs in EF Core

After introducing Strongly Typed IDs in your entity models, you need to modify your EF Core Mapping.

You need to use conversion to tell EF Core how to map Strongly Typed IDs to the database, and how to map database values to ids.

For example, for Order entity:

builder.Property(x => x.Id)
    .HasConversion(
        id => id.Value,
        value => new OrderId(value)
    )
    .IsRequired();
Enter fullscreen mode Exit fullscreen mode

Strongly Typed IDs and Request/Response/DTO models

Strongly Typed IDs are your domain-specific models, the outside world should not know about them.
And moreover, your public request/response/DTO models should be as simple as possible.

It is a good practice to have plain primitives types in your request/response/DTO models and map them to domain entities and vice versa.

For example, I am mapping CustomerId with Guid type to CustomerId in my "Create Order" use case:

public sealed record CreateOrderRequest(
    Guid CustomerId,
    List<OrderItemRequest> Items,
    Address ShippingAddress,
    string Carrier,
    string ReceiverEmail);

public static CreateOrderCommand MapToCommand(this CreateOrderRequest request)
{
    return new CreateOrderCommand(
        CustomerId: new CustomerId(request.CustomerId),
        Items: request.Items,
        ShippingAddress: request.ShippingAddress,
        Carrier: request.Carrier,
        ReceiverEmail: request.ReceiverEmail
    );
}
Enter fullscreen mode Exit fullscreen mode

And the reverse mapping to CustomerResponse from Strongly Typed OrderId:

public static OrderResponse MapToResponse(this Order order)
{
    return new OrderResponse(
        OrderId: order.Id.Value,
        OrderNumber: order.OrderNumber.Value,
        OrderDate: order.Date,
        Items: order.Items.Select(x => new OrderItemResponse(
            ProductName: x.Product.Value,
            Quantity: x.Quantity)).ToList()
    );
}
Enter fullscreen mode Exit fullscreen mode

Summary

Strongly typed IDs are a powerful tool for improving the type safety, clarity, and maintainability of your .NET applications.
By encapsulating IDs within dedicated records or structs, you can prevent common mistakes and make your code more expressive.

C# records and readonly record structs provide you an elegant, easy and fast way to implement Strongly Typed IDs without boilerplate code.

On my website: antondevtips.com I share .NET and Architecture best practices.
Subscribe to become a better developer.
Download the source code for this blog post for free.

💖 💪 🙅 🚩
antonmartyniuk
Anton Martyniuk

Posted on September 24, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related