Strongly typed IDs wrap a primitive such as Guid in a distinct C# type like OrderId or CustomerId.
The compiler then rejects identifier mixups between those types.
A readonly record struct supplies value equality, while EF Core converters, JSON converters, and route parsing connect the wrapper to persistence and API boundaries.
The Problem With Primitive IDs
How many times have you seen (or written) this?
public async Task<Result> AssignOrderToCustomer(Guid orderId, Guid customerId)
{
var order = await _orderRepository.GetByIdAsync(customerId); // Bug! Wrong parameter
// ...
}
The compiler doesn't catch it. Both parameters are Guid. It compiles, deploys, and breaks in production.
Or this:
// Which Guid is which?
await _service.ProcessPayment(
orderId,
customerId,
paymentMethodId,
transactionId);
Four Guid parameters. Good luck getting the order right every time.
This is a classic case of primitive obsession: using raw primitives for concepts that deserve their own type.
Strongly typed IDs solve this at the type level. OrderId and CustomerId are different types - swapping them is a compile error.
They're the simplest form of a value object: no identity of their own, equality by value, and a single wrapped primitive.
Basic Implementation
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
}
public readonly record struct ProductId(Guid Value)
{
public static ProductId New() => new(Guid.NewGuid());
}
Using record struct gives you:
- Value equality (two
OrderIdwith the same Guid are equal) - Immutability (with the
readonlymodifier) - No separate object allocation in ordinary unboxed use; boxing still allocates
- Built-in
ToString()
On .NET 9+, consider generating sequential IDs with Guid.CreateVersion7() instead of Guid.NewGuid():
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.CreateVersion7());
}
Guid.CreateVersion7() includes a timestamp in the identifier.
Whether that improves index locality depends on your database's GUID ordering and storage representation; it is not a universal replacement for a provider's sequential key strategy.
Now the method signature makes mistakes impossible:
public async Task<Result> AssignOrderToCustomer(OrderId orderId, CustomerId customerId)
{
var order = await _orderRepository.GetByIdAsync(customerId); // Compile error!
}
Using in Entities
This minimal aggregate base exposes the typed key. Keep domain events and other aggregate behavior in your application's own base type:
public abstract class AggregateRoot<TId>(TId id) where TId : notnull
{
public TId Id { get; private set; } = id;
}
public sealed class Order : AggregateRoot<OrderId>
{
private Order(OrderId id, CustomerId customerId) : base(id)
{
CustomerId = customerId;
}
public CustomerId CustomerId { get; private set; }
public static Order Create(CustomerId customerId)
{
if (customerId.Value == Guid.Empty)
throw new ArgumentException("A customer ID is required.", nameof(customerId));
return new Order(OrderId.New(), customerId);
}
}
EF Core Configuration
EF Core doesn't know how to store your custom types. Add value conversions in the infrastructure project, importing the EF Core configuration namespaces:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.Id)
.HasConversion(
id => id.Value,
value => new OrderId(value))
.ValueGeneratedNever();
builder.Property(o => o.CustomerId)
.HasConversion(
id => id.Value,
value => new CustomerId(value));
}
}
ValueGeneratedNever records that Order.Create supplies the key.
Apply the configuration from your DbContext.OnModelCreating override:
modelBuilder.ApplyConfiguration(new OrderConfiguration());
Convention-Based Registration
Alternatively, register each ID converter through ConfigureConventions in your DbContext.
Import the EF Core value-conversion namespace for the converter:
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Properties<OrderId>()
.HaveConversion<OrderIdConverter>();
}
public class OrderIdConverter : ValueConverter<OrderId, Guid>
{
public OrderIdConverter()
: base(id => id.Value, value => new OrderId(value)) { }
}
Add equivalent converters and conventions for CustomerId and ProductId when those types appear in your model.
The convention replaces the repeated conversion, not the key-generation configuration.
JSON Serialization
ASP.NET Core needs to serialize/deserialize your typed IDs from API requests:
System.Text.Json
Use System.Text.Json and System.Text.Json.Serialization for this converter:
public class OrderIdJsonConverter : JsonConverter<OrderId>
{
public override OrderId Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
return new OrderId(reader.GetGuid());
}
public override void Write(
Utf8JsonWriter writer,
OrderId value,
JsonSerializerOptions options)
{
writer.WriteStringValue(value.Value);
}
}
Register it for Minimal APIs in Program.cs:
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new OrderIdJsonConverter());
});
Add equivalent converters for every other ID exposed in JSON.
For MVC controllers, configure the converter through AddJsonOptions on the builder returned by AddControllers.
Controller and Minimal API JSON options are separate.
Route Parameter Binding
For Minimal API parameter binding, implement the TryParse contract.
Implementing IParsable<T> provides that method and also supports controller binding on modern ASP.NET Core.
Replace the earlier OrderId declaration with:
public readonly record struct OrderId(Guid Value) : IParsable<OrderId>
{
public static OrderId New() => new(Guid.NewGuid());
public static OrderId Parse(string s, IFormatProvider? provider)
{
return new OrderId(Guid.Parse(s));
}
public static bool TryParse(string? s, IFormatProvider? provider, out OrderId result)
{
if (Guid.TryParse(s, out var guid))
{
result = new OrderId(guid);
return true;
}
result = default;
return false;
}
public override string ToString() => Value.ToString();
}
Now it works in route templates:
app.MapGet("/api/orders/{id}", async (OrderId id, IOrderRepository repository) =>
{
var order = await repository.GetByIdAsync(id);
return order is not null ? Results.Ok(order) : Results.NotFound();
});
Reducing Boilerplate
Each typed ID needs: record struct, EF converter, JSON converter, IParsable. That's a lot of boilerplate for a wrapper around Guid.
Source Generators
The StronglyTypedId generator can create wrappers and converter code. Its template-based API is available in the version shown in the project's documentation:
dotnet add package StronglyTypedId --version 1.0.0-beta08
dotnet add package StronglyTypedId.Templates --version 1.0.0-beta08
using StronglyTypedIds;
[StronglyTypedId(Template.Guid, "guid-efcore")]
public partial struct OrderId { }
[StronglyTypedId(Template.Guid, "guid-efcore")]
public partial struct CustomerId { }
This is an alternative to the handwritten types above.
The built-in GUID template supplies equality, parsing, and JSON support; the additional template supplies an EfCoreValueConverter nested in each ID type.
You still register the generated OrderId.EfCoreValueConverter through EF Core's HasConversion method.
Evaluate generated code and package versions as dependencies; using a generator reduces repeated source code but does not remove integration choices.
Generic Base Approach
Or share the common pieces through a base type.
Structs can't inherit from other types, so this approach requires a record class:
public abstract record TypedId<TSelf>(Guid Value)
where TSelf : TypedId<TSelf>
{
public sealed override string ToString() => Value.ToString();
}
public sealed record OrderId(Guid Value) : TypedId<OrderId>(Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
The tradeoff: a record class allocates on the heap and can be null.
For high-throughput code paths, prefer the readonly record struct version and accept a little duplication (or use a source generator).
Can a Strongly Typed ID Still Be Empty?
One real-world trap with struct-based IDs: default(OrderId) is a valid value.
OrderId id = default; // OrderId with Guid.Empty - no constructor ran
OrderId[] ids = new OrderId[1]; // Array elements also start at default.
A struct always has a default value, and no factory method or constructor can prevent it. That means an all-zeros ID can silently flow through your system and hit the database.
Two mitigations:
- Add a guard where IDs enter the system (request validation, factory methods):
if (id.Value == Guid.Empty) return Result.Failure(...) - Validate required IDs in production entry points and aggregate factories, including syntactically valid all-zero GUIDs
This is the one place where the record class approach has an edge: a class-based ID is null when uninitialized, and nullable reference types will warn you about it.
When to Use Strongly Typed IDs
Use them when:
- Methods take multiple ID parameters of the same primitive type
- You've had bugs from swapping parameters
- You're building a rich domain model and want type safety
- Your aggregates reference each other by ID (DDD best practice)
Skip them when:
- Your API is simple CRUD with few entities
- The boilerplate cost outweighs the safety benefit
- Your team finds them confusing
Start small: introduce typed IDs for your most important aggregates first (OrderId, CustomerId). Expand if the pattern proves valuable.
Summary
Strongly typed IDs turn runtime bugs into compile-time errors. You can never accidentally pass an OrderId where a CustomerId is expected.
The setup requires some boilerplate (EF converters, JSON converters, route binding), but source generators like StronglyTypedId handle most of it automatically.
For DDD projects with rich aggregate boundaries, strongly typed IDs are worth the investment.
Thanks for reading.
And stay awesome!
Frequently Asked Questions
What is a strongly typed ID in C#?
A strongly typed ID wraps a primitive like Guid in a small domain-specific type such as OrderId or CustomerId. Because the types are distinct, passing an OrderId where a CustomerId is expected becomes a compile-time error instead of a runtime bug.
Should strongly typed IDs be a class or a struct in C#?
A readonly record struct gives you value equality and avoids a separate object allocation in ordinary unboxed use. A record class supports inheritance and reference-type nullability. Structs can still be boxed, and default values remain possible.
How do you use strongly typed IDs with EF Core?
Add a value conversion that maps the ID type to its underlying primitive, either per property with HasConversion or globally with ConfigureConventions and a ValueConverter per ID type.
How do strongly typed IDs work in ASP.NET Core routes?
Implement IParsable on the ID type. Minimal APIs and controllers then bind route and query parameters automatically, so endpoints can accept OrderId directly.
What is primitive obsession?
Primitive obsession is the code smell of using raw primitives (Guid, string, int, decimal) for concepts that deserve their own type. It loses type safety and scatters validation. Strongly typed IDs and value objects are the cure.



