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.
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.
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 OrderIdThe mistake is now a compile-time error. The bug never reaches production. The compiler stopped it the moment you typed it.
A quick comparison
Here is how the two approaches stack up side by side.
| Concern | Plain primitive ID | Strongly typed ID |
|---|---|---|
| Mixing up two IDs | Allowed, silent | Caught by compiler |
| Reads clearly in method signatures | No, all are Guid | Yes, names tell the story |
| Equality checks | Manual or by value | Free with records |
| Heap allocation | None | None (with record struct) |
| Works with EF Core | Directly | Needs a value converter |
| Effort to set up | Zero | A 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
Steps
Domain
CustomerId in C#
Converter
ID becomes Guid
Column
Guid stored in DB
Read back
Guid becomes CustomerId
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.
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?
Steps
Few IDs
Hand-write record structs
Many IDs
Use a source generator
Custom rules
Hand-write for full control
Choose
Match team comfort
Things to watch out for
Strongly typed IDs are great, but a few rough edges deserve a heads-up.
| Pitfall | What happens | How to handle it |
|---|---|---|
| Forgetting the EF converter | EF Core throws or maps wrongly | Register a converter for every ID property |
| JSON serialization | The ID may serialize as an object, not a value | Add a JSON converter or use the generator's option |
| Route binding in APIs | A controller may not parse the ID from the URL | Add a TryParse or a model binder |
| Default value confusion | A default ID holds an empty Guid | Treat empty IDs as "not set" in your code |
| Logging | The ID may print as a type name | Override 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.
References and further reading
- Andrew Lock — Using strongly-typed entity IDs to avoid primitive obsession (Part 1)
- Andrew Lock — Rebuilding StronglyTypedId as a source generator
- StronglyTypedId source generator on GitHub
- Thomas Levesque — Using C# records as strongly-typed ids
- elmah.io — Implementing strongly-typed IDs in .NET for safer domain models
- SSW Rules — Do you use Strongly Typed IDs to avoid Primitive Obsession?
Quick recap
- Using a plain
Guid,int, orstringfor 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
CustomerIdand anOrderIdare no longer interchangeable. - A
readonly record structis 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
Guidorint. - 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
SOLID Principles in C# and .NET: A Beginner-Friendly Guide
Learn the 5 SOLID principles in C# and .NET with simple words, real-life examples, diagrams, and clean code you can copy and try yourself today.
8 Tips to Write Clean Code in C# and .NET
Learn 8 simple, beginner-friendly tips to write clean C# and .NET code with clear names, small methods, good error handling, and easy-to-read structure.
A Modern Way to Create Value Objects to Solve Primitive Obsession in .NET
Learn how value objects fix primitive obsession in .NET. A simple, friendly guide using record structs, Vogen, and StronglyTypedId, with diagrams and examples.
5 Ways to Check for Duplicates in C# Collections
Learn 5 simple ways to find duplicates in C# collections using HashSet, LINQ Any, GroupBy, and Distinct, with clear examples and a speed comparison.
How to Build a High-Performance Cache in C# Without External Libraries
Build a fast, thread-safe, size-limited LRU cache in C# using only the .NET base class library. Clear diagrams, code, and student-friendly explanations.
The Best Way to Validate Objects in .NET (2024 Guide)
A friendly guide to validating objects in .NET: Data Annotations, FluentValidation, IValidatableObject, and the new built-in validation in .NET 10.