StrictId.AspNetCore
3.0.0-preview.2
dotnet add package StrictId.AspNetCore --version 3.0.0-preview.2
NuGet\Install-Package StrictId.AspNetCore -Version 3.0.0-preview.2
<PackageReference Include="StrictId.AspNetCore" Version="3.0.0-preview.2" />
<PackageVersion Include="StrictId.AspNetCore" Version="3.0.0-preview.2" />
<PackageReference Include="StrictId.AspNetCore" />
paket add StrictId.AspNetCore --version 3.0.0-preview.2
#r "nuget: StrictId.AspNetCore, 3.0.0-preview.2"
#:package StrictId.AspNetCore@3.0.0-preview.2
#addin nuget:?package=StrictId.AspNetCore&version=3.0.0-preview.2&prerelease
#tool nuget:?package=StrictId.AspNetCore&version=3.0.0-preview.2&prerelease
Strongly-typed, ergonomic, fun to use identifiers for everything, without ceremony or magic
StrictId gives you strongly-typed generic structs for entity IDs.
Four families cover different backing types: Id<T> (ULID), Guid<T> (wraps System.Guid), IdNumber<T> (integer), IdString<T> (opaque string). Since Id<User> and Id<Order> are different closed generics, the compiler catches mix-ups at build time.
Comes with System.Text.Json converters, EF Core value converters/generators, ASP.NET Core integration (OpenAPI, route constraints, ProblemDetails), Roslyn analysers, and AOT support. Single runtime dependency: Ulid.
[IdPrefix("user")]
public class User
{
public Id<User> Id { get; init; } = Id<User>.NewId(); // user_01knfv9xv03499c7bf2brngecz
public Guid<Tenant> TenantId { get; set; } // Guid<T>, strongly-typed Guid
public IdNumber<Invoice> LatestInvoice { get; set; } // inv_42, bigint-backed
public IdString<StripeCustomer> StripeId { get; set; } // cus_abcDEF123, opaque string
}
It's just generic structs and attributes. Decorate your entities with [IdPrefix] if you want prefixed string forms, and you're good to go.
Why StrictId
- Type safety without boilerplate. Libraries like StronglyTypedId and Vogen require declaring a partial type per entity and rely on source generation to create the ID struct for you. StrictId uses closed generics instead, so
Id<User>works immediately. - Simple, obvious API. Things just work when you need them to, with sane defaults and quality-of-life features.
- Ulid, but more. If you already use the Ulid library,
Id<T>wraps it with type safety, prefixed string forms, JSON converters, and EF Core/ASP.NET integration on top. - Covers every backing type. Whether you use Guid, Ulid, slugs, numbers, or all four at the same time, StrictId works seamlessly and consistently.
- Drop-in Guid replacement.
Guid<T>mirrors theSystem.GuidAPI, so switching is mostly find-and-replace. Existing database columns work without migration. - Robust and reliable. Lack of magic and 400+ unit and integration tests make sure I don't cause you any sleepless nights
Get started
dotnet add package StrictId
Optional but recommended, add a global using:
global using StrictId;
Create, parse, format:
var id = Id<User>.NewId(); // Generate a new ULID-backed ID
var parsed = Id<User>.Parse("user_01knfv9xv03499c7bf2brngecz"); // From canonical form
var bare = Id<User>.Parse("01knfv9xv03499c7bf2brngecz"); // Bare ULID also accepted
id.ToString(); // "user_01knfv9xv03499c7bf2brngecz"
id.ToString("B"); // "01knfv9xv03499c7bf2brngecz" (bare)
bool ok = Id<User>.TryParse(input, out var result);
bool valid = Id<User>.IsValid(input); // Like TryParse, without the out var
This pattern (NewId(), Parse, TryParse, IsValid, ToString with format specifiers) is the same across all four families.
Features
- Four ID families.
Id<T>for ULIDs,Guid<T>(wrapsSystem.Guid),IdNumber<T>for integers,IdString<T>for opaque strings. Each also has a non-generic variant (Id,IdNumber,IdString) for type-erased scenarios. - Compile-time type safety.
Id<User>andId<Order>are different closed generic types, so cross-type comparisons, assignments, and method calls are compiler errors. - Prefixed string forms.
[IdPrefix("user")]on your entity type gives youuser_01knfv...output. Supports multiple aliases for backwards-compatible parsing, and the separator character is configurable per type or per assembly. - System.Text.Json converters for every family, including dictionary key support. Included in the core package.
- EF Core value converters and conventions via StrictId.EFCore. Prefixes live in the type system and are never stored in the database.
- ASP.NET Core integration via StrictId.AspNetCore, including OpenAPI schemas with per-type patterns and examples, route constraints, and
ProblemDetailson parse failure. - Roslyn analysers (STRID001–006) catch cross-type
.Valuecomparisons, malformed[IdPrefix]declarations, wrong-family attribute mismatches, and more at compile time. - AOT-friendly. A bundled source generator populates a registry at module init so JSON and EF Core hot paths skip reflection.
- Single runtime dependency: Ulid.
Usage
Id<T>, ULID-backed
The default choice. Backed by a ULID: 128 bits, time-sortable, lexicographically orderable, and friendlier than GUIDs in URLs and logs.
// Create
var id = Id<User>.NewId(); // New ULID
var id = Id<User>.NewId(DateTimeOffset.UtcNow); // With specific timestamp
var id = new Id<User>(someUlid); // Wrap an existing Ulid
var id = new Id<User>(someGuid); // Wrap a Guid (converted to ULID)
// Implicit conversions into Id<T>
Id<User> id = Ulid.NewUlid();
Id<User> id = Guid.NewGuid();
Id<User> id = Id.NewId(); // From non-generic Id
// Parse: accepts prefixed, bare ULID, or GUID string
var id = Id<User>.Parse("user_01knfv9xv03499c7bf2brngecz");
var id = Id<User>.Parse("01knfv9xv03499c7bf2brngecz");
var id = Id<User>.Parse("018ed2a7-8eea-2682-20c5-9f41c7402e73");
// Extract
id.ToUlid(); // Ulid
id.ToGuid(); // System.Guid
id.ToByteArray(); // byte[16]
id.ToBase64(); // Base64 string
id.ToId(); // Non-generic Id (erases the type parameter)
Format specifiers:
| Specifier | Output | Example |
|---|---|---|
C (default) |
Canonical, prefix + ULID | user_01knfv9xv03499c7bf2brngecz |
B |
Bare ULID | 01knfv9xv03499c7bf2brngecz |
G |
Prefix + GUID | user_018ed2a7-8eea-2682-20c5-... |
BG |
Bare GUID | 018ed2a7-8eea-2682-20c5-... |
U |
Uppercase ULID (v2 compat) | 01KNFV9XV03499C7BF2BRNGECZ |
Without a prefix, C and B produce the same output.
The non-generic Id works identically but without prefix support, useful for type-erased scenarios, logging, and generic transport layers.
Guid<T>, drop-in Guid replacement
Wraps System.Guid with a type parameter. The API mirrors System.Guid (same methods, same format specifiers), so switching from Guid to Guid<T> is mostly find-and-replace.
// Create: same methods you already know
var id = Guid<User>.NewGuid(); // V4 (random), same as Guid.NewGuid()
var id = Guid<User>.NewId(); // V7 (time-sortable)
var id = Guid<User>.CreateVersion7(); // Same as NewId(), mirrors Guid.CreateVersion7()
var id = Guid<User>.CreateVersion7(DateTimeOffset.UtcNow); // V7 with specific timestamp
// Implicit from Guid
Guid<User> id = Guid.NewGuid();
// Explicit back to Guid
Guid raw = (Guid)id;
// Parse: standard GUID formats, optionally prefixed
var id = Guid<User>.Parse("user_550e8400-e29b-41d4-a716-446655440000");
var id = Guid<User>.Parse("550e8400-e29b-41d4-a716-446655440000");
// Extract
id.ToGuid(); // System.Guid
id.ToByteArray(); // byte[16]
Format specifiers, the standard Guid set plus C for the canonical prefixed form:
| Specifier | Output | Prefix |
|---|---|---|
C (default) |
Prefix + D-format | Yes |
D |
550e8400-e29b-41d4-a716-446655440000 |
No |
N |
550e8400e29b41d4a716446655440000 |
No |
B |
{550e8400-e29b-41d4-a716-446655440000} |
No |
P |
(550e8400-e29b-41d4-a716-446655440000) |
No |
X |
Hex GUID | No |
Without a prefix, C and D produce the same output, identical to Guid.ToString().
EF Core maps Guid<T> to native uniqueidentifier/uuid, so existing Guid columns work as-is.
IdNumber<T>, integers
For database-assigned auto-increment IDs, counters, or any integer-keyed entity.
[IdPrefix("inv")]
public class Invoice;
var id = new IdNumber<Invoice>(42); // From any integer type
var id = IdNumber<Invoice>.Parse("inv_42"); // Prefixed
var id = IdNumber<Invoice>.Parse("42"); // Bare
id.ToUInt64(); // 42UL
id.ToInt64(); // 42L (throws OverflowException if > long.MaxValue)
id.ToString(); // "inv_42"
id.ToString("B"); // "42"
// Implicit from any integer type
IdNumber<Invoice> id = 42;
IdNumber<T> has no NewId(), numeric IDs come from the database or your own code.
IdString<T>, opaque strings
For third-party IDs (Stripe cus_..., Twilio SM...), slugs, SKUs, and legacy string keys, anything where the ID is an externally-defined string.
[IdPrefix("cus")]
[IdString(MaxLength = 32, CharSet = IdStringCharSet.Alphanumeric)]
public class StripeCustomer;
var id = IdString<StripeCustomer>.Parse("cus_AL8x9Kq4YZ");
id.ToString(); // "cus_AL8x9Kq4YZ"
id.ToString("B"); // "AL8x9Kq4YZ"
id.ToBareString(); // "AL8x9Kq4YZ"
// Implicit from string
IdString<StripeCustomer> id = "AL8x9Kq4YZ";
Validation rules are declared per type with [IdString]:
| Property | Default | Options |
|---|---|---|
MaxLength |
255 | Any positive integer |
CharSet |
AlphanumericDashUnderscore |
Any, Alphanumeric, AlphanumericDash, AlphanumericUnderscore, AlphanumericDashUnderscore |
IgnoreCase |
false |
true for case-insensitive comparison and storage |
IdString<T> has no NewId(), string IDs come from wherever you decide.
Prefixes and separators
Decorate entity types to give their IDs a human-readable prefix:
[IdPrefix("user")]
public class User; // user_01knfv9xv03499c7bf2brngecz
[IdPrefix("order", IsDefault = true)]
[IdPrefix("ord")]
[IdPrefix("o")]
public class Order; // Outputs "order_...", also parses "ord_..." and "o_..."
[IdPrefix("tenant")]
[IdSeparator(IdSeparator.Colon)]
public class Tenant; // tenant:01knfv9xv03499c7bf2brngecz
- Prefixes must match
^[a-z][a-z0-9_]{0,62}$ - With multiple prefixes, exactly one must be
IsDefault = true. That one is used on output, all are accepted on parse. - Attributes inherit from base classes; derived types can override independently
- Malformed prefixes fail the build via STRID003
Available separators: Underscore (default), Slash, Period, Colon. Set per-type with [IdSeparator], or set a project-wide default at assembly level:
[assembly: IdSeparator(IdSeparator.Colon)] // All types in this assembly default to ":"
Parsing is tolerant: any of the four separator characters is accepted on input, regardless of which one is declared.
Type safety
Since each family is a closed generic, the compiler prevents you from mixing up IDs:
var personId = Id<Person>.NewId();
var dogId = Id<Dog>.NewId();
if (personId == dogId) { } // Compiler error
Feed(personId); // Compiler error, persons aren't dogs
// But method overloads work beautifully:
void Feed(Id<Dog> id) => GetDog(id).FeedLeftovers();
void Feed(Id<Person> id) => GetPerson(id).FeedMichelinStarMeal();
Feed(personId); // We eat well tonight
The STRID001 analyser also catches the .Value escape hatch. Comparing personId.Value == dogId.Value triggers a warning.
Shared interface
All four families implement IStrictId<TSelf>, so you can write generic code that works with any StrictId:
public T ParseOrDefault<T>(string? input) where T : struct, IStrictId<T>
=> T.TryParse(input, null, out var id) ? id : T.Empty;
The interface includes Empty, HasValue, IsValid(string?), Parse, TryParse, CompareTo, ToString with format specifiers, and span-based formatting. Parse throws FormatException (matching IParsable<T>) with detailed error messages that include the offending input, the expected shape, and registered prefixes.
EF Core
dotnet add package StrictId.EFCore
Add ConfigureStrictId() to your DbContext:
using StrictId.EFCore;
public class AppDb(DbContextOptions<AppDb> options) : DbContext(options)
{
protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
builder.ConfigureStrictId();
}
}
This registers value converters for all four families:
| Family | Column type | Notes |
|---|---|---|
Id<T> |
char(26) |
Bare ULID, fixed-width, ASCII |
Guid<T> |
Native uniqueidentifier / uuid |
Works with existing Guid columns |
IdNumber<T> |
bigint |
Signed 64-bit |
IdString<T> |
varchar(MaxLength) |
Sized from [IdString] attribute, defaults to 255 |
Prefixes are never stored. They exist only in C# and are reconstituted on read. So you can use your existing database columns without any migrations.
Value generators
Generate IDs automatically when entities are added:
modelBuilder.Entity<User>()
.Property(e => e.Id)
.HasStrictIdValueGenerator<User>(); // Id<User>.NewId() on add
modelBuilder.Entity<Tenant>()
.Property(e => e.Id)
.HasGuidValueGenerator<Tenant>(); // Guid<Tenant>.NewId() (V7) on add
Alternative column type for Id<T>
If you prefer a native GUID column for Id<T>, use IdToGuidConverter<T>, which reorders ULID bytes for SQL Server sort compatibility.
ASP.NET Core
dotnet add package StrictId.AspNetCore
Route and query binding work via ISpanParsable<T> (.NET 7+):
app.MapGet("/users/{id}", (Id<User> id) => db.Users.Find(id));
For OpenAPI, route constraints, and error handling, add AddStrictId():
using StrictId.AspNetCore;
builder.Services.AddStrictId();
This registers:
OpenAPI: StrictId parameters and properties render as string schemas with a per-type pattern, example, and description instead of the raw underlying type.
Route constraints: pre-filter URL segments before they reach your action:
app.MapGet("/users/{id:id}", ...); // Matches ULID or GUID format
app.MapGet("/invoices/{id:idnumber}", ...); // Matches decimal integers
app.MapGet("/products/{id:idstring}", ...); // Matches non-empty strings
app.MapGet("/tenants/{id:strictguid}", ...); // Matches GUID format
(strictguid instead of guid because ASP.NET Core already has a built-in guid constraint.)
ProblemDetails: StrictId parse failures become RFC 7807 400 Bad Request responses with the full diagnostic in detail.
TypeConverters: for legacy model binding paths (System.ComponentModel, configuration binding).
Cherry-pick if you only want some of it:
builder.Services.AddStrictIdOpenApi();
builder.Services.AddStrictIdRouteConstraints();
builder.Services.AddStrictIdTypeConverters();
builder.Services.AddStrictIdProblemDetails();
Analysers
Six Roslyn analysers ship inside the main package:
| ID | Severity | What it catches |
|---|---|---|
| STRID001 | Warning | Cross-type .Value comparison (userId.Value == orderId.Value) |
| STRID002 | Info | default(Id<T>) assigned to an Id property, probably meant NewId() |
| STRID003 | Error | Invalid [IdPrefix]: bad grammar, duplicates, missing IsDefault |
| STRID004 | Error | Invalid [IdSeparator]: out-of-range enum cast |
| STRID005 | Warning | Wrong ID family for entity's attributes (e.g. using Id<T> when T has [IdString]) |
| STRID006 | Warning | StrictId closed with an open generic type parameter instead of a concrete type |
v1/v2 to v3 migration
v3 is a ground-up rewrite, but your existing IDs should continue to work in most cases. The highlights:
new Id<T>("string")is gone, useId<T>.Parse("string")- Default
ToString()is now lowercase, use"U"format specifier for v2 uppercase Id<T>toIdconversion is now explicit (other direction stays implicit)IIdinterface replaced byIStrictId<TSelf>
Acknowledgements
- Ulid, ULID implementation for C#, the engine behind
Id<T> - StronglyTypedId, for exploring this space first
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. 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. |
-
net10.0
- Microsoft.AspNetCore.OpenApi (>= 10.0.5)
- StrictId (>= 3.0.0-preview.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.0.0-preview.2 | 43 | 4/6/2026 |
v3.0.0. New package in the StrictId family. Ships an OpenAPI schema
transformer and operation transformer that emit string schemas with per-
closed-generic pattern, example, and description; three route constraints
(id, idnumber, idstring); TypeConverter subclasses for legacy model binding;
and a ProblemDetails mapping for StrictId FormatExceptions raised during
model binding. Route binding via ISpanParsable<T> works out of the box in
.NET 7+; this package adds the polish. One-liner wire-up via
services.AddStrictId().