Minimal, allocation-friendly strongly typed IDs for .NET, with a Roslyn source generator that adds JSON conversion, parsing, comparison operators, and helpful extensions.
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. 😊
Raw GUIDs and primitive types in domain models lead to confusion and bugs. Strongly typed IDs provide:
- 🎯 Explicit intent -
AccountIdvsProductIdinstead ofGuidvsGuid - 🔒 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.
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
# 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.GeneratorDefine 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
var emptyId = AccountId.Empty; // Static empty value for Guid/DateTime types
// Implicit conversion from underlying type
AccountId id = Guid.NewGuid(); // Clean and concise!
// Parsing
var parsed = AccountId.From("550e8400-e29b-41d4-a716-446655440000");
if (ProductId.TryParse("123", out var tryParsed))
{
Console.WriteLine($"Parsed: {tryParsed}");
}
// Culture-specific parsing for numeric/date types
var decimalId = DecimalId.From("1.234,56", CultureInfo.GetCultureInfo("de-DE"));
// 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);// 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; }
}[GenerateStronglyTypedIdSupport] // Enables all features by default
public partial record YourId(Type Value) : StronglyTypedId<Type>(Value);The generator provides tailored support for common ID types:
[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[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 (IComparable<T> is implemented)
ProductId a = ProductId.From("1");
ProductId b = ProductId.From("2");
bool less = a < b; // true
int comparison = a.CompareTo(b); // -1[GenerateStronglyTypedIdSupport]
public partial record OrderId(long Value) : StronglyTypedId<long>(Value);
[GenerateStronglyTypedIdSupport]
public partial record SequenceId(short Value) : StronglyTypedId<short>(Value);
[GenerateStronglyTypedIdSupport]
public partial record BatchId(byte Value) : StronglyTypedId<byte>(Value);
// All numeric types supported: int, long, decimal, short, byte, double, float
// Choose based on your domain needs and external system constraints[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[GenerateStronglyTypedIdSupport]
public partial record TimestampId(DateTimeOffset Value) : StronglyTypedId<DateTimeOffset>(Value);
var id = TimestampId.New(); // DateTimeOffset.UtcNow
bool isEmpty = id.IsEmpty(); // Checks for DateTimeOffset.MinValueThe [GenerateStronglyTypedIdSupport] attribute generates:
[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// 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");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
// IComparable<T> is implemented for sortable types
int compareResult = early.CompareTo(later); // -1 (early comes before later)
var sorted = new[] { later, early }.OrderBy(x => x).ToArray(); // [early, later]All generated IDs include a DebuggerDisplay attribute for a better debugging experience:
var userId = UserId.New();
// In debugger: shows "550e8400-e29b-41d4-a716-446655440000" instead of "UserId { Value = ... }"The generated code also includes comprehensive XML documentation and avoids unnecessary using directives for a cleaner codebase.
// 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
// Static Empty property for types with natural empty values
var emptyGuid = UserId.Empty; // new UserId(Guid.Empty)
var emptyDate = TimestampId.Empty; // new TimestampId(DateTime.MinValue)
// Implicit conversion from underlying type
UserId userId = Guid.NewGuid(); // Implicit conversion
Guid guid = (Guid)userId; // Explicit conversion to underlying typeFor numeric and DateTime types, format provider overloads are generated:
// Parse with specific culture
var germanDecimal = DecimalId.From("1.234,56", CultureInfo.GetCultureInfo("de-DE"));
var usDecimal = DecimalId.From("1,234.56", CultureInfo.GetCultureInfo("en-US"));
// TryParse with culture
if (DecimalId.TryParse("1.234,56", CultureInfo.GetCultureInfo("de-DE"), out var result))
{
Console.WriteLine($"Parsed: {result}");
}
// Works with DateTime too
var germanDate = DateId.From("31.12.2024", CultureInfo.GetCultureInfo("de-DE"));// 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}");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 | 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 |
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>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 (with optional culture-specific overloads)
- Comparison operators and
IComparable<T>implementation when applicable - Implicit/explicit conversion operators for cleaner syntax
- Static
Emptyproperty for types with natural empty values - Extension methods for common operations
DebuggerDisplayattribute for improved debugging- Comprehensive XML documentation
- Optimized using directives (only includes what's needed)
All generation happens at compile time - there's no runtime reflection or performance impact.
- Use descriptive names -
CustomerId,ProductId,OrderIdinstead 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'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
This library builds on foundational work in the .NET community around strongly typed IDs and combating primitive obsession:
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.
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.
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.
MIT License - see the LICENSE file for details.