WhizbangIds: Strongly-Typed Identity Values¶
Verified by tests
TrackedGuidTests, TrackedGuidJsonConverterTests, TrackedGuidMonotonicityTests, GuidMetadataTests, WhizbangIdTests, WhizbangIdProviderTests, WhizbangIdProviderRegistryTests, WhizbangIdServiceCollectionExtensionsTests, Uuid7IdProviderTests, IWhizbangIdProviderGenericTests, GuidOrderingValidatorTests, WhizbangIdGeneratorTests, GuidInterceptorGeneratorTests, ThirdPartyGuidInterceptionTests, GuidUsageAnalyzerTests, GuidToTrackedGuidTransformerTests — library CI run #37346231411 (2026-10-05)
Whizbang uses strongly-typed identity values based on UUIDv7 for all identifiers. This provides type safety, prevents ID mixing mistakes, and enables AOT-compatible dependency injection.
Overview¶
WhizbangIds are source-generated value types that: - ✅ Wrap UUIDv7 GUIDs for time-ordered, database-friendly identities - ✅ Provide compile-time type safety (can't mix OrderId with CustomerId) - ✅ Support both static and DI-based ID generation - ✅ Are fully AOT-compatible (zero reflection) - ✅ Auto-register with DI via ModuleInitializer
TrackedGuid: Metadata-Aware GUID Wrapper¶
For scenarios where you need to work with raw GUIDs while preserving generation metadata, Whizbang provides TrackedGuid:
TrackedGuid: Metadata-Aware GUID Wrapper
using Whizbang.Core.ValueObjects;
// Create with sub-millisecond precision (recommended)
var tracked = TrackedGuid.New(); // The framework's UUIDv7 generator
// Check metadata
bool isTimeOrdered = tracked.IsTimeOrdered; // true
bool subMs = tracked.SubMillisecondPrecision; // true
DateTimeOffset when = tracked.Timestamp; // Extracted from UUIDv7
GuidMetadatas metadata = tracked.Metadata; // Version7 | SourceWhizbang
// Implicit conversion to Guid
Guid guid = tracked;
// Parse from external sources (database, API)
var parsed = TrackedGuid.Parse("550e8400-e29b-41d4-a716-446655440000");
var external = TrackedGuid.FromExternal(someGuid);
Why TrackedGuid?¶
| Feature | Guid.NewGuid() |
Guid.CreateVersion7() |
TrackedGuid.New() |
|---|---|---|---|
| Time-ordered | ❌ No (v4) | ✅ Yes (v7) | ✅ Yes (v7) |
| Sub-millisecond precision | ❌ N/A | ❌ No (ms only) | ✅ Yes |
| Metadata preserved | ❌ No | ❌ No | ✅ Yes |
| Monotonic counter | ❌ No | ❌ No | ✅ Yes |
| Database index friendly | ❌ Poor | ✅ Good | ✅ Excellent |
Recommendation: Use [WhizbangId] types for domain identities, TrackedGuid for infrastructure code that needs GUID flexibility with metadata preservation.
How New() orders ids¶
{verified: Uuid7GeneratorTests.Shared_OneMillionIdsInATightLoop_AreStrictlyIncreasingAsync, Uuid7GeneratorTests.Shared_ManyThreadsAtOnce_AreUniqueAndIncreasingPerThreadAsync, Uuid7GeneratorTests.Shared_IssueOrderIsSortOrder_AcrossThreadsAsync, Uuid7GeneratorTests.NewGuid_CounterExhausted_BorrowsTheNextMillisecondInsteadOfWrappingAsync, Uuid7GeneratorTests.NewGuid_ClockGoesBackwards_KeepsTheLastMillisecondAndKeepsCountingAsync}
TrackedGuid.New() is served by the framework's own UUIDv7 generator. Every id it issues sorts after every id issued before it in the process, compared as big-endian bytes, which is how the string form, the wire and PostgreSQL's uuid type order them. Events, cursors and claims are ordered by id across the framework, so that one property carries a lot.
| Bits | Content |
|---|---|
| 0 to 47 | Unix time in milliseconds |
| 48 to 51 | Version 0111 |
| 52 to 63 | Counter, high 12 bits |
| 64 to 65 | Variant 10 |
| 66 to 79 | Counter, low 14 bits |
| 80 to 127 | Random |
The 26-bit counter orders ids within one millisecond. The first id of a millisecond seeds it from 25 random bits, leaving at least 2^25 of room; each further id adds a random step of 1 to 16, so the next id is hard to guess without costing the order. Every id is issued under one lock, so the order ids leave the generator is their sort order even across threads.
Two edge cases are handled explicitly:
- Counter exhausted. The generator moves to the next millisecond and reseeds, rather than wrapping to a smaller counter inside the same millisecond.
- Clock goes backwards. The generator keeps issuing in the last millisecond it used and keeps counting, then resumes real time once the clock passes it again.
The layout is RFC 9562 version 7 with a fixed-length dedicated counter (section 6.2, method 1). Ids used to come from the Medo.Uuid7 package and now come from the framework's own generator, tagged SourceWhizbang. They have exactly the shape they had before, so ids already stored or in flight read as they always did.
Tracking GUID Sources¶
TrackedGuid tracks where and how each GUID was created using the GuidMetadatas flags (note the plural — the enum type is GuidMetadatas, declared in GuidMetadata.cs):
Tracking GUID Sources
// Freshly created - full metadata available
var fresh = TrackedGuid.New();
Console.WriteLine(fresh.IsTracking); // true (authoritative)
Console.WriteLine(fresh.SubMillisecondPrecision); // true (known)
Console.WriteLine(fresh.Metadata); // Version7 | SourceWhizbang
// Loaded from database - metadata is inferred
var loaded = TrackedGuid.FromExternal(dbGuid);
Console.WriteLine(loaded.IsTracking); // false (not authoritative)
Console.WriteLine(loaded.SubMillisecondPrecision); // false (unknown source)
Console.WriteLine(loaded.Metadata); // Version7 | SourceExternal (inferred)
Key Point: Only GUIDs created through New(), NewMicrosoftV7(), or NewRandom() (and interceptor-generated FromIntercepted calls) have authoritative metadata (IsTracking = true — defined as having a SourceWhizbang, SourceMedo or SourceMicrosoft flag). GUIDs loaded from external sources have inferred metadata based on version detection.
Debugging with TrackedGuid¶
TrackedGuid helps you debug GUID-related issues by tracking creation sources and timestamps:
Problem 1: "Where did this GUID come from?"¶
Problem 1: 'Where did this GUID come from?'
public class OrderService {
private readonly ILogger<OrderService> _logger;
public async Task ProcessOrderAsync(TrackedGuid orderId) {
// Debug: Check if this ID was freshly created or loaded
if (!orderId.IsTracking) {
_logger.LogWarning(
"OrderId {OrderId} has no tracking metadata - loaded from external source",
orderId);
}
// Check source
var source = orderId.Metadata switch {
var m when (m & GuidMetadatas.SourceWhizbang) != 0 => "TrackedGuid.New()",
var m when (m & GuidMetadatas.SourceMedo) != 0 => "Medo.Uuid7 (called directly)",
var m when (m & GuidMetadatas.SourceMicrosoft) != 0 => "Microsoft GUID",
var m when (m & GuidMetadatas.SourceExternal) != 0 => "Database/API",
var m when (m & GuidMetadatas.SourceParsed) != 0 => "Parsed string",
_ => "Unknown"
};
_logger.LogInformation(
"Processing order {OrderId} from source: {Source}, IsV7: {IsV7}",
orderId,
source,
orderId.IsTimeOrdered);
}
}
Output:
Problem 2: "Why are my IDs not sorting chronologically?"¶
Problem 2: 'Why are my IDs not sorting chronologically?'
public void DebugIdOrdering(List<TrackedGuid> ids) {
foreach (var id in ids) {
var timestamp = id.Timestamp;
var version = (id.Metadata & GuidMetadatas.Version7) != 0 ? "v7" : "v4";
var precision = id.SubMillisecondPrecision ? "sub-ms" : "ms-only";
Console.WriteLine(
$"ID: {id}, Version: {version}, Timestamp: {timestamp:O}, Precision: {precision}");
if (!id.IsTimeOrdered) {
Console.WriteLine(" ⚠️ WARNING: This is a UUIDv4 - not time-ordered!");
}
if (!id.SubMillisecondPrecision && id.IsTimeOrdered) {
Console.WriteLine(
" ⚠️ WARNING: Millisecond-only precision - IDs within same ms may not sort correctly");
}
}
}
Output:
ID: 019c7df5-494b-77d6-b994-e7145b796ec0, Version: v7, Timestamp: 2025-01-15T14:32:15.0000000Z, Precision: sub-ms
ID: 550e8400-e29b-41d4-a716-446655440000, Version: v4, Timestamp: 0001-01-01T00:00:00.0000000Z, Precision: ms-only
⚠️ WARNING: This is a UUIDv4 - not time-ordered!
Problem 3: "Did I use the right GUID generator?"¶
Problem 3: 'Did I use the right GUID generator?'
public class IdGenerationValidator {
public void ValidateIdUsage(TrackedGuid id, string context) {
// Check if using recommended generator
if ((id.Metadata & GuidMetadatas.SourceWhizbang) != 0) {
Console.WriteLine($"✅ {context}: Using the recommended TrackedGuid.New()");
return;
}
// Check if using Microsoft v7 (acceptable but not optimal)
if ((id.Metadata & GuidMetadatas.SourceMicrosoft) != 0 &&
(id.Metadata & GuidMetadatas.Version7) != 0) {
Console.WriteLine(
$"⚠️ {context}: Using Guid.CreateVersion7() - consider TrackedGuid.New() for sub-ms precision");
return;
}
// Check if using v4 (problematic)
if ((id.Metadata & GuidMetadatas.Version4) != 0) {
Console.WriteLine(
$"❌ {context}: Using UUIDv4 (random) - not time-ordered, fragments indexes");
return;
}
// External/Unknown source
Console.WriteLine($"ℹ️ {context}: Source unknown - loaded from external system");
}
}
// Usage
var validator = new IdGenerationValidator();
validator.ValidateIdUsage(TrackedGuid.New(), "OrderId");
validator.ValidateIdUsage(TrackedGuid.NewRandom(), "TestId");
Output:
✅ OrderId: Using the recommended TrackedGuid.New()
❌ TestId: Using UUIDv4 (random) - not time-ordered, fragments indexes
Problem 4: "When was this GUID created?"¶
Problem 4: 'When was this GUID created?'
public void InvestigateEventTiming(TrackedGuid eventId) {
if (!eventId.IsTimeOrdered) {
Console.WriteLine("Cannot extract timestamp - this is not a UUIDv7");
return;
}
var timestamp = eventId.Timestamp;
var now = DateTimeOffset.UtcNow;
var age = now - timestamp;
Console.WriteLine($"Event {eventId}:");
Console.WriteLine($" Created: {timestamp:O}");
Console.WriteLine($" Age: {age.TotalSeconds:F2} seconds");
if (age.TotalMinutes > 5) {
Console.WriteLine(" ⚠️ WARNING: Event is more than 5 minutes old - potential processing delay");
}
}
Output:
Event 019c7df5-494b-77d6-b994-e7145b796ec0:
Created: 2025-01-15T14:32:15.4940000Z
Age: 127.53 seconds
⚠️ WARNING: Event is more than 5 minutes old - potential processing delay
JSON Serialization with TrackedGuidJsonConverter¶
TrackedGuid serializes as a plain UUID string, not as an object with metadata:
JSON Serialization with TrackedGuidJsonConverter
using System.Text.Json;
using Whizbang.Core.ValueObjects;
public class Order {
public TrackedGuid OrderId { get; set; }
public string CustomerName { get; set; }
}
var order = new Order {
OrderId = TrackedGuid.New(),
CustomerName = "Alice"
};
// TrackedGuid has no [JsonConverter] attribute — register the converter in your
// options. (Whizbang registers it automatically in its JsonContextRegistry, so
// framework serialization paths already use it.)
var options = new JsonSerializerOptions {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new TrackedGuidJsonConverter() }
};
// Serialize to JSON
var json = JsonSerializer.Serialize(order, options);
// Result: {"orderId":"019c7df5-494b-77d6-b994-e7145b796ec0","customerName":"Alice"}
// NOT: {"orderId":{"value":"...","metadata":5}}
// Deserialize from JSON
var deserialized = JsonSerializer.Deserialize<Order>(json, options);
Console.WriteLine(deserialized.OrderId.IsTracking); // false (metadata lost)
Console.WriteLine(deserialized.OrderId.Metadata); // Version7 | SourceExternal (inferred via FromExternal)
Why serialize as string? - PostgreSQL UUID column compatibility - Efficient JSONB queries in databases - Interoperability with systems expecting standard UUID format - Smaller JSON payload (no metadata object overhead)
Important: Metadata is not preserved across serialization boundaries. After deserialization, IsTracking will be false and metadata is inferred from the GUID version.
TrackedGuid Interception (Opt-In)¶
Whizbang includes an optional compile-time interceptor that automatically wraps GUID creation calls with TrackedGuid, preserving metadata about the GUID source and version.
Enabling Interception¶
Add to your project file to enable automatic interception:
Enabling Interception
<PropertyGroup>
<WhizbangGuidInterceptionEnabled>true</WhizbangGuidInterceptionEnabled>
</PropertyGroup>
When enabled, the following calls are intercepted:
- Guid.NewGuid() → TrackedGuid with Version4 | SourceMicrosoft
- Guid.CreateVersion7() → TrackedGuid with Version7 | SourceMicrosoft
- Third-party libraries (Marten, UUIDNext, Medo.Uuid7)
How It Works¶
The GuidInterceptorGenerator uses C# 12 [InterceptsLocation] to replace GUID creation calls at compile-time.
GuidMetadatas Flags¶
The GuidMetadatas flags enum (declared in GuidMetadata.cs) tracks both the UUID version and creation source:
GuidMetadatas Flags
namespace Whizbang.Core.ValueObjects;
[Flags]
public enum GuidMetadatas : ushort {
None = 0,
// UUID Version (bits 0-1)
Version4 = 1 << 0, // Random UUID - not time-ordered
Version7 = 1 << 1, // Time-ordered UUID - chronologically sortable
// Creation Source (bits 2-6)
SourceMedo = 1 << 2, // Medo.Uuid7 called directly (detected by interception)
SourceMicrosoft = 1 << 3, // Guid.NewGuid() / CreateVersion7()
SourceParsed = 1 << 4, // Parsed from string
SourceExternal = 1 << 5, // From database, API, deserialization
SourceUnknown = 1 << 6, // Implicit conversion from Guid
Reserved = 1 << 7, // Reserved for future use
// Third-Party Libraries (bits 8-13)
SourceMarten = 1 << 8, // Marten CombGuidIdGeneration
SourceUuidNext = 1 << 9, // UUIDNext library
SourceDaanV2 = 1 << 10, // DaanV2.UUID library
SourceUuids = 1 << 11, // UUIDs library
SourceGuidOne = 1 << 12, // GuidOne library
SourceTaiizor = 1 << 13, // Taiizor UUID library
// Framework (bit 14)
SourceWhizbang = 1 << 14 // TrackedGuid.New() - the framework's own generator, sub-millisecond precision
}
Usage:
GuidMetadatas Flags (2)
// Your code (with interception enabled)
var id = Guid.NewGuid();
// After interception (generated code — FromIntercepted is internal,
// callable only by the generated interceptors)
var id = TrackedGuid.FromIntercepted(
Guid.NewGuid(),
GuidMetadatas.Version4 | GuidMetadatas.SourceMicrosoft);
// Check metadata flags
bool isV7 = (id.Metadata & GuidMetadatas.Version7) != 0;
bool fromWhizbang = (id.Metadata & GuidMetadatas.SourceWhizbang) != 0; // TrackedGuid.New()
bool fromMedo = (id.Metadata & GuidMetadatas.SourceMedo) != 0; // consumer code calling Medo.Uuid7
// Common combinations (internal helpers)
// WHIZBANG_V7 = Version7 | SourceWhizbang
// MICROSOFT_V7 = Version7 | SourceMicrosoft
// EXTERNAL_V7 = Version7 | SourceExternal
Why Track Sources?
Different GUID generators have different characteristics:
- TrackedGuid.New() (the framework's generator): Sub-millisecond precision, monotonic counter
- Microsoft v7: Millisecond precision only
- Microsoft v4: Random, not time-ordered
- External: Unknown precision and ordering guarantees
Tracking the source helps you: - Validate time-ordering assumptions - Debug GUID generation issues - Enforce UUIDv7 usage policies - Understand precision limitations
Suppressing Interception¶
Use [SuppressGuidInterception] to opt-out of interception:
Suppressing Interception
using Whizbang.Core;
public class LegacyService {
[SuppressGuidInterception]
public Guid CreateLegacyId() {
return Guid.NewGuid(); // Not intercepted
}
}
// Or suppress entire class
[SuppressGuidInterception]
public class TestFixtures {
// All Guid calls in this class are not intercepted
}
Runtime Validation¶
Use GuidOrderingValidator to validate TrackedGuids at runtime:
Runtime Validation
using Whizbang.Core.Configuration;
using Whizbang.Core.Validation;
var options = new WhizbangOptions {
GuidOrderingViolationSeverity = GuidOrderingSeverity.Warning
};
var validator = new GuidOrderingValidator(options, logger);
// Validates that the GUID is time-ordered (v7)
validator.ValidateForTimeOrdering(trackedGuid, "EventId");
// Logs warning if v4 GUID is used where v7 is expected
Configuration options:
- DisableGuidTracking - Bypass all validation (default: false)
- GuidOrderingViolationSeverity - None, Info, Warning (default), Error
Diagnostics¶
GuidUsageAnalyzer: Roslyn Analyzer (WHIZ055-WHIZ056)¶
Whizbang includes a Roslyn analyzer (GuidUsageAnalyzer) that detects problematic GUID generation patterns at compile-time:
GuidUsageAnalyzer: Roslyn Analyzer (WHIZ055-WHIZ056)
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class GuidUsageAnalyzer : DiagnosticAnalyzer {
// Detects: Guid.NewGuid() (WHIZ055) and Guid.CreateVersion7() (WHIZ056)
}
The analyzer runs during compilation and provides instant feedback in your IDE when you use GUID patterns that could cause problems.
WHIZ055: Guid.NewGuid() Usage¶
Severity: Warning
WHIZ055: Guid.NewGuid() Usage
// ⚠️ Warning: Use TrackedGuid.New() or a [WhizbangId] type instead
var id = Guid.NewGuid(); // WHIZ055: Detected at compile-time
// IDE shows squiggle and warning
// ✅ Fix 1: Use TrackedGuid
var id = TrackedGuid.New();
// ✅ Fix 2: Use strongly-typed ID
var orderId = OrderId.New();
Why: Guid.NewGuid() creates UUIDv4 (random) which:
- Is not time-ordered → breaks chronological assumptions
- Fragments database indexes → poor query performance
- Has no timestamp → can't extract creation time
Impact: B-tree indexes in PostgreSQL/SQL Server fragment over time, causing page splits and degraded performance.
WHIZ056: Guid.CreateVersion7() Usage¶
Severity: Warning
WHIZ056: Guid.CreateVersion7() Usage
// ⚠️ Warning: Use TrackedGuid.New() for sub-millisecond precision
var id = Guid.CreateVersion7(); // WHIZ056: Detected at compile-time
// ✅ Fix: Use TrackedGuid for sub-millisecond precision
var id = TrackedGuid.New();
Why: Guid.CreateVersion7() only has millisecond precision:
- In high-throughput scenarios, multiple IDs within same millisecond may not sort correctly
- TrackedGuid.New() provides sub-millisecond precision + a monotonic counter
- Better ordering guarantees in distributed systems
Real-World Example:
WHIZ056: Guid.CreateVersion7() Usage (2)
// Problematic with Guid.CreateVersion7()
for (int i = 0; i < 100; i++) {
var id = Guid.CreateVersion7(); // Multiple IDs in same millisecond
await InsertEventAsync(id); // May not sort correctly!
}
// Fixed with TrackedGuid.New()
for (int i = 0; i < 100; i++) {
var id = TrackedGuid.New(); // Sub-millisecond + monotonic counter
await InsertEventAsync(id); // Guaranteed correct ordering
}
Suppressing Analyzer Warnings¶
For legitimate cases where you need raw GUID operations:
Suppressing Analyzer Warnings
// Suppress for specific line
#pragma warning disable WHIZ055
var testId = Guid.NewGuid(); // Intentional for test fixture
#pragma warning restore WHIZ055
// Suppress for entire method
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Whizbang.SourceGeneration",
"WHIZ055:Guid.NewGuid() Usage")]
public Guid CreateTestGuid() {
return Guid.NewGuid(); // Analyzer suppressed
}
// Suppress for entire class
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Whizbang.SourceGeneration",
"WHIZ055:Guid.NewGuid() Usage")]
public class LegacyGuidService {
// All Guid.NewGuid() calls in this class are suppressed
}
// Or suppress in project file (for test projects)
<PropertyGroup>
<NoWarn>$(NoWarn);WHIZ055;WHIZ056</NoWarn>
</PropertyGroup>
How the Analyzer Works¶
The GuidUsageAnalyzer uses syntax node analysis to detect problematic patterns:
How the Analyzer Works
public override void Initialize(AnalysisContext context) {
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
// Register for method invocations
context.RegisterSyntaxNodeAction(_analyzeInvocation, SyntaxKind.InvocationExpression);
}
private static void _analyzeInvocation(SyntaxNodeAnalysisContext context) {
var invocation = (InvocationExpressionSyntax)context.Node;
var methodSymbol = context.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol;
if (methodSymbol?.ContainingType?.ToDisplayString() == "System.Guid") {
if (methodSymbol.Name == "NewGuid") {
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.GuidNewGuidUsage,
invocation.GetLocation()));
} else if (methodSymbol.Name == "CreateVersion7") {
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.GuidCreateVersion7Usage,
invocation.GetLocation()));
}
}
}
Key Features:
- Runs during compilation - No runtime overhead
- IDE integration - Squiggles appear immediately as you type
- Configurable severity - Can be error, warning, or info
- Suppressible - Use #pragma warning or attributes when needed
Quick Start¶
Defining a WhizbangId¶
Defining a WhizbangId
using Whizbang.Core;
[WhizbangId]
public readonly partial struct OrderId;
[WhizbangId]
public readonly partial struct CustomerId;
The [WhizbangId] attribute triggers source generation that creates:
- Value object with Value property (Guid)
- New() static method for creating new IDs (uses TrackedGuid.New() internally)
- From(Guid) / From(TrackedGuid) static methods for wrapping existing GUIDs — both validate UUIDv7 and throw ArgumentException for non-v7 values
- Parse(string) method for deserialization (validates UUIDv7)
- Equality operators and IComparable<T>
- Implicit conversion to Guid (explicit conversion from Guid)
- JSON converter
- Strongly-typed provider (IWhizbangIdProvider<TId>) via CreateProvider(...)
- Auto-registration via ModuleInitializer
Using WhizbangIds¶
Using WhizbangIds
// Static creation (uses global WhizbangIdProvider)
var orderId = OrderId.New();
// From existing GUID (must be UUIDv7 — throws ArgumentException otherwise)
var existingId = OrderId.From(guid);
// Parse from string (must parse to a UUIDv7)
var parsedId = OrderId.Parse("3c5e4...");
// Implicit conversion to Guid
Guid guid = orderId;
// Get underlying Guid
Guid underlyingGuid = orderId.Value;
Strongly-Typed ID Providers¶
The Problem: Generic Code Needs Type-Safe IDs¶
When writing generic services or utilities, you need type-safe ID generation:
The Problem: Generic Code Needs Type-Safe IDs
// ❌ WRONG: Loses type safety
public class Repository<TEntity> {
private readonly IWhizbangIdProvider _idProvider;
public async Task<TEntity> CreateAsync(TEntity entity) {
entity.Id = _idProvider.NewGuid(); // Returns TrackedGuid - not type-safe!
}
}
// ✅ CORRECT: Type-safe with IWhizbangIdProvider<TId>
public class Repository<TEntity, TId>
where TId : struct {
private readonly IWhizbangIdProvider<TId> _idProvider;
public async Task<TEntity> CreateAsync(TEntity entity) {
entity.Id = _idProvider.NewId(); // Returns TId - type-safe!
}
}
Interface: IWhizbangIdProvider<TId>¶
The generic IWhizbangIdProvider<TId> interface enables type-safe ID generation in generic code:
Interface: IWhizbangIdProvider<TId>
namespace Whizbang.Core;
/// <summary>
/// Strongly-typed provider for generating WhizbangId instances.
/// </summary>
public interface IWhizbangIdProvider<TId> where TId : struct {
/// <summary>
/// Generates a new strongly-typed ID instance.
/// </summary>
TId NewId();
}
Why Generic Providers?
Without generic providers, you lose type safety in generic code:
Interface: IWhizbangIdProvider<TId> - Repository
// ❌ WITHOUT generic provider - loses type safety
public class Repository<TEntity> {
private readonly IWhizbangIdProvider _provider;
public TEntity Create() {
var id = _provider.NewGuid(); // Returns TrackedGuid - not type-safe!
// Need to manually wrap: var orderId = OrderId.From(id);
}
}
// ✅ WITH generic provider - type-safe
public class Repository<TEntity, TId> where TId : struct {
private readonly IWhizbangIdProvider<TId> _provider;
public TEntity Create() {
var id = _provider.NewId(); // Returns TId - type-safe!
// No wrapping needed - already the correct type
}
}
Real-World Example:
Interface: IWhizbangIdProvider<TId> - Order
// Domain entities with different ID types
public class Order {
public OrderId Id { get; set; }
public string CustomerName { get; set; }
}
public class Customer {
public CustomerId Id { get; set; }
public string Name { get; set; }
}
// Generic repository using IWhizbangIdProvider<TId>
public class Repository<TEntity, TId> where TId : struct {
private readonly IWhizbangIdProvider<TId> _idProvider;
private readonly DbContext _db;
public Repository(IWhizbangIdProvider<TId> idProvider, DbContext db) {
_idProvider = idProvider;
_db = db;
}
public async Task<TEntity> CreateAsync(TEntity entity) {
// Type-safe ID generation
var id = _idProvider.NewId(); // Returns TId (OrderId or CustomerId)
// Assuming TEntity has an Id property of type TId
var idProperty = typeof(TEntity).GetProperty("Id");
idProperty?.SetValue(entity, id);
_db.Add(entity);
await _db.SaveChangesAsync();
return entity;
}
}
// Usage with dependency injection
public class OrderService {
private readonly Repository<Order, OrderId> _orderRepo;
private readonly Repository<Customer, CustomerId> _customerRepo;
public OrderService(
Repository<Order, OrderId> orderRepo,
Repository<Customer, CustomerId> customerRepo) {
_orderRepo = orderRepo;
_customerRepo = customerRepo;
}
public async Task ProcessOrderAsync() {
// Each repository uses the correct ID type automatically
var order = await _orderRepo.CreateAsync(new Order {
CustomerName = "Alice"
});
var customer = await _customerRepo.CreateAsync(new Customer {
Name = "Alice"
});
}
}
How It Works¶
- Source Generation: For each
[WhizbangId], the generator creates: OrderIdProviderclass implementingIWhizbangIdProvider<OrderId>-
Auto-registration in
WhizbangIdProviderRegistration.g.cs -
ModuleInitializer: Runs when assembly loads, registers all providers
-
DI Integration:
AddWhizbangIdProviders()registers all typed providers
10+ Provider Registration Patterns¶
1. Auto-Register All Providers (Recommended)¶
When: Standard application setup
Auto-Register All Providers (Recommended)
var builder = WebApplication.CreateBuilder(args);
// Registers IWhizbangIdProvider (Uuid7IdProvider by default)
// AND all IWhizbangIdProvider<TId> for discovered WhizbangIds
builder.Services.AddWhizbangIdProviders();
var app = builder.Build();
What gets registered:
- IWhizbangIdProvider → Uuid7IdProvider (singleton)
- IWhizbangIdProvider<OrderId> → OrderIdProvider (singleton)
- IWhizbangIdProvider<CustomerId> → CustomerIdProvider (singleton)
- ... (all WhizbangIds in all loaded assemblies)
2. Custom Base Provider¶
When: Using database sequences, tenant-specific IDs, or custom ID generation
Custom Base Provider
// Custom ID generator — IWhizbangIdProvider.NewGuid() returns TrackedGuid, not Guid.
// NOTE: generated WhizbangId types validate UUIDv7, so a custom base provider
// must produce time-ordered (v7-form) GUIDs.
public class SequenceBasedIdProvider : IWhizbangIdProvider {
private readonly IDbConnection _db;
public TrackedGuid NewGuid() {
var sequence = _db.GetNextSequence("id_sequence");
return TrackedGuid.FromExternal(GuidV7FromSequence(sequence));
}
}
// Register custom provider AFTER AddWhizbangIdProviders — typed providers resolve
// IWhizbangIdProvider from DI lazily, and the LAST registration wins.
builder.Services.AddWhizbangIdProviders();
builder.Services.AddSingleton<IWhizbangIdProvider, SequenceBasedIdProvider>();
// Now ALL typed providers use SequenceBasedIdProvider
// (For a dependency-free provider, pass an instance instead:
// builder.Services.AddWhizbangIdProviders(new CustomIdProvider());)
3. Override Specific ID Types¶
When: Some IDs need special generation (e.g., CustomerIds from external system)
Override Specific ID Types
builder.Services.AddWhizbangIdProviders();
// Override CustomerIdProvider
builder.Services.AddSingleton<IWhizbangIdProvider<CustomerId>>(sp => {
var externalSystem = sp.GetRequiredService<IExternalCustomerService>();
return new ExternalCustomerIdProvider(externalSystem);
});
4. Test Project Overrides¶
When: Tests need deterministic or custom IDs
Test Project Overrides
// Test setup
var services = new ServiceCollection();
// Use sequential IDs for tests — pass the instance as the base provider
services.AddWhizbangIdProviders(new SequentialTestIdProvider());
// All typed providers now use SequentialTestIdProvider
var provider = services.BuildServiceProvider();
var orderIdProvider = provider.GetRequiredService<IWhizbangIdProvider<TestOrderId>>();
var id1 = orderIdProvider.NewId(); // TestOrderId(00000000-0000-7000-8000-000000000001)
var id2 = orderIdProvider.NewId(); // TestOrderId(00000000-0000-7000-8000-000000000002)
// Note the v7 version/variant nibbles: generated WhizbangId types reject non-v7 GUIDs.
5. No DI - Direct Provider Creation¶
When: Console apps, scripts, or areas without DI
No DI - Direct Provider Creation
// Create typed provider directly
var baseProvider = new Uuid7IdProvider();
var orderIdProvider = OrderId.CreateProvider(baseProvider);
var orderId = orderIdProvider.NewId();
6. Global Provider Configuration¶
When: Want to use the global static provider AND DI
Updated
The static configuration method is WhizbangIdProvider.SetProvider(...) (there is no Configure method). Note the shipped behavior: the generated static OrderId.New() always calls TrackedGuid.New() directly — it does not consult the global provider. SetProvider affects only code that calls WhizbangIdProvider.NewGuid() itself. Use the DI-based typed providers (IWhizbangIdProvider<TId>) when you need to customize ID generation.
Global Provider Configuration
// Configure the global static provider
WhizbangIdProvider.SetProvider(new Uuid7IdProvider());
// ALSO register with DI (for injection)
builder.Services.AddWhizbangIdProviders();
// Static API — uses the provider set via SetProvider:
TrackedGuid raw = WhizbangIdProvider.NewGuid();
// Generated static factory — always TrackedGuid.New(), ignores SetProvider:
var id1 = OrderId.New();
// DI path — uses the base provider registered with AddWhizbangIdProviders:
var id2 = orderIdProvider.NewId();
7. Hybrid - Static + DI¶
When: Some code uses static New(), some uses DI
Hybrid - Static + DI
// Configure global static provider (used by WhizbangIdProvider.NewGuid() callers)
WhizbangIdProvider.SetProvider(new Uuid7IdProvider());
// Register for DI
builder.Services.AddWhizbangIdProviders();
public class OrderService {
// Option 1: Use static New() — always TrackedGuid.New() (see callout above)
public Order CreateOrder() {
return new Order {
Id = OrderId.New()
};
}
// Option 2: Inject typed provider
public class OrderRepository {
private readonly IWhizbangIdProvider<OrderId> _idProvider;
public OrderRepository(IWhizbangIdProvider<OrderId> idProvider) {
_idProvider = idProvider;
}
public Order CreateOrder() {
return new Order {
Id = _idProvider.NewId() // Uses injected provider
};
}
}
}
8. Multi-Tenant ID Generation¶
When: IDs need tenant prefix or tenant-specific sequences
Multi-Tenant ID Generation
public class TenantAwareIdProvider : IWhizbangIdProvider {
private readonly IHttpContextAccessor _contextAccessor; // singleton-safe accessor
public TrackedGuid NewGuid() {
var tenantId = _contextAccessor.HttpContext?.User.FindFirst("tenant_id")?.Value;
return TrackedGuid.FromExternal(GenerateTenantPrefixedV7Guid(tenantId));
}
}
// Register as singleton AFTER AddWhizbangIdProviders (last registration wins);
// typed providers are singletons, so use IHttpContextAccessor for per-request data.
builder.Services.AddWhizbangIdProviders();
builder.Services.AddSingleton<IWhizbangIdProvider, TenantAwareIdProvider>();
// All typed providers now include tenant context
9. Database Sequence IDs¶
When: Using database-generated sequences for distributed ID generation
Database Sequence IDs
public class PostgresSequenceIdProvider : IWhizbangIdProvider {
private readonly NpgsqlConnection _connection;
public TrackedGuid NewGuid() {
var sequence = _connection.ExecuteScalar<long>(
"SELECT nextval('global_id_sequence')"
);
return TrackedGuid.FromExternal(ConvertSequenceToV7Guid(sequence));
}
}
// Register AFTER AddWhizbangIdProviders so this provider wins for typed providers
builder.Services.AddWhizbangIdProviders();
builder.Services.AddSingleton<IWhizbangIdProvider, PostgresSequenceIdProvider>();
10. Provider Lifetimes (Singleton Only)¶
When: Understanding provider lifetimes in DI
Updated
Shipped behavior: AddWhizbangIdProviders() registers the base IWhizbangIdProvider and every generated IWhizbangIdProvider<TId> as singletons. Scoped base providers are not supported — a scoped IWhizbangIdProvider registration would be captured by the singleton typed providers. If a provider needs per-request data, inject a singleton-safe accessor (e.g. IHttpContextAccessor) into a singleton provider, as in the multi-tenant pattern above.
Provider Lifetimes
builder.Services.AddWhizbangIdProviders();
// IWhizbangIdProvider → singleton (the base provider instance)
// IWhizbangIdProvider<OrderId> → singleton (resolves the base provider from DI lazily)
Advanced Scenarios¶
Custom Provider Implementation¶
Custom Provider Implementation
public class CustomOrderIdProvider : IWhizbangIdProvider<OrderId> {
private readonly ILogger<CustomOrderIdProvider> _logger;
public CustomOrderIdProvider(ILogger<CustomOrderIdProvider> logger) {
_logger = logger;
}
public OrderId NewId() {
var id = OrderId.From(TrackedGuid.New());
_logger.LogDebug("Generated OrderId: {OrderId}", id);
return id;
}
}
// Register custom implementation
builder.Services.AddSingleton<IWhizbangIdProvider<OrderId>, CustomOrderIdProvider>();
Composite Provider (Multiple Strategies)¶
Composite Provider (Multiple Strategies)
public class CompositeIdProvider : IWhizbangIdProvider {
private readonly IWhizbangIdProvider _primary;
private readonly IWhizbangIdProvider _fallback;
public TrackedGuid NewGuid() {
try {
return _primary.NewGuid();
}
catch {
return _fallback.NewGuid();
}
}
}
Logging Wrapper¶
Logging Wrapper
public class LoggingIdProviderWrapper<TId> : IWhizbangIdProvider<TId>
where TId : struct {
private readonly IWhizbangIdProvider<TId> _inner;
private readonly ILogger _logger;
public TId NewId() {
var id = _inner.NewId();
_logger.LogDebug("Generated {IdType}: {Id}", typeof(TId).Name, id);
return id;
}
}
API Reference¶
IWhizbangIdProvider<TId>¶
Namespace: Whizbang.Core
Purpose: Strongly-typed provider for generating WhizbangId instances.
Methods:
- TId NewId() - Generates a new ID instance
Usage: IWhizbangIdProvider<TId>
public class OrderService {
private readonly IWhizbangIdProvider<OrderId> _idProvider;
public OrderService(IWhizbangIdProvider<OrderId> idProvider) {
_idProvider = idProvider;
}
public Order CreateOrder() {
return new Order {
Id = _idProvider.NewId()
};
}
}
WhizbangIdProviderRegistry¶
Namespace: Whizbang.Core
Purpose: Global registry for typed ID provider factories (used by generated code).
Methods:
- RegisterFactory<TId>(Func<IWhizbangIdProvider, IWhizbangIdProvider<TId>>) - Register factory (called by ModuleInitializer)
- CreateProvider<TId>(IWhizbangIdProvider) - Create typed provider from registry
- RegisterDICallback(Action<IServiceCollection, IWhizbangIdProvider>) - Register DI callback
- RegisterAllWithDI(IServiceCollection, IWhizbangIdProvider) - Call all DI callbacks
- GetRegisteredIdTypes() - Get all registered ID types
Note: Typically not used directly - ModuleInitializer handles registration automatically.
AddWhizbangIdProviders Extension¶
Namespace: Microsoft.Extensions.DependencyInjection
Purpose: Registers all WhizbangId providers with DI.
Signature: AddWhizbangIdProviders Extension
public static IServiceCollection AddWhizbangIdProviders(
this IServiceCollection services,
IWhizbangIdProvider? baseProvider = null
)
Parameters:
- baseProvider - Custom base provider (default: new Uuid7IdProvider())
Returns: IServiceCollection for chaining
Example: AddWhizbangIdProviders Extension (2)
builder.Services.AddWhizbangIdProviders(); // Uses Uuid7IdProvider
// OR
builder.Services.AddWhizbangIdProviders(new CustomIdProvider());
Testing Patterns¶
Test with Sequential IDs¶
Test with Sequential IDs
public class SequentialTestIdProvider : IWhizbangIdProvider {
private long _counter = 0;
public TrackedGuid NewGuid() {
var value = Interlocked.Increment(ref _counter);
// v7 version/variant nibbles — generated WhizbangId types reject non-v7 GUIDs
return TrackedGuid.FromExternal(new Guid($"00000000-0000-7000-8000-{value:D12}"));
}
}
// In tests — pass the instance as the base provider
var services = new ServiceCollection();
services.AddWhizbangIdProviders(new SequentialTestIdProvider());
Test with Known IDs¶
Test with Known IDs
public class KnownIdProvider<TId> : IWhizbangIdProvider<TId>
where TId : struct {
private readonly Queue<TId> _ids;
public KnownIdProvider(params TId[] ids) {
_ids = new Queue<TId>(ids);
}
public TId NewId() => _ids.Dequeue();
}
// In tests — the known Guid must be v7-form (From validates UUIDv7)
var knownOrderId = OrderId.From(new Guid("00000000-0000-7000-8000-111111111111"));
var provider = new KnownIdProvider<OrderId>(knownOrderId);
var order = new Order { Id = provider.NewId() };
Assert.Equal(knownOrderId, order.Id);
Test Direct Provider Creation¶
Test Direct Provider Creation
[Test]
public void OrderId_CreateProvider_GeneratesValidIds() {
// Arrange
var baseProvider = new Uuid7IdProvider();
var orderIdProvider = OrderId.CreateProvider(baseProvider);
// Act
var id1 = orderIdProvider.NewId();
var id2 = orderIdProvider.NewId();
// Assert
Assert.NotEqual(id1, id2);
Assert.NotEqual(Guid.Empty, id1.Value);
}
Migration Guide¶
Automated Migration with whizbang-migrate¶
Whizbang provides automated code transformation for migrating from raw Guid usage:
Automated Migration with whizbang-migrate
# Analyze a project for migration scope (Marten/Wolverine → Whizbang)
whizbang-migrate analyze --project ./src/MyApp.csproj
# Preview transformations without modifying files
whizbang-migrate apply --project ./src/MyApp.csproj --dry-run
# Apply the transformations (Guid → TrackedGuid included)
whizbang-migrate apply --project ./src/MyApp.csproj
The CLI exposes analyze, plan, apply, rollback, and status commands (transformers are not selected individually via flags — they run as part of the apply pipeline). The GuidToTrackedGuidTransformer automatically:
- Converts Guid.NewGuid() → TrackedGuid.New()
- Converts Guid.CreateVersion7() → TrackedGuid.New()
- Converts Marten's CombGuidIdGeneration.NewGuid() → TrackedGuid.New()
- Adds using Whizbang.Core.ValueObjects; directive
- Emits warnings for default-StreamId check patterns and collision-retry patterns that need manual review
A companion GuidToIdProviderTransformer rewrites raw Guid generation to the IWhizbangIdProvider pattern for DI scenarios.
Migrating from Guid to WhizbangId¶
Before: Migrating from Guid to WhizbangId
public class Order {
public Guid OrderId { get; init; }
}
public class OrderService {
public Order CreateOrder() {
return new Order {
OrderId = Guid.NewGuid() // ❌ Not time-ordered, not type-safe
};
}
}
After: Migrating from Guid to WhizbangId - OrderId
[WhizbangId]
public readonly partial struct OrderId;
public class Order {
public OrderId OrderId { get; init; } // ✅ Type-safe
}
public class OrderService {
private readonly IWhizbangIdProvider<OrderId> _idProvider;
public OrderService(IWhizbangIdProvider<OrderId> idProvider) {
_idProvider = idProvider;
}
public Order CreateOrder() {
return new Order {
OrderId = _idProvider.NewId() // ✅ Time-ordered UUIDv7, type-safe
};
}
}
Migrating from IWhizbangIdProvider to IWhizbangIdProvider<TId>¶
Before: Migrating from IWhizbangIdProvider to
public class Repository<TEntity> {
private readonly IWhizbangIdProvider _idProvider;
public Repository(IWhizbangIdProvider idProvider) {
_idProvider = idProvider;
}
public TEntity Create(TEntity entity) {
entity.Id = _idProvider.NewGuid(); // ❌ Returns TrackedGuid, needs manual wrapping
return entity;
}
}
After: Migrating from IWhizbangIdProvider to
public class Repository<TEntity, TId>
where TId : struct {
private readonly IWhizbangIdProvider<TId> _idProvider;
public Repository(IWhizbangIdProvider<TId> idProvider) {
_idProvider = idProvider;
}
public TEntity Create(TEntity entity) {
entity.Id = _idProvider.NewId(); // ✅ Returns TId
return entity;
}
}
// Usage
var orderRepo = new Repository<Order, OrderId>(orderIdProvider);
Best Practices¶
- Use typed providers in generic code - Enables type safety in repositories, services, utilities
- Prefer DI over static New() - Makes testing easier, allows custom providers
- Configure providers early - Call
AddWhizbangIdProviders()(andWhizbangIdProvider.SetProvider()if you use the static API) inProgram.csbefore any IDs are created - Use auto-registration - Let ModuleInitializer handle registration automatically
- Override specific types when needed - Register custom implementations after
AddWhizbangIdProviders() - Test with sequential IDs - Makes tests predictable and debuggable
- Document custom providers - Explain why/when custom generation is needed
See Also¶
- Message Context - How IDs flow through message processing
- Observability - Correlation and causation tracking
- Testing Strategy - Testing with WhizbangIds