Skip to content

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:

Processing order 019c7df5-494b-77d6-b994-e7145b796ec0 from source: Database/API, IsV7: true

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

  • WHIZ058 - Info: GUID call intercepted
  • WHIZ059 - Info: Interception suppressed

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

  1. Source Generation: For each [WhizbangId], the generator creates:
  2. OrderIdProvider class implementing IWhizbangIdProvider<OrderId>
  3. Auto-registration in WhizbangIdProviderRegistration.g.cs

  4. ModuleInitializer: Runs when assembly loads, registers all providers

    [ModuleInitializer]
    public static void Initialize() {
        WhizbangIdProviderRegistry.RegisterFactory<OrderId>(
            baseProvider => new OrderIdProvider(baseProvider)
        );
    }
    

  5. DI Integration: AddWhizbangIdProviders() registers all typed providers

    services.AddSingleton<IWhizbangIdProvider<OrderId>>(
        sp => new OrderIdProvider(sp.GetRequiredService<IWhizbangIdProvider>())
    );
    

10+ Provider Registration Patterns

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

  1. Use typed providers in generic code - Enables type safety in repositories, services, utilities
  2. Prefer DI over static New() - Makes testing easier, allows custom providers
  3. Configure providers early - Call AddWhizbangIdProviders() (and WhizbangIdProvider.SetProvider() if you use the static API) in Program.cs before any IDs are created
  4. Use auto-registration - Let ModuleInitializer handle registration automatically
  5. Override specific types when needed - Register custom implementations after AddWhizbangIdProviders()
  6. Test with sequential IDs - Makes tests predictable and debuggable
  7. Document custom providers - Explain why/when custom generation is needed

See Also