Implementing strongly-typed IDs in .NET for safer domain models

As developers, we know that user requests can be unpredictable. When they request data, it is either successfully returned or not found. However, a "not found" result usually happens in two ways. First, the user might request a record, such as a basketball player, by passing an ID that doesn't exist. Second, they might pass the wrong type of ID, like a Team ID, while trying to fetch a Player. The first situation is common, but the second should be handled to avoid logic pitfalls. Enforcing this validation for every entity can be a humongous task when your application contains hundreds of entities. In this post, I will provide a way out of this situation so you can restrict your application from passing IDs interchangeably among entities.

Implementing strongly-typed IDs in .NET for safer domain models

Primitive obsession

Primitive obsession refers to a situation where we use primitive data types, such as int, string, or bool, to represent complex data. Usually, values like URLs, Addresses, or identities are saved as strings. This creates a hidden breach of encapsulation because it forces you to add an extra layer of validation somewhere else in the code.

For domain-specific entities like URLs or addresses, you can use owned entities. However, to define domain-specific identities, we need strongly-typed IDs.

Primitive Id implementation for EF Core model entity

Let's first understand the problem with the traditional way. I will create a console application with a PostgreSQL database to catch the issue at hand.

Step 1: Create the project

dotnet new console -n IntIdsDemo
cd IntIdsDemo

Step 2: Install necessary packages

dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL

Step 3: Define models

I'll create models to represent a player, a team, and the relationship between them.

Player

namespace IntIdsDemo.Domain.Entities;

public class Player
{
    public int Id { get; private set; }
    public string Name { get; private set; } = string.Empty;

    private Player() { } // Required by EF Core

    public Player(string name)
    {
        Name = name;
    }
}

Team

namespace IntIdsDemo.Domain.Entities;

public class Team
{
    public int Id { get; private set; }
    public string Name { get; private set; } = string.Empty;

    private Team() { } 

    public Team(string name)
    {
        Name = name;
    }
}

PlayerTeam to join them

namespace IntIdsDemo.Domain.Entities;

public class PlayerTeam
{
    public int Id { get; private set; }
    public int PlayerId { get; set; }
    public int TeamId { get; set; }

    private PlayerTeam() { }

    public PlayerTeam(int playerId, int teamId)
    {
        PlayerId = playerId;
        TeamId = teamId;
    }
}

Step 4: Configure ApplicationDbContext

using IntIdsDemo.Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace IntIdsDemo.Data;

public class ApplicationDbContext: DbContext
{
    public DbSet<Player> Players => Set<Player>();
    public DbSet<Team> Teams => Set<Team>();
    public DbSet<PlayerTeam> PlayerTeams => Set<PlayerTeam>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseNpgsql(
            "Connection string with database simpleIdsDb");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {        
    }
}

Step 5: Define Program.cs

using IntIdsDemo.Data;
using IntIdsDemo.Domain.Entities;

using var db = new ApplicationDbContext();

// Seed data
var player1 = new Player("Ali");
var player2 = new Player("Hamza");
var team = new Team("Warriors");

db.Players.Add(player1);
db.Players.Add(player2);
db.Teams.Add(team);
db.SaveChanges();

Console.WriteLine("Player & Team saved");

// Simple assignment method
void AssignPlayerToTeam(int playerId, int teamId)
{
    var playerTeam = new PlayerTeam(playerId, teamId);
    db.PlayerTeams.Add(playerTeam);
    db.SaveChanges();
}

AssignPlayerToTeam(player2.Id, team.Id);
AssignPlayerToTeam(team.Id, player2.Id);

Player player = db.Players.Find(team.Id);

if (player != null)
{
    Console.WriteLine($"Player Id (int): {player.Id}");
    Console.WriteLine($"Player Name: {player.Name}");
}

player = db.Players.Find(player2.Id);

if (player != null)
{
    Console.WriteLine($"Player Id (int): {player.Id}");
    Console.WriteLine($"Player Name: {player.Name}");
}

I am initializing 2 players and 1 team here. Later, I assign player 2 to the team. First, I did it correctly as per the method's requirement, with AssignPlayerToTeam(player2.Id, team.Id); While in the second part, which is logically wrong, the compiler will give a green signal to AssignPlayerToTeam(team.Id, player2.Id). Next, I am observing an issue with fetching data. I can easily pass teamId as the parameter for the Find method, where playerId (db.Players.Find(team.Id)) is expected. But since we are using the primitive type of int, it will compile without any errors.

Step 6: Run the migration

We need to create an empty database

CREATE DATABASE simpleIdsDb;

And create a migration and update the database

dotnet ef migrations add InitialCreate
dotnet ef database update

Step 7: run the project

dotnet run
Output

Let's check the database. The Players table contains the following rows:

Players table

The Teams table contains a single row:

Teams table

And finally, the PlayerTeams table contains two relationship rows:

PlayerTeams table

As you can see, the assignment is done, and PlayerId and TeamId were assigned interchangeably. To prevent this at the database level, we would have to add specific constraints. However, at the coding level, this remains a significant issue. While the console output shows that the player was not found with team.Id, this behavior can still lead to data inconsistency or unexpected runtime errors.

Strongly-Typed ID implementation for safer domain model

To keep focus on strongly-typed ID implementations, I will continue a similar project.

Step 1: Create the project

dotnet new console -n StronglyTypedIdsDemo
cd StronglyTypedIdsDemo

Step 2: Install necessary packages

dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL

Step 3: Create the Strongly-Typed IDs

Traversing to the directory Domain/Ids/ and add the following code for creating a player ID

namespace StronglyTypedIdsDemo.Domain.Ids;

public readonly record struct PlayerId(int Value)
{
    public override string ToString() => Value.ToString();
}

And the following for creating a team ID

namespace StronglyTypedIdsDemo.Domain.Ids;

public readonly record struct TeamId(int Value)
{
    public override string ToString() => Value.ToString();
}

And finally, the player/team relational ID

namespace StronglyTypedIdsDemo.Domain.Ids;

public readonly record struct PlayerTeamId(int Value)
{
    public override string ToString() => Value.ToString();
}

struct are the best fit for strongly-typed IDs. A struct cannot contain null, while a class would allow it null, heap allocation, and reference semantics. readonly ensures immutability and cannot be modified after creation. As we know record is a value object, record struct will give you value-based equality automatically.

Step 4: Create the Entity

Go to the directory StronglyTypedIdsDemo.Domain.Entities and add the following model classes

using StronglyTypedIdsDemo.Domain.Ids;

namespace StronglyTypedIdsDemo.Domain.Entities;

public class Player
{
    public PlayerId Id { get; private set; }
    public string Name { get; private set; } = string.Empty;

    private Player() { } 

    public Player(string name)
    {
        Name = name;
    }
}
using StronglyTypedIdsDemo.Domain.Ids;

namespace StronglyTypedIdsDemo.Domain.Entities;

public class Team
{
    public TeamId Id { get; private set; }
    public string Name { get; private set; } = string.Empty;

    private Team() { } 

    public Team(string name)
    {
        Name = name;
    }
}
using StronglyTypedIdsDemo.Domain.Ids;

namespace StronglyTypedIdsDemo.Domain.Entities;

public class PlayerTeam
{
    public PlayerTeamId Id { get; private set; }
    public PlayerId PlayerId { get; private set; }
    public TeamId TeamId { get; private set; }

    private PlayerTeam() { }

    public PlayerTeam(PlayerId playerId, TeamId teamId)
    {
        PlayerId = playerId;
        TeamId = teamId;
    }
}

These classes look similar to the first application but all ID types have been switched out for the strongly typed versions.

Step 5: Configure the DbContext

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using StronglyTypedIdsDemo.Domain.Entities;
using StronglyTypedIdsDemo.Domain.Ids;

namespace StronglyTypedIdsDemo.Data;

public class ApplicationDbContext: DbContext
{
    public DbSet<Player> Players => Set<Player>();
    public DbSet<Team> Teams => Set<Team>();
    public DbSet<PlayerTeam> PlayerTeams => Set<PlayerTeam>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseNpgsql(
            "Connection string with db name strongIdsDb");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var playerIdConverter = new ValueConverter<PlayerId, int>(
            id => id.Value,
            value => new PlayerId(value));

        modelBuilder.Entity<Player>(entity =>
        {
            entity.HasKey(x => x.Id);

            entity.Property(x => x.Id)
                .HasConversion(playerIdConverter)
                .ValueGeneratedOnAdd();

            entity.Property(x => x.Name)
                .IsRequired();
        });
        
        var teamIdConverter = new ValueConverter<TeamId, int>(
            id => id.Value,
            value => new TeamId(value));

        modelBuilder.Entity<Team>(entity =>
        {
            entity.HasKey(x => x.Id);

            entity.Property(x => x.Id)
                .HasConversion(teamIdConverter)
                .ValueGeneratedOnAdd();

            entity.Property(x => x.Name)
                .IsRequired();
        });
        
        var playerTeamIdConverter = new ValueConverter<PlayerTeamId, int>(
            id => id.Value,
            value => new PlayerTeamId(value));

        modelBuilder.Entity<PlayerTeam>(entity =>
        {
            entity.HasKey(x => x.Id);

            entity.Property(x => x.Id)
                .HasConversion(playerTeamIdConverter)
                .ValueGeneratedOnAdd();

            entity.Property(pt => pt.PlayerId)
                .HasConversion(playerIdConverter)
                .IsRequired();

            entity.Property(pt => pt.TeamId)
                .HasConversion(teamIdConverter)
                .IsRequired();
        });
    }
}

I used ValueConverter<PlayerId, int> that tells EF Core to use PlayerId but save the value as int. similarly TeamId and PlayerTeamId are configured. Notice that adding a connection string through code is not recommended and only done for simplicity. Always use external configuration for this.

Step 6: Configure Program.cs

using StronglyTypedIdsDemo.Data;
using StronglyTypedIdsDemo.Domain.Entities;
using StronglyTypedIdsDemo.Domain.Ids;

using var db = new ApplicationDbContext();

// Seed data
var player1 = new Player("Ali");
var player2 = new Player("Hamza");
var team = new Team("Warriors");

db.Players.Add(player1);
db.Players.Add(player2);
db.Teams.Add(team);
db.SaveChanges();

Console.WriteLine("Player & Team saved");

// Simple assignment method
void AssignPlayerToTeam(PlayerId playerId, TeamId teamId)
{
    var playerTeam = new PlayerTeam(playerId, teamId);
    db.PlayerTeams.Add(playerTeam);
    db.SaveChanges();
}

AssignPlayerToTeam(player2.Id, team.Id);

// ⚠️ Wrong order of parameter
AssignPlayerToTeam(team.Id, player2.Id);

// ⚠️ Wrong ID type for Find
Player player = db.Players.Find(team.Id);

if (player != null)
{
    Console.WriteLine($"Player Id (int): {player.Id}");
    Console.WriteLine($"Player Name: {player.Name}");
}

player = db.Players.Find(player2.Id);

if (player != null)
{
    Console.WriteLine($"Player Id (int): {player.Id}");
    Console.WriteLine($"Player Name: {player.Name}");
}

In the code, I'm adding a record manually . In the line AssignPlayerToTeam(team.Id, player2.Id), a compiler error is shown.

Compile error

The compiler now detects when we try to use the incorrect IDs when adding a user to a team.

Sep 7: run migrations

We need to create an empty database

CREATE DATABASE strongIdsDb;

And add the migration and run it

dotnet ef migrations add InitialCreate
dotnet ef database update

Step 8: Run and test

dotnet run

The second error is now revealed, since we are trying to use a team ID as the parameter for the Find method on the Players table

ArgumentException

This means we are on the right track. Remove erroneous code and everything looks great

Output

What strongly-typed IDs have improved?

  • Primitive IDs can lead to id-mixup at compile time. Defining identity structure. Each entity eliminates this shortcoming.
  • The traditional approach lacks domain meaning, whereas a strongly typed ID contains explicit intent.
  • Strongly-typed IDs removed the ambiguity of method signatures that happens when int, Guid, or other primitive types are used.
  • Traditionally, bugs are skipped at compile time, leading to runtime errors, while strongly-typed IDs catch ID mismatches at compile time.

Conclusion

Robustness is a big win for any application. Your code should handle issues right at the gateway. One common problem most people overlook is primitive obsession. Using types like ints for IDs allows you to pass IDs of different entities interchangeably. You can validate them manually, but that requires extra verbose code elsewhere, which breaks encapsulation. In this post, I have provided a solution using strongly-typed IDs to resolve that problem. We first looked at the traditional approach and observed the real-world issues caused by primitive obsession. Using separate value-object IDs makes your domain model much safer.

Code: https://github.com/elmahio-blog/StronglyTypedIdsDemo

elmah.io: Error logging and Uptime Monitoring for your web apps

This blog post is brought to you by elmah.io. elmah.io is error logging, uptime monitoring, deployment tracking, and service heartbeats for your .NET and JavaScript applications. Stop relying on your users to notify you when something is wrong or dig through hundreds of megabytes of log files spread across servers. With elmah.io, we store all of your log messages, notify you through popular channels like email, Slack, and Microsoft Teams, and help you fix errors fast.

See how we can help you monitor your website for crashes Monitor your website