# Strongly Typed IDs in C# to Prevent Primitive Obsession

> Passing Guid parameters around is error-prone. Strongly typed IDs wrap primitives in domain-specific types so you cannot accidentally pass an OrderId where a CustomerId is expected. Here is how to implement them in C# with EF Core support.

Published: 2026-09-22. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/strongly-typed-ids-csharp

**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?

```csharp
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:

```csharp
// 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**](https://milanjovanovic.tech/blog/entity-vs-value-object-ddd): no identity of their own, equality by value, and a single wrapped primitive.

## Basic Implementation

```csharp
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 `OrderId` with the same Guid are equal)
- Immutability (with the `readonly` modifier)
- 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()`:

```csharp
public readonly record struct OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.CreateVersion7());
}
```

[**`Guid.CreateVersion7()`**](https://learn.microsoft.com/en-us/dotnet/api/system.guid.createversion7?view=net-10.0) 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:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
```

```csharp
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<OrderId>()
        .HaveConversion<OrderIdConverter>();

}
```

```csharp
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:

```csharp
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`:

```csharp
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**](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding?view=aspnetcore-10.0#tryparse), 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:

```csharp
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:

```csharp
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**](https://github.com/andrewlock/StronglyTypedId) generator can create wrappers and converter code.
Its template-based API is available in the version shown in the project's documentation:

```bash
dotnet add package StronglyTypedId --version 1.0.0-beta08
dotnet add package StronglyTypedId.Templates --version 1.0.0-beta08
```

```csharp
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:

```csharp
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.

```csharp
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**](https://milanjovanovic.tech/blog/refactoring-from-an-anemic-domain-model-to-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**](https://milanjovanovic.tech/blog/domain-driven-design-dotnet-getting-started) projects with rich [**aggregate**](https://milanjovanovic.tech/blog/aggregate-design-ddd) 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.
