| name | structid |
|---|---|
| description | Helps define, use, and extend StructId — a zero-dependency, strongly-typed ID library for .NET that uses readonly record structs. Use this skill when working with struct IDs, value-typed identifiers, IStructId, IStructId<TValue>, EF Core converters, Dapper handlers, JSON converters, custom templates ([TStructId]/[TValue]), or INewable factory patterns in a StructId-based project. |
StructId is a zero-dependency, strongly-typed ID library for .NET. Every user-declared ID type is a
readonly partial record struct that implements either IStructId (string-backed) or
IStructId<TValue> (struct-backed). All code is source-generated directly into the consuming
project — there are no runtime package references.
// String-backed ID
public readonly partial record struct ProductId : IStructId;
// Struct-backed ID (Guid, int, long, Ulid, or any struct)
public readonly partial record struct UserId : IStructId<Guid>;
public readonly partial record struct OrderId : IStructId<int>;public partial interface IStructId
{
string Value { get; }
}public partial interface IStructId<TValue> where TValue : struct
{
TValue Value { get; }
}Static interface members for consistent factory methods:
public interface INewable<TSelf>
{
static abstract TSelf New(string value);
}
public interface INewable<TSelf, TValue>
{
static abstract TSelf New(TValue value);
}All struct IDs automatically implement these interfaces via generated code. Use them for generic constraints that require creating new instances:
T CreateId<T>(string value) where T : INewable<T> => T.New(value);
T CreateId<T, V>(V value) where T : INewable<T, V> => T.New(value);The minimum declaration is a readonly partial record struct implementing one of the core interfaces:
public readonly partial record struct UserId : IStructId<Guid>;
public readonly partial record struct ProductId : IStructId; // string-backed
public readonly partial record struct OrderId : IStructId<int>;
public readonly partial record struct TraceId : IStructId<Ulid>; // Ulid supported out of the boxKey requirements (enforced by analyzer with code fixes):
- Must be
readonly - Must be
partial - Must be
record struct - If you declare a primary constructor, it must have a single parameter named
Value
// Custom primary constructor (e.g. to add attributes)
public readonly partial record struct ProductId([property: JsonPropertyName("id")] int Value) : IStructId<int>;For every struct ID, the source generator emits:
- Primary constructor
(TValue Value)(unless you declared one) ValuepropertyIComparable<TSelf>+ comparison operators (<,<=,>,>=) ifTValue : IComparable<TValue>IParsable<TSelf>+ISpanParsable<TSelf>ifTValue : IParsable<TValue>IFormattable+ISpanFormattable+IUtf8SpanFormattableforwarding toValue(when applicable)- Implicit/explicit conversion operators to/from
TValue INewable<TSelf>/INewable<TSelf, TValue>implementationNew(TValue value)static factory methodNew()(parameterless) forGuid- andUlid-backed IDs, usingGuid.CreateVersion7()on .NET 9+ orGuid.NewGuid()on earlier targets /Ulid.NewUlid()
// Guid-backed: parameterless New() generates a new GUID
// On .NET 9+, uses Guid.CreateVersion7(); earlier targets use Guid.NewGuid()
var userId = UserId.New(); // new UserId(Guid.CreateVersion7()) on .NET 9+
var userId2 = UserId.New(someGuid); // new UserId(someGuid)
// Ulid-backed: parameterless New() generates a new ULID
var traceId = TraceId.New(); // new TraceId(Ulid.NewUlid())
// String-backed
var productId = ProductId.New("p-123"); // new ProductId("p-123")
// int-backed (no parameterless New())
var orderId = OrderId.New(42); // new OrderId(42)Reference Microsoft.EntityFrameworkCore — no other configuration needed. The generator emits
value converters and registers them via UseStructId():
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=app.db")
.UseStructId() // registers all struct ID value converters
.Options;
// Or inside OnConfiguring:
protected override void OnConfiguring(DbContextOptionsBuilder builder) => builder.UseStructId();Value type coverage:
- Built-in:
Guid,int,long,string,bool,byte,short,float,double,decimal,DateTime,DateTimeOffset,TimeSpan - Automatic via
IParsable<T>+IFormattable:Ulidand any custom type implementing both - Custom: any
ValueConverter<TModel, TProvider>subclass in your project is auto-registered
Reference Dapper — no other configuration needed. The generator emits SqlMapper.TypeHandler<T>
implementations and registers them via UseStructId():
using var connection = new SqliteConnection("Data Source=app.db");
connection.UseStructId(); // registers all struct ID type handlers
connection.Open();Value type coverage:
- Built-in:
Guid,int,long,string - Automatic via
IParsable<T>+IFormattable:Ulidand any custom type implementing both - Custom: any
SqlMapper.TypeHandler<T>subclass in your project is auto-registered
Activated automatically when the value type implements IParsable<T>. Uses JsonConverter<T>
that serializes/deserializes via the Value property string representation.
Reference Newtonsoft.Json — the generator emits JsonConverter<T> subclasses automatically.
Reference the Ulid NuGet package. Since Ulid implements IParsable<T> and IFormattable:
- EF Core and Dapper handlers are generated automatically
- A parameterless
New()factory is generated usingUlid.NewUlid()
public readonly partial record struct TraceId : IStructId<Ulid>;
var id = TraceId.New(); // new TraceId(Ulid.NewUlid())The template system allows extending all (or a subset of) struct IDs with additional interfaces or members. Templates are regular C# files in your project.
- Must be annotated with
[TStructId] - Must be
file partial record struct(file-scoped to avoid polluting the assembly) - Must be named
TSelf - Primary constructor parameter (if present) must be named
Value— its type controls which struct IDs the template applies to
Apply to all struct IDs (any value type):
[TStructId]
file partial record struct TSelf(TValue Value)
{
public static implicit operator TValue(TSelf id) => id.Value;
public static explicit operator TSelf(TValue value) => new(value);
}
file record struct TValue; // empty = match any value typeApply only to string-backed IDs:
[TStructId]
file partial record struct TSelf(string Value)
{
public static implicit operator string(TSelf id) => id.Value;
public static explicit operator TSelf(string value) => new(value);
}Apply only to Guid-backed IDs:
[TStructId]
file partial record struct TSelf(Guid Value) : IMyGuidId
{
public Guid AsGuid() => Value;
}Apply to IDs whose value type implements a specific interface:
[TStructId]
file partial record struct TSelf(TValue Value) : IComparable<TSelf>
{
public int CompareTo(TSelf other) => ((IComparable<TValue>)Value).CompareTo(other.Value);
public static bool operator <(TSelf left, TSelf right) => left.Value.CompareTo(right.Value) < 0;
public static bool operator <=(TSelf left, TSelf right) => left.Value.CompareTo(right.Value) <= 0;
public static bool operator >(TSelf left, TSelf right) => left.Value.CompareTo(right.Value) > 0;
public static bool operator >=(TSelf left, TSelf right) => left.Value.CompareTo(right.Value) >= 0;
}
// Constrain TValue — only applies to IDs whose value type implements IComparable<TValue>
file record struct TValue : IComparable<TValue>
{
public int CompareTo(TValue other) => throw new NotImplementedException();
}Exclude string from TValue matching:
// /*!string*/ inline comment excludes string-backed IDs
[TStructId]
file partial record struct TSelf(/*!string*/ TValue Value)
{
// only applies to non-string value types
}
file record struct TValue;Add TSelf interface constraint (additional filtering):
[TStructId]
file partial record struct TSelf(Ulid Value)
{
public static TSelf New() => new(Ulid.NewUlid());
}
// This partial declaration is removed at expansion time; it only constrains matching
file partial record struct TSelf : INewable<TSelf, Ulid>
{
public static TSelf New(Ulid value) => throw new NotImplementedException();
}For a struct ID PersonId : IStructId<Guid> and a template applying to Guid-backed IDs:
[TStructId]attribute is removed from the outputTSelfis replaced withPersonIdTValueis replaced withGuid- The primary constructor is removed (provided by
ConstructorGenerator) - The
filemodifier is removed from the type declaration - The output is wrapped in the same namespace as
PersonId - File-local helper types (like
file record struct TValue) are removed from output
To generate unique helper type names per struct ID, use the TSelf_ or TValue_ prefix:
[TStructId]
file partial record struct TSelf(TValue Value)
{
// TSelf_Helper becomes PersonId_Helper, OrderId_Helper, etc.
private sealed class TSelf_Helper { }
}
file record struct TValue;For custom Dapper handlers or EF Core converters for a specific value type, use [TValue]:
[TValue]
file class TValue_Handler : SqlMapper.TypeHandler<TValue>
{
public override void SetValue(IDbDataParameter parameter, TValue value)
=> parameter.Value = value.ToString();
public override TValue Parse(object value)
=> TValue.Parse((string)value, null);
}
file record struct TValue : IParsable<TValue>, IFormattable
{
// Define the value type constraints
}These are automatically discovered and registered in the generated UseStructId extension.
| Diagnostic | Trigger | Auto-Fix Available |
|---|---|---|
| SID001 | Struct ID is not readonly partial record struct |
✅ Add missing modifiers |
| SID002 | Primary constructor parameter not named Value (or multiple params) |
✅ Rename to Value / Remove constructor |
| SID003 | [TStructId] type is not file partial record struct |
✅ Add file modifier |
| SID004 | [TStructId] constructor parameter not named Value |
✅ Rename to Value |
| SID005 | [TStructId] type is not named TSelf |
✅ Rename type |
<PackageReference Include="StructId" Version="*" />- Install only in the top-level project — analyzers and generators propagate transitively to all referencing projects
- The package is
developmentDependency="true"— no runtime dependency is added to consumers
Features activate automatically when the corresponding package is referenced:
| Package | Generated Feature |
|---|---|
Microsoft.EntityFrameworkCore |
ValueConverter<T, TProvider> + UseStructId(DbContextOptionsBuilder) |
Dapper |
SqlMapper.TypeHandler<T> + UseStructId(IDbConnection) |
Newtonsoft.Json |
JsonConverter<T> subclass |
Ulid |
Ulid-specific handlers + parameterless New() factory |
No attribute, configuration, or code change is needed — just add the NuGet reference and rebuild.
public readonly partial record struct UserId : IStructId<Guid>;
public class User
{
public UserId Id { get; set; } = UserId.New();
public string Name { get; set; } = "";
}
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<User>().HasKey(u => u.Id);
}
}
// Setup
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=app.db")
.UseStructId()
.Options;public readonly partial record struct ProductId : IStructId<int>;
using var connection = new SqliteConnection("Data Source=app.db");
connection.UseStructId();
connection.Open();
var product = connection.QueryFirst<Product>(
"SELECT * FROM Products WHERE Id = @Id",
new { Id = new ProductId(42) });public class Repository<TEntity, TId, TValue>
where TId : struct, IStructId<TValue>, INewable<TId, TValue>
where TValue : struct
{
public TEntity GetById(TValue rawValue) => Get(TId.New(rawValue));
private TEntity Get(TId id) => /* ... */;
}// IEntityId.cs — custom interface
public interface IEntityId
{
Guid AsGuid();
}
// EntityIdTemplate.cs — template to implement it for all Guid-backed IDs
[TStructId]
file partial record struct TSelf(Guid Value) : IEntityId
{
public Guid AsGuid() => Value;
}- Struct IDs must be
readonly partial record struct - Always use
IStructId<TValue>for struct value types; useIStructIdfor strings - Templates must be in
file partial record struct TSelfnamed files; no specific file naming required TValueplaceholder in templates means "any value type"; add interfaces to constrain it- Use
TSelf.New()(parameterless) forGuidandUlidIDs;TSelf.New(value)for all others UseStructId()must be called once at startup for EF Core (DbContextOptionsBuilder) and Dapper (IDbConnection)- Custom
ValueConverter<,>andSqlMapper.TypeHandler<T>subclasses in the project are auto-registered