Skip to main content
SEMastery
Fundamentalsintermediate

Strongly Typed IDs in .NET: A Safer Way to Identify Entities

Learn how strongly typed IDs in .NET stop you from mixing up entity identifiers, catch bugs at compile time, and work cleanly with EF Core.

11 min readUpdated May 13, 2026

Introduction

Imagine your school gives every student a roll number and every library book a barcode number. Both are just numbers. Now imagine the librarian accidentally types a student's roll number where a book's barcode should go. The computer happily accepts it, because to the computer a number is a number. The mistake is only found later, when a real student gets marked as "missing" from the shelf.

This kind of mix-up happens in code all the time. In .NET we often use a Guid, an int, or a string for the ID of every entity. A CustomerId, an OrderId, and a ProductId all look exactly the same to the compiler. So nothing stops you from passing an order's ID where a customer's ID was expected. The program builds fine. The bug shows up in production.

Strongly typed IDs fix this. They give each ID its own type, so the compiler becomes your safety net. This article shows what they are, why they help, and how to use them with Entity Framework Core.

The problem: primitive obsession

When we use a plain Guid or int for every identifier, we are leaning on a code smell called primitive obsession. We use a primitive type to represent an idea that really deserves its own type.

Look at this method. Can you spot the bug?

public class OrderService
{
    public void TransferOrder(Guid customerId, Guid orderId)
    {
        // ... business logic ...
    }
}
 
// somewhere else in the code
Guid customerId = customer.Id;
Guid orderId = order.Id;
 
// Oops — arguments are swapped!
orderService.TransferOrder(orderId, customerId);

Both parameters are Guid. The call compiles perfectly. But the arguments are in the wrong order. The compiler cannot help you, because it cannot tell a customer's Guid apart from an order's Guid. You only discover the problem when an order goes to the wrong place.

This is the heart of the issue. The type system knows nothing about your domain. Let us draw the trap.

Why plain primitive IDs let bugs slip through to runtime

The idea: give every ID its own type

The fix is simple. Instead of using a raw Guid everywhere, we wrap it in a tiny custom type. A CustomerId is a type. An OrderId is a different type. Now they are no longer interchangeable.

In modern C#, the cleanest way to write this is a readonly record struct. A record gives you equality checks and a short syntax for free. The struct part keeps it a value type, so there is no extra object allocated on the heap. The readonly part keeps it immutable, which is exactly what we want for an identifier.

public readonly record struct CustomerId(Guid Value);
public readonly record struct OrderId(Guid Value);
public readonly record struct ProductId(Guid Value);

That single line per ID is the whole declaration. Because it is a record, two CustomerId values with the same Value are considered equal automatically. You did not have to write Equals, GetHashCode, or == by hand.

Now watch what happens to our earlier bug:

public class OrderService
{
    public void TransferOrder(CustomerId customerId, OrderId orderId)
    {
        // ... business logic ...
    }
}
 
CustomerId customerId = customer.Id;
OrderId orderId = order.Id;
 
// This NO LONGER compiles — the types do not match.
orderService.TransferOrder(orderId, customerId);
//                          ^^^^^^^  expected CustomerId, got OrderId

The mistake is now a compile-time error. The bug never reaches production. The compiler stopped it the moment you typed it.

How a typed ID turns a runtime bug into a compile-time error

A quick comparison

Here is how the two approaches stack up side by side.

ConcernPlain primitive IDStrongly typed ID
Mixing up two IDsAllowed, silentCaught by compiler
Reads clearly in method signaturesNo, all are GuidYes, names tell the story
Equality checksManual or by valueFree with records
Heap allocationNoneNone (with record struct)
Works with EF CoreDirectlyNeeds a value converter
Effort to set upZeroA little, mostly one time

The trade is small. You write one line per ID and a bit of one-time configuration. In return, a whole class of bugs becomes impossible.

How the pieces fit together

Before we wire this into a real app, it helps to see the journey of an ID from your C# code down to the database and back. The strong type lives in your code. The database still stores a normal Guid column. A converter sits in the middle and translates between the two.

The life of a strongly typed ID

Domain
Converter
Column
Read back

Steps

1

Domain

CustomerId in C#

2

Converter

ID becomes Guid

3

Column

Guid stored in DB

4

Read back

Guid becomes CustomerId

The strong type stays in your code; the database keeps a plain primitive.

Using strongly typed IDs in your entities

Let us model a small shop. A Customer has its own ID, and so does an Order. The order also carries the ID of the customer it belongs to. Notice how the types document the relationships for you.

public readonly record struct CustomerId(Guid Value);
public readonly record struct OrderId(Guid Value);
 
public class Customer
{
    public CustomerId Id { get; set; }
    public string Name { get; set; } = string.Empty;
}
 
public class Order
{
    public OrderId Id { get; set; }
    public CustomerId CustomerId { get; set; } // clearly a customer, not an order
    public decimal Total { get; set; }
}

Reading the Order class, you instantly know that CustomerId points to a customer. With raw Guid properties, you would only know that from the property name, and names can lie or get copy-pasted wrong.

To create a new ID, you wrap a Guid:

var customer = new Customer
{
    Id = new CustomerId(Guid.NewGuid()),
    Name = "Asha"
};

Teaching EF Core to understand your ID

Entity Framework Core does not know what a CustomerId is. If you try to save it directly, EF Core will not know which database column type to use. You fix this with a value converter. A value converter is a small class that tells EF Core: "When you save, turn the CustomerId into a Guid. When you read, turn the Guid back into a CustomerId."

using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
 
public class CustomerIdConverter : ValueConverter<CustomerId, Guid>
{
    public CustomerIdConverter()
        : base(
            id => id.Value,                 // C# -> database
            value => new CustomerId(value)) // database -> C#
    {
    }
}

Then you apply the converter when EF Core builds the model:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .Property(c => c.Id)
        .HasConversion(new CustomerIdConverter());
 
    modelBuilder.Entity<Order>(b =>
    {
        b.Property(o => o.Id).HasConversion(new OrderIdConverter());
        b.Property(o => o.CustomerId).HasConversion(new CustomerIdConverter());
    });
}

After this, EF Core stores a normal Guid in the column, but every time it hands data back to you, it gives you a strong CustomerId. Your database stays simple, and your code stays safe.

This sequence shows what happens during a save and a load.

EF Core converts between the strong ID and the stored Guid

Saving boilerplate with a source generator

Writing one converter per ID gets repetitive once you have many entities. This is where a source generator helps. The popular StronglyTypedId library by Andrew Lock can generate the ID type, its converters, and JSON serialization for you, all at compile time. You just add an attribute.

using StronglyTypedIds;
 
[StronglyTypedId(Template.Guid)]
public partial struct CustomerId { }
 
[StronglyTypedId(Template.Guid)]
public partial struct OrderId { }

The generator writes the rest of the code behind the scenes, including equality, parsing, and EF Core or Dapper converters depending on how you configure it. You get all the safety with very little typing. The library needs a fairly recent .NET SDK, so make sure your project targets a current version such as .NET 10.

This decision guide helps you pick an approach.

Hand-written or generated?

Few IDs
Many IDs
Custom rules
Choose

Steps

1

Few IDs

Hand-write record structs

2

Many IDs

Use a source generator

3

Custom rules

Hand-write for full control

4

Choose

Match team comfort

Pick based on how many IDs you have and how much control you want.

Things to watch out for

Strongly typed IDs are great, but a few rough edges deserve a heads-up.

PitfallWhat happensHow to handle it
Forgetting the EF converterEF Core throws or maps wronglyRegister a converter for every ID property
JSON serializationThe ID may serialize as an object, not a valueAdd a JSON converter or use the generator's option
Route binding in APIsA controller may not parse the ID from the URLAdd a TryParse or a model binder
Default value confusionA default ID holds an empty GuidTreat empty IDs as "not set" in your code
LoggingThe ID may print as a type nameOverride ToString or rely on the record's output

None of these are hard. Most are solved once, often by the source generator, and then you forget about them.

When should you use them?

Strongly typed IDs shine in larger applications with many entities, where mixing up IDs is a real danger. They also pay off in domain-driven designs where you want your types to express meaning clearly.

For a tiny script or a one-page app, the extra setup may not be worth it. As always, match the tool to the size of the job. But once a project grows past a handful of entities, the safety they add is usually well worth the small cost.

A simple rule of thumb for adopting strongly typed IDs

References and further reading

Quick recap

  • Using a plain Guid, int, or string for every ID is primitive obsession. The compiler cannot tell one kind of ID from another.
  • A strongly typed ID wraps the primitive in its own type, so a CustomerId and an OrderId are no longer interchangeable.
  • A readonly record struct is the sweet spot: one line to declare, free equality, immutable, and no heap allocation.
  • This turns a whole class of "wrong ID" bugs from runtime surprises into compile-time errors.
  • EF Core needs a value converter to map your ID to and from the primitive column. The database still stores a normal Guid or int.
  • A source generator like StronglyTypedId removes the repetitive boilerplate when you have many IDs.
  • Watch out for JSON serialization, API route binding, and default empty IDs — all easy to handle once.
  • Reach for strongly typed IDs as your app grows past a few entities; the small setup cost buys lasting safety.

Related Posts