Skip to content

Lifecycle Synchronization

Verified by tests

LifecycleStageAwaiterTests, PerspectiveCompletionWaiterTests, ReceptorRegistryRuntimeRegistrationTests — library CI run #31657041675 (2026-08-13)

Integration tests must wait for asynchronous perspective processing to complete before asserting on perspective data. Polling-based approaches have race conditions. Lifecycle synchronization provides deterministic completion detection using the PostPerspectiveInline lifecycle stage.

The Race Condition Problem

Why Polling Fails

Traditional polling approach checks if database queues are empty:

Why Polling Fails

// ❌ RACE CONDITION!
public async Task WaitForEventProcessingAsync() {
  for (var i = 0; i < 100; i++) {
    var hasPendingWork = await _dbContext.WorkItems.AnyAsync();
    if (!hasPendingWork) {
      return;  // Queue empty = done?
    }
    await Task.Delay(100);
  }
}

The Problem:

sequenceDiagram
    participant Test
    participant Queue as Work Queue
    participant Bus as Service Bus
    participant Perspective

    Test->>Queue: Dispatch command
    Queue-->>Test: Command saved
    Queue->>Bus: Publish to transport
    Note right of Queue: Queue is now EMPTY

    Test->>Queue: Check queue (polling)
    Queue-->>Test: Empty! ✓
    Note right of Test: Test proceeds...

    Bus->>Perspective: Message in-flight...
    Note right of Test: ❌ Assertion FAILS!
    Perspective->>Perspective: Apply event
    Note right of Perspective: Too late!

The queue is empty while message is in-flight through Service Bus!

Test Failures

Test Failures

[Test]
public async Task CreateProduct_UpdatesProductCatalog_FailsRandomlyAsync() {
  // Arrange
  var command = new CreateProductCommand("Widget", 9.99m);

  // Act
  await _dispatcher.SendAsync(command);
  await WaitForEventProcessingAsync();  // ❌ Returns too early!

  // Assert
  var product = await _productLens.GetByIdAsync(command.ProductId);
  await Assert.That(product).IsNotNull();  // ❌ FAILS randomly!
}

Failure Rate: 20-40% depending on system load and Service Bus latency.


The Solution: PostPerspectiveInline

Lifecycle Stage Guarantee

The PostPerspectiveInline lifecycle stage fires: - ✅ After perspective RunAsync() completes - ✅ After database writes are committed - ✅ Before checkpoint is reported to coordinator - ✅ Blocks checkpoint reporting until receptors complete

Timing Diagram:

sequenceDiagram
    participant Test
    participant Dispatcher
    participant Bus as Service Bus
    participant Worker as Perspective Worker
    participant Perspective
    participant Receptor as Lifecycle Receptor
    participant Test2 as Test (Resumed)

    Test->>Dispatcher: SendAsync(command)
    Dispatcher->>Bus: Publish event
    Test->>Test: await completionSource.Task ⏸️

    Bus->>Worker: Receive message
    Worker->>Perspective: RunAsync()
    Perspective->>Perspective: Apply events
    Perspective->>Perspective: SaveChangesAsync() ✓

    Note right of Worker: PostPerspectiveInline fires here
    Worker->>Receptor: HandleAsync(event)
    Receptor->>Test2: completionSource.SetResult(true)

    Test2->>Test2: Task completes!
    Test2->>Test2: Assert perspective data ✓

Key Guarantee: When PostPerspectiveInline fires, database writes are committed - safe to query!

PostPerspectiveInline fires per perspective. When an event feeds multiple perspectives and you need all of them committed, use the PostAllPerspectivesInline / PostAllPerspectivesDetached stages instead - the lifecycle coordinator fires them exactly once per event after the last perspective signals completion (WhenAll pattern). The WhenAll gate controls when these stages fire, never whether they fire.


First-Class Awaiters: Whizbang.Testing

Before hand-rolling a receptor, check the Whizbang.Testing package - it ships ready-made awaiters that encapsulate the pattern below (including the deadlock-safe TaskCompletionSource setup and automatic registration/cleanup):

LifecycleAwaiter Helper

using Whizbang.Testing.Lifecycle;

[Test]
public async Task CreateProduct_UpdatesProductCatalog_WithAwaiterAsync() {
  // Register BEFORE dispatching so a fast completion can't be missed
  using var awaiter = LifecycleAwaiter.ForPerspectiveCompletion<ProductCreatedEvent>(
    _host,
    perspectiveName: "ProductCatalogPerspective");

  // Act
  await _dispatcher.SendAsync(new CreateProductCommand("Widget", 9.99m));

  // Deterministic completion signal - no polling, no Task.Delay
  var evt = await awaiter.WaitAsync(timeoutMilliseconds: 15000);

  // Assert - perspective data is guaranteed committed
  var product = await _productLens.GetByIdAsync(evt.ProductId);
  await Assert.That(product).IsNotNull();
}

LifecycleAwaiter.For<TMessage>(host, stage, ...) targets any lifecycle stage; convenience factories exist for the common ones (ForPerspectiveCompletion, ForPrePerspective, ForPostOutbox, ForPostInbox, ForPreDistribute, ForPostDistribute, ForImmediateDetached). For multi-host scenarios where one event fans out to several perspectives on several hosts, use PerspectiveCompletionWaiter<TEvent> from the same package.

The rest of this page shows the underlying pattern so you can build custom synchronization when the built-in awaiters don't fit.


Basic Test Pattern

Step 1: Create Completion Receptor

Step 1: Create Completion Receptor

using Whizbang.Core;
using Whizbang.Core.Messaging;

[FireAt(LifecycleStage.PostPerspectiveInline)]
public sealed class PerspectiveCompletionReceptor<TEvent> : IReceptor<TEvent>
  where TEvent : IEvent {

  private readonly TaskCompletionSource<bool> _completionSource;
  private readonly string? _perspectiveName;
  private readonly ILifecycleContext? _context;

  public PerspectiveCompletionReceptor(
      TaskCompletionSource<bool> completionSource,
      string? perspectiveName = null,
      ILifecycleContext? context = null) {

    _completionSource = completionSource;
    _perspectiveName = perspectiveName;
    _context = context;
  }

  public ValueTask HandleAsync(TEvent message, CancellationToken ct) {
    // Filter by perspective if specified (matches the perspective CLASS name)
    if (_context is not null && _perspectiveName is not null) {
      if (_context.PerspectiveType?.Name != _perspectiveName) {
        return ValueTask.CompletedTask;  // Not our perspective
      }
    }

    // Signal test to proceed
    _completionSource.TrySetResult(true);
    return ValueTask.CompletedTask;
  }
}

Step 2: Register at Runtime

Step 2: Register at Runtime

[Test]
public async Task CreateProduct_UpdatesProductCatalog_DeterministicallyAsync() {
  // Arrange
  // CRITICAL: RunContinuationsAsynchronously prevents deadlocks
  var completionSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
  var receptor = new PerspectiveCompletionReceptor<ProductCreatedEvent>(
    completionSource,
    perspectiveName: "ProductCatalogPerspective"  // Optional: filter by perspective class name
  );

  var registry = _host.Services.GetRequiredService<IReceptorRegistry>();
  registry.Register<ProductCreatedEvent>(receptor, LifecycleStage.PostPerspectiveInline);

  try {
    var command = new CreateProductCommand("Widget", 9.99m);

    // Act
    await _dispatcher.SendAsync(command);

    // Wait for perspective processing to complete (deterministic!)
    await completionSource.Task.WaitAsync(TimeSpan.FromSeconds(15));

    // Assert - perspective data is guaranteed to be saved
    var product = await _productLens.GetByIdAsync(command.ProductId);
    await Assert.That(product).IsNotNull();
    await Assert.That(product!.Name).IsEqualTo("Widget");
    await Assert.That(product.Price).IsEqualTo(9.99m);

  } finally {
    // Always unregister
    registry.Unregister<ProductCreatedEvent>(receptor, LifecycleStage.PostPerspectiveInline);
  }
}

Success Rate: 100% - no more race conditions!


Helper Method Pattern

Extension Method

Create a reusable helper method in your test fixtures:

Extension Method

// File: samples/ECommerce/tests/ECommerce.Integration.TestUtilities/Fixtures/LifecycleReceptorTestExtensions.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Whizbang.Core;
using Whizbang.Core.Messaging;

public static class LifecycleReceptorTestExtensions {

  /// <summary>
  /// Waits for perspective processing to complete for a specific event type.
  /// Uses PostPerspectiveInline lifecycle stage for deterministic synchronization.
  /// </summary>
  public static async Task WaitForPerspectiveCompletionAsync<TEvent>(
      this IHost host,
      string? perspectiveName = null,
      int timeoutMilliseconds = 15000)
      where TEvent : IEvent {

    ArgumentNullException.ThrowIfNull(host);

    // CRITICAL: RunContinuationsAsynchronously prevents deadlocks
    var completionSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
    var receptor = new PerspectiveCompletionReceptor<TEvent>(completionSource, perspectiveName);
    var registry = host.Services.GetRequiredService<IReceptorRegistry>();

    registry.Register<TEvent>(receptor, LifecycleStage.PostPerspectiveInline);

    try {
      await completionSource.Task.WaitAsync(TimeSpan.FromMilliseconds(timeoutMilliseconds));
    } finally {
      registry.Unregister<TEvent>(receptor, LifecycleStage.PostPerspectiveInline);
    }
  }
}

Simplified Test Usage

Simplified Test Usage

[Test]
public async Task CreateProduct_UpdatesProductCatalog_SimpleAsync() {
  // Arrange
  var command = new CreateProductCommand("Widget", 9.99m);

  // Act
  await _dispatcher.SendAsync(command);

  // Wait for perspective completion (one line!)
  await _host.WaitForPerspectiveCompletionAsync<ProductCreatedEvent>(
    perspectiveName: "ProductCatalogPerspective",
    timeoutMilliseconds: 15000
  );

  // Assert
  var product = await _productLens.GetByIdAsync(command.ProductId);
  await Assert.That(product).IsNotNull();
  await Assert.That(product!.Name).IsEqualTo("Widget");
}

Benefits: - ✅ One-line synchronization - ✅ Automatic registration/cleanup - ✅ Consistent timeout handling - ✅ Reusable across all tests


Advanced Patterns

Pattern 1: Multiple Event Types

Wait for multiple different events (e.g., command triggers multiple perspectives):

Pattern 1: Multiple Event Types

public static async Task WaitForMultiplePerspectiveCompletionsAsync(
    this IHost host,
    Type[] eventTypes,
    string? perspectiveName = null,
    int timeoutMilliseconds = 15000) {

  ArgumentNullException.ThrowIfNull(host);
  ArgumentNullException.ThrowIfNull(eventTypes);

  if (eventTypes.Length == 0) {
    return;
  }

  // CRITICAL: RunContinuationsAsynchronously prevents deadlocks
  var completionSources = eventTypes
    .Select(_ => new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously))
    .ToArray();
  var receptors = new List<object>();
  var registry = host.Services.GetRequiredService<IReceptorRegistry>();

  try {
    // Register receptors for each event type
    for (var i = 0; i < eventTypes.Length; i++) {
      var eventType = eventTypes[i];
      var completionSource = completionSources[i];

      // Create receptor using reflection (dynamic event type)
      var receptorType = typeof(PerspectiveCompletionReceptor<>).MakeGenericType(eventType);
      var receptor = Activator.CreateInstance(receptorType, completionSource, perspectiveName, null)
        ?? throw new InvalidOperationException($"Failed to create receptor for {eventType.Name}");

      receptors.Add(receptor);

      // Register using reflection
      var registerMethod = typeof(IReceptorRegistry)
        .GetMethod(nameof(IReceptorRegistry.Register))!
        .MakeGenericMethod(eventType);
      registerMethod.Invoke(registry, new[] { receptor, LifecycleStage.PostPerspectiveInline });
    }

    // Wait for ALL completions
    await Task.WhenAll(completionSources.Select(cs => cs.Task))
      .WaitAsync(TimeSpan.FromMilliseconds(timeoutMilliseconds));

  } finally {
    // Unregister all receptors
    for (var i = 0; i < eventTypes.Length; i++) {
      var eventType = eventTypes[i];
      var receptor = receptors[i];

      var unregisterMethod = typeof(IReceptorRegistry)
        .GetMethod(nameof(IReceptorRegistry.Unregister))!
        .MakeGenericMethod(eventType);
      unregisterMethod.Invoke(registry, new[] { receptor, LifecycleStage.PostPerspectiveInline });
    }
  }
}

Usage: Pattern 1: Multiple Event Types (2)

[Test]
public async Task UpdateInventory_UpdatesMultiplePerspectives_DeterministicallyAsync() {
  // Arrange
  var command = new UpdateInventoryCommand(productId: _productId, quantity: 100);

  // Act
  await _dispatcher.SendAsync(command);

  // Wait for BOTH events to be processed
  await _host.WaitForMultiplePerspectiveCompletionsAsync(
    new[] { typeof(InventoryUpdatedEvent), typeof(ProductModifiedEvent) },
    timeoutMilliseconds: 20000
  );

  // Assert - both perspectives updated
  var inventory = await _inventoryLens.GetByProductIdAsync(_productId);
  var product = await _productLens.GetByIdAsync(_productId);

  await Assert.That(inventory!.Quantity).IsEqualTo(100);
  await Assert.That(product!.LastModified).IsNotNull();
}

Pattern 2: Perspective-Specific Filtering

Wait for a specific perspective to complete (useful when multiple perspectives process same event):

Pattern 2: Perspective-Specific Filtering

[Test]
public async Task CreateProduct_UpdatesOnlyProductCatalog_NotInventoryAsync() {
  // Arrange
  var command = new CreateProductCommand("Widget", 9.99m);

  // Act
  await _dispatcher.SendAsync(command);

  // Wait for ProductCatalog perspective specifically
  await _host.WaitForPerspectiveCompletionAsync<ProductCreatedEvent>(
    perspectiveName: "ProductCatalogPerspective"  // Only this perspective (class name)
  );

  // Assert - ProductCatalog updated
  var product = await _productLens.GetByIdAsync(command.ProductId);
  await Assert.That(product).IsNotNull();

  // But Inventory perspective NOT updated yet (different event)
  var inventory = await _inventoryLens.GetByProductIdAsync(command.ProductId);
  await Assert.That(inventory).IsNull();  // Expected!
}

Pattern 3: Timeout Handling

Handle timeouts gracefully with diagnostic information:

Pattern 3: Timeout Handling

[Test]
public async Task SlowPerspective_TimesOut_WithDiagnosticsAsync() {
  // Arrange
  var command = new CreateProductCommand("Widget", 9.99m);

  // Act & Assert
  await _dispatcher.SendAsync(command);

  try {
    await _host.WaitForPerspectiveCompletionAsync<ProductCreatedEvent>(
      timeoutMilliseconds: 1000  // Short timeout for demonstration
    );

    Assert.Fail("Expected TimeoutException");

  } catch (TimeoutException ex) {
    // Gather diagnostics
    var pendingWork = await _dbContext.WorkItems.CountAsync();
    var lastCheckpoint = await _dbContext.PerspectiveCheckpoints
      .Where(p => p.PerspectiveName == "ProductCatalogPerspective")
      .FirstOrDefaultAsync();

    Assert.Fail(
      $"Perspective processing timed out. " +
      $"Pending work items: {pendingWork}, " +
      $"Last checkpoint: {lastCheckpoint?.LastProcessedEventId}");
  }
}

Pattern 4: Any-Of-Multiple Events

Wait for any one of multiple events (first to complete):

Pattern 4: Any-Of-Multiple Events

[Test]
public async Task Command_TriggersOneOfSeveralEvents_FlexiblyAsync() {
  // Arrange
  var command = new ProcessOrderCommand(orderId: _orderId);

  // Create completion sources for each possible event
  // CRITICAL: RunContinuationsAsynchronously prevents deadlocks
  var orderCompletedSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
  var orderFailedSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

  var registry = _host.Services.GetRequiredService<IReceptorRegistry>();

  var completedReceptor = new PerspectiveCompletionReceptor<OrderCompletedEvent>(orderCompletedSource);
  var failedReceptor = new PerspectiveCompletionReceptor<OrderFailedEvent>(orderFailedSource);

  registry.Register<OrderCompletedEvent>(completedReceptor, LifecycleStage.PostPerspectiveInline);
  registry.Register<OrderFailedEvent>(failedReceptor, LifecycleStage.PostPerspectiveInline);

  try {
    // Act
    await _dispatcher.SendAsync(command);

    // Wait for EITHER event (first to complete)
    var completedTask = await Task.WhenAny(
      orderCompletedSource.Task,
      orderFailedSource.Task
    ).WaitAsync(TimeSpan.FromSeconds(15));

    // Assert based on which event fired
    if (completedTask == orderCompletedSource.Task) {
      var order = await _orderLens.GetByIdAsync(_orderId);
      await Assert.That(order!.Status).IsEqualTo(OrderStatus.Completed);
    } else {
      var order = await _orderLens.GetByIdAsync(_orderId);
      await Assert.That(order!.Status).IsEqualTo(OrderStatus.Failed);
    }

  } finally {
    registry.Unregister<OrderCompletedEvent>(completedReceptor, LifecycleStage.PostPerspectiveInline);
    registry.Unregister<OrderFailedEvent>(failedReceptor, LifecycleStage.PostPerspectiveInline);
  }
}

Integration Test Fixture Pattern

Base Fixture Setup

Base Fixture Setup

public class ServiceBusIntegrationFixture : IAsyncDisposable {
  protected IHost _inventoryHost = null!;
  protected IHost _bffHost = null!;
  protected IDispatcher _dispatcher = null!;

  [Before(Test)]
  public async Task SetupAsync() {
    // Start hosts
    _inventoryHost = await InventoryHostBuilder.BuildAsync();
    _bffHost = await BffHostBuilder.BuildAsync();

    await _inventoryHost.StartAsync();
    await _bffHost.StartAsync();

    _dispatcher = _bffHost.Services.GetRequiredService<IDispatcher>();
  }

  [After(Test)]
  public async Task CleanupAsync() {
    await _inventoryHost.StopAsync();
    await _bffHost.StopAsync();
    await DisposeAsync();
  }

  // Helper method available to all tests
  protected Task WaitForPerspectiveAsync<TEvent>(
      string? perspectiveName = null,
      int timeoutMs = 15000)
      where TEvent : IEvent {

    return _inventoryHost.WaitForPerspectiveCompletionAsync<TEvent>(
      perspectiveName,
      timeoutMs
    );
  }

  public async ValueTask DisposeAsync() {
    if (_inventoryHost is not null) await _inventoryHost.DisposeAsync();
    if (_bffHost is not null) await _bffHost.DisposeAsync();
  }
}

Test Class Usage

Test Class Usage

public class CreateProductWorkflowTests : ServiceBusIntegrationFixture {

  [Test]
  public async Task CreateProduct_FlowsFromBffToInventory_DeterministicallyAsync() {
    // Arrange
    var command = new CreateProductCommand("Widget", 9.99m);

    // Act
    await _dispatcher.SendAsync(command);

    // Wait for perspective (uses helper from base fixture)
    await WaitForPerspectiveAsync<ProductCreatedEvent>(
      perspectiveName: "ProductCatalogPerspective"
    );

    // Assert
    var inventoryDbContext = _inventoryHost.Services
      .GetRequiredService<InventoryDbContext>();

    var product = await inventoryDbContext.Products
      .FirstOrDefaultAsync(p => p.Id == command.ProductId);

    await Assert.That(product).IsNotNull();
    await Assert.That(product!.Name).IsEqualTo("Widget");
  }
}

Troubleshooting

Problem: TimeoutException

Symptoms: Test times out waiting for perspective completion.

Possible Causes: 1. Perspective not running - Check that perspective worker is started 2. Different event type - Verify you're waiting for correct event 3. Perspective name mismatch - Check perspective name filter 4. Message routing issue - Event not reaching perspective

Debugging: Problem: TimeoutException

// Check pending work items
var pendingWork = await _dbContext.WorkItems.ToListAsync();
Console.WriteLine($"Pending work: {pendingWork.Count}");

// Check perspective checkpoints
var checkpoint = await _dbContext.PerspectiveCheckpoints
  .FirstOrDefaultAsync(p => p.PerspectiveName == "ProductCatalogPerspective");
Console.WriteLine($"Last checkpoint: {checkpoint?.LastProcessedEventId}");

// Check if event was published
var events = await _dbContext.Events
  .Where(e => e.EventType == "ProductCreatedEvent")
  .ToListAsync();
Console.WriteLine($"ProductCreatedEvent count: {events.Count}");

Problem: Test Passes But Assertions Fail

Symptoms: WaitForPerspectiveCompletionAsync returns, but data not in database.

Possible Causes: 1. Wrong perspective - Waiting for perspective that doesn't update target data 2. Multiple perspectives - Need to wait for multiple perspectives 3. Detached stage instead of Inline - Use PostPerspectiveInline, not PostPerspectiveDetached

Fix: Problem: Test Passes But Assertions Fail

// ❌ WRONG: PostPerspectiveDetached (fire-and-forget, does not block the pipeline)
[FireAt(LifecycleStage.PostPerspectiveDetached)]
public class CompletionReceptor : IReceptor<ProductCreatedEvent> { }

// ✅ CORRECT: PostPerspectiveInline (blocking, guarantees persistence)
[FireAt(LifecycleStage.PostPerspectiveInline)]
public class CompletionReceptor : IReceptor<ProductCreatedEvent> { }

Problem: Receptor Not Firing

Symptoms: WaitForPerspectiveCompletionAsync times out, receptor never called.

Possible Causes: 1. Not registered - Forgot to call registry.Register() 2. Wrong event type - Generic type mismatch 3. Wrong stage - Using different stage than PostPerspectiveInline 4. Perspective name filter excludes event - Check filter logic

Debugging: Problem: Receptor Not Firing

// Add logging to receptor
public ValueTask HandleAsync(ProductCreatedEvent evt, CancellationToken ct) {
  Console.WriteLine($"RECEPTOR FIRED: {evt.GetType().Name}");
  Console.WriteLine($"Perspective: {_context?.PerspectiveType?.Name}");
  Console.WriteLine($"Stream: {_context?.StreamId}");

  _completionSource.TrySetResult(true);
  return ValueTask.CompletedTask;
}

Problem: Registry Not Found

Symptoms: GetRequiredService<IReceptorRegistry>() throws exception.

Fix: IReceptorRegistry is registered by the source-generated AddWhizbangDispatcher() extension (which calls AddWhizbangReceptorRegistry() internally). Make sure your host wiring includes it: Problem: Registry Not Found

// In Program.cs
services
  .AddWhizbang()
  .WithEFCore<MyDbContext>()
  .WithDriver.Postgres;

// Source-generated registrations (register IReceptorRegistry + receptors)
services.AddWhizbangDispatcher();
services.AddReceptors();

Performance Considerations

Lifecycle Receptor Overhead

Inline receptors add < 1ms per event: - Registration: One-time cost (< 0.1ms) - Invocation: Pattern matching + delegate call (< 0.5ms) - Completion signal: TaskCompletionSource.SetResult() (< 0.1ms)

For tests with 1000+ events: - Traditional polling: 100ms * 100 iterations = ~10 seconds - Lifecycle sync: 1ms * 1000 events = ~1 second

Lifecycle synchronization is 10x faster than polling!

Timeout Recommendations

Timeout Recommendations

// Local development (fast)
timeoutMilliseconds: 5000   // 5 seconds

// CI/CD pipeline (slower)
timeoutMilliseconds: 15000  // 15 seconds

// Slow integration tests (Azure Service Bus)
timeoutMilliseconds: 30000  // 30 seconds

Migration Guide

Before: Polling-Based Tests

Before: Polling-Based Tests

[Test]
public async Task OldTest_UsesPollingSyncAsync() {
  // Arrange
  var command = new CreateProductCommand("Widget", 9.99m);

  // Act
  await _dispatcher.SendAsync(command);

  // ❌ OLD: Polling (race condition!)
  await WaitForEventProcessingAsync();

  // Assert
  var product = await _productLens.GetByIdAsync(command.ProductId);
  await Assert.That(product).IsNotNull();
}

private async Task WaitForEventProcessingAsync() {
  for (var i = 0; i < 100; i++) {
    var hasPendingWork = await _dbContext.WorkItems.AnyAsync();
    if (!hasPendingWork) return;
    await Task.Delay(100);
  }
}

After: Lifecycle Synchronization

After: Lifecycle Synchronization

[Test]
public async Task NewTest_UsesLifecycleSyncAsync() {
  // Arrange
  var command = new CreateProductCommand("Widget", 9.99m);

  // Act
  await _dispatcher.SendAsync(command);

  // ✅ NEW: Deterministic lifecycle synchronization
  await _host.WaitForPerspectiveCompletionAsync<ProductCreatedEvent>(
    perspectiveName: "ProductCatalogPerspective"
  );

  // Assert
  var product = await _productLens.GetByIdAsync(command.ProductId);
  await Assert.That(product).IsNotNull();
}

Migration Steps: 1. Add PerspectiveCompletionReceptor<TEvent> to test project 2. Add WaitForPerspectiveCompletionAsync<TEvent>() extension method 3. Replace WaitForEventProcessingAsync() calls with lifecycle sync 4. Remove polling helper methods 5. Run tests 100 times to verify 100% reliability



Summary

  • Problem: Polling has race conditions (in-flight messages not visible in queue)
  • Solution: PostPerspectiveInline lifecycle stage guarantees persistence
  • First-class: LifecycleAwaiter / PerspectiveCompletionWaiter<TEvent> in the Whizbang.Testing package
  • Pattern: Register a completion receptor at runtime via IReceptorRegistry
  • Helper: WaitForPerspectiveCompletionAsync<TEvent>() extension method
  • Benefits: 100% test reliability, 10x faster than polling
  • Advanced: Multiple events, perspective filtering, any-of patterns
  • Migration: Replace polling with lifecycle sync in existing tests