ErikLieben.FA.StronglyTypedIds
2.0.5
See the version list below for details.
dotnet add package ErikLieben.FA.StronglyTypedIds --version 2.0.5
NuGet\Install-Package ErikLieben.FA.StronglyTypedIds -Version 2.0.5
<PackageReference Include="ErikLieben.FA.StronglyTypedIds" Version="2.0.5" />
<PackageVersion Include="ErikLieben.FA.StronglyTypedIds" Version="2.0.5" />
<PackageReference Include="ErikLieben.FA.StronglyTypedIds" />
paket add ErikLieben.FA.StronglyTypedIds --version 2.0.5
#r "nuget: ErikLieben.FA.StronglyTypedIds, 2.0.5"
#:package ErikLieben.FA.StronglyTypedIds@2.0.5
#addin nuget:?package=ErikLieben.FA.StronglyTypedIds&version=2.0.5
#tool nuget:?package=ErikLieben.FA.StronglyTypedIds&version=2.0.5
ErikLieben.FA.StronglyTypedIds
Minimal, allocation-friendly strongly typed IDs for .NET, with a Roslyn source generator that adds JSON conversion, parsing, comparison operators, and helpful extensions.
๐ A Friendly Note
This is an opinionated library built primarily for my own projects and coding style. You're absolutely free to use it (it's MIT licensed!), but please don't expect free support or feature requests. If it works for you, great! If not, there are many other excellent libraries in the .NET ecosystem.
That said, I do welcome bug reports and thoughtful contributions. If you're thinking about a feature or change, please open an issue first to discuss it - this helps avoid disappointment if it doesn't align with the library's direction. ๐
๐ Why Strongly Typed IDs?
Raw GUIDs and primitive types in domain models lead to confusion and bugs. Strongly typed IDs provide:
- ๐ฏ Explicit intent -
AccountId
vsProductId
instead ofGuid
vsGuid
- ๐ Type safety - Prevent accidental mixups between different entity IDs
- ๐งช Better testing - Clear, expressive domain models that are easy to test
- โ๏ธ First-class tooling - JSON serialization, parsing, and conversions work seamlessly
Perfect for Domain-Driven Design, clean architecture, and any application where entity identity matters.
โ When NOT to Use This Library
Consider alternatives when:
- Performance is absolutely critical - The abstraction adds minimal but measurable overhead
- Simple applications - Basic CRUD without complex domain modeling might not benefit
- Team unfamiliarity - Your team isn't comfortable with strongly typed patterns
- Legacy constraints - Existing systems heavily depend on primitive ID types
- Over-engineering risk - Adding typed IDs would increase complexity without clear benefit
๐ฆ Installation
# Core library with base types
dotnet add package ErikLieben.FA.StronglyTypedIds
# Generator for JSON, parsing, and additional features (recommended)
dotnet add package ErikLieben.FA.StronglyTypedIds.Generator
โก Quick Start
Define your ID as a partial record and annotate with [GenerateStronglyTypedIdSupport]
:
using ErikLieben.FA.StronglyTypedIds;
[GenerateStronglyTypedIdSupport]
public partial record AccountId(Guid Value) : StronglyTypedId<Guid>(Value);
[GenerateStronglyTypedIdSupport]
public partial record ProductId(int Value) : StronglyTypedId<int>(Value);
Use them like value objects with generated capabilities:
// Factory methods
var accountId = AccountId.New(); // Generates new Guid
var productId = ProductId.New(); // Random int
// Parsing
var parsed = AccountId.From("550e8400-e29b-41d4-a716-446655440000");
if (ProductId.TryParse("123", out var tryParsed))
{
Console.WriteLine($"Parsed: {tryParsed}");
}
// Type safety - this won't compile!
// ProcessAccount(productId); // โ Compiler error
ProcessAccount(accountId); // โ
Type safe
// JSON serialization works automatically
var json = JsonSerializer.Serialize(accountId);
var roundTrip = JsonSerializer.Deserialize<AccountId>(json);
๐๏ธ Core Architecture
Base Types
// Minimal base record
public abstract record StronglyTypedId<T>(T Value) : IStronglyTypedId<T>
where T : IEquatable<T>
// Interface for generic constraints
public interface IStronglyTypedId<out T>
{
T Value { get; }
}
Generator Attribute
[GenerateStronglyTypedIdSupport] // Enables all features by default
public partial record YourId(Type Value) : StronglyTypedId<Type>(Value);
๐ ๏ธ Supported Underlying Types
The generator provides tailored support for common ID types:
Guid-based IDs (Most Common)
[GenerateStronglyTypedIdSupport]
public partial record CustomerId(Guid Value) : StronglyTypedId<Guid>(Value);
var id = CustomerId.New(); // Guid.NewGuid()
var parsed = CustomerId.From("guid-string"); // Validates and parses
bool isEmpty = id.IsEmpty(); // Checks for Guid.Empty
Integer-based IDs
[GenerateStronglyTypedIdSupport]
public partial record ProductId(int Value) : StronglyTypedId<int>(Value);
var id = ProductId.New(); // Random.Shared.Next()
var specific = ProductId.From("42"); // int.Parse("42")
// Comparison operators work naturally
ProductId a = ProductId.From("1");
ProductId b = ProductId.From("2");
bool less = a < b; // true
Long-based IDs
[GenerateStronglyTypedIdSupport]
public partial record OrderId(long Value) : StronglyTypedId<long>(Value);
var id = OrderId.New(); // Random.Shared.NextInt64()
String-based IDs
[GenerateStronglyTypedIdSupport]
public partial record ExternalKey(string Value) : StronglyTypedId<string>(Value);
var id = ExternalKey.New(); // Guid.NewGuid().ToString()
var custom = ExternalKey.From("ABC-123"); // Direct assignment
DateTime-based IDs
[GenerateStronglyTypedIdSupport]
public partial record TimestampId(DateTimeOffset Value) : StronglyTypedId<DateTimeOffset>(Value);
var id = TimestampId.New(); // DateTimeOffset.UtcNow
bool isEmpty = id.IsEmpty(); // Checks for DateTimeOffset.MinValue
๐ง Generated Features
The [GenerateStronglyTypedIdSupport]
attribute generates:
JSON Serialization
[GenerateStronglyTypedIdSupport]
public partial record UserId(Guid Value) : StronglyTypedId<Guid>(Value);
var user = UserId.New();
var json = JsonSerializer.Serialize(user);
// Output: "550e8400-e29b-41d4-a716-446655440000"
var deserialized = JsonSerializer.Deserialize<UserId>(json);
// Works seamlessly with System.Text.Json
Parsing Methods
// Safe parsing with validation
if (UserId.TryParse("invalid-guid", out var userId))
{
// Won't execute - invalid format
}
// Direct parsing (throws on invalid input)
var validId = UserId.From("550e8400-e29b-41d4-a716-446655440000");
Comparison Operations
var early = TimestampId.From("2024-01-01T00:00:00Z");
var later = TimestampId.From("2024-12-31T23:59:59Z");
bool isEarlier = early < later; // true
bool isSame = early == later; // false (record equality)
bool isLaterOrEqual = later >= early; // true
Factory Methods
// Type-specific intelligent defaults
var accountId = AccountId.New(); // New Guid
var productId = ProductId.New(); // Random int
var timestamp = TimestampId.New(); // Current UTC time
var key = ExternalKey.New(); // Guid as string
Extension Methods
// Empty checks for applicable types
var emptyGuid = new UserId(Guid.Empty);
bool isEmpty = emptyGuid.IsEmpty(); // true
bool hasValue = emptyGuid.IsNotEmpty(); // false
// Collection helpers
var userIds = new[] { UserId.New(), UserId.New(), UserId.New() };
Guid[] values = userIds.ToValues().ToArray(); // Extract underlying values
HashSet<Guid> uniqueValues = userIds.ToValueSet(); // Unique underlying values
Dictionary<Guid, string> lookup = userIds.ToValueDictionary(id => $"User-{id}");
โ๏ธ Customizing Generation
Control which features are generated using attribute properties:
[GenerateStronglyTypedIdSupport(
GenerateJsonConverter = true, // System.Text.Json support
GenerateTypeConverter = true, // System.ComponentModel.TypeConverter
GenerateParseMethod = true, // From() method
GenerateTryParseMethod = true, // TryParse() method
GenerateComparisons = true, // <, <=, >, >= operators
GenerateNewMethod = true, // New() factory method
GenerateExtensions = true // IsEmpty(), collection helpers
)]
public partial record ConfigurableId(Guid Value) : StronglyTypedId<Guid>(Value);
Feature Details
Feature | When Enabled | When Disabled |
---|---|---|
JsonConverter | Automatic serialization with System.Text.Json | Manual converter required |
TypeConverter | Works with model binding, configuration | Manual conversion needed |
ParseMethod | YourId.From(string) available |
Create instances manually |
TryParseMethod | YourId.TryParse(string, out result) available |
Manual validation required |
Comparisons | < , <= , > , >= operators work |
Only equality (== , != ) available |
NewMethod | YourId.New() factory available |
Use constructor: new YourId(value) |
Extensions | IsEmpty() , collection helpers available |
Use .Value property directly |
๐ Complete Domain Example
using System.Text.Json;
using ErikLieben.FA.StronglyTypedIds;
// Define strongly typed IDs for your domain
[GenerateStronglyTypedIdSupport]
public partial record CustomerId(Guid Value) : StronglyTypedId<Guid>(Value);
[GenerateStronglyTypedIdSupport]
public partial record OrderId(long Value) : StronglyTypedId<long>(Value);
[GenerateStronglyTypedIdSupport]
public partial record ProductId(int Value) : StronglyTypedId<int>(Value);
// Domain entities using typed IDs
public record Customer(CustomerId Id, string Name, string Email);
public record Product(ProductId Id, string Name, decimal Price);
public record OrderLine(ProductId ProductId, int Quantity, decimal UnitPrice);
public record Order(OrderId Id, CustomerId CustomerId, OrderLine[] Lines, DateTimeOffset CreatedAt);
// Service methods are type-safe
public class OrderService
{
public Order CreateOrder(CustomerId customerId, OrderLine[] lines)
{
var orderId = OrderId.New(); // Generated factory method
return new Order(orderId, customerId, lines, DateTimeOffset.UtcNow);
}
public Customer? FindCustomer(CustomerId id)
{
// Type safety prevents mixing up different ID types
// This won't compile: FindCustomer(OrderId.New())
return _customers.Find(c => c.Id == id);
}
}
// JSON serialization works seamlessly
var customer = new Customer(CustomerId.New(), "John Doe", "john@example.com");
var json = JsonSerializer.Serialize(customer);
var deserialized = JsonSerializer.Deserialize<Customer>(json);
// Parse from external systems
if (CustomerId.TryParse(externalSystemId, out var parsedId))
{
var customer = orderService.FindCustomer(parsedId);
}
// Work with collections
var customerIds = new[] { CustomerId.New(), CustomerId.New() };
var guidValues = customerIds.ToValues(); // Extract underlying Guids
var uniqueIds = customerIds.ToValueSet(); // HashSet<Guid>
๐ How the Generator Works
The Roslyn source generator:
- Scans your compilation for records inheriting from
StronglyTypedId<T>
- Finds the attribute
[GenerateStronglyTypedIdSupport]
- Analyzes the underlying type (Guid, int, string, etc.)
- Generates appropriate code for each enabled feature
- Emits partial classes that extend your ID types
Generated code includes:
- JSON converters compatible with System.Text.Json
- Type converters for model binding and configuration
- Static factory and parsing methods
- Comparison operators when applicable
- Extension methods for common operations
All generation happens at compile time - there's no runtime reflection or performance impact.
๐ก Best Practices
Do's โ
- Use descriptive names -
CustomerId
,ProductId
,OrderId
instead of genericId
- Be consistent - Use the same underlying type for similar concepts
- Leverage type safety - Let the compiler catch ID mixups at build time
- Generate all features - Unless you have specific performance concerns
- Test with real IDs - Use the
New()
factory in unit tests for realistic scenarios
Don'ts โ
- Don't use for all primitives - Only create typed IDs for entity identifiers
- Don't mix underlying types - Stick to one type (usually Guid) across your domain
- Don't disable safety features - Keep JSON and parsing support enabled unless necessary
- Don't over-engineer - Simple lookup keys might not need strongly typed IDs
๐ Inspiration & Prior Art
This library builds on foundational work in the .NET community around strongly typed IDs and combating primitive obsession:
Primitive Obsession (1999)
The core concept was first formalized by Martin Fowler in his seminal book "Refactoring: Improving the Design of Existing Code", where he identified "Primitive Obsession" as a code smell that occurs when primitive types are overused to represent domain concepts.
Andrew Lock's Pioneering Work
This library was significantly inspired by Andrew Lock's groundbreaking blog series and StronglyTypedId library. His comprehensive work brought strongly typed IDs to mainstream .NET development:
Original Blog Series (2019-2021):
- Part 1: An introduction to strongly-typed entity IDs - The foundational article introducing the concept
- Part 2: Adding JSON converters to strongly typed IDs - ASP.NET Core integration
- Part 3: Using strongly-typed entity IDs with EF Core - Database integration challenges
- Part 4: Strongly-typed IDs in EF Core (Revisited) - Solving EF Core issues
- Part 5: Generating strongly-typed IDs at build-time with Roslyn - First code generation approach
Library Evolution Updates:
- Part 6: Strongly-typed ID update 0.2.1 - Adding System.Text.Json support and new features (2020)
- Part 7: Rebuilding StronglyTypedId as a source generator - Major rewrite using .NET 5 source generators (2021)
- Part 8: Updates to the StronglyTypedId library - simplification, templating, and CodeFixes - Template system and maintainability improvements (2023)
GitHub Repository: andrewlock/StronglyTypedId
Andrew's work demonstrated the value of strongly typed IDs and provided the first widely-adopted source generator solution for .NET. His library uses a struct-based approach with extensive customization options through a template system, evolving from CodeGeneration.Roslyn to native source generators.
While less customizable than Andrew's template system, this library aims to provide a simpler API that covers the majority of use cases for my use cases with minimal complexity.
โ FAQ
Q: Do I need the generator package? A: Technically no - the core types work without it. But you'll miss JSON serialization, parsing helpers, comparisons, and extensions that make strongly typed IDs practical.
Q: Does this work with Entity Framework Core?
A: Yes, but you may need custom value converters. The generated TypeConverter can help, or you can map to the underlying Value
property directly.
Q: Is this compatible with Native AOT? A: Yes! The generator produces regular C# code at compile time. No reflection or runtime code generation is used.
Q: Can I use this with ASP.NET Core model binding? A: Yes, the generated TypeConverter enables automatic conversion from route parameters and form data.
Q: What about performance? A: Minimal overhead - records are value types with efficient equality. The wrapper adds one level of indirection but optimizes well.
๐ License
MIT License - see the LICENSE file for details.
Product | Versions Compatible and additional computed target framework versions. |
---|---|
.NET | net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net9.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.