Skip to content

Quick Start Tutorial

Verified by tests

CreateProductCommandTests, ReceptorTests — library CI run #31657041675 (2026-08-13)

Build your first Whizbang application in 10 minutes. This tutorial walks you through creating a simple order management system using Whizbang's core patterns. It mirrors the structure of the ECommerce sample that ships with the library.

What You'll Build

A minimal ASP.NET Core API that: - Accepts CreateOrder commands via HTTP endpoint - Processes orders using a Receptor (message handler) - Returns OrderCreated events with validation - Uses Dispatcher for type-safe message routing - Persists framework state (event store, outbox, inbox) via the EF Core Postgres driver

Prerequisites: Complete the Installation Guide first. Docker is required for PostgreSQL and RabbitMQ.

Step 1: Create Project Structure

Step 1: Create Project Structure

# Create solution and project
dotnet new sln -n QuickStartApp
dotnet new webapi -n QuickStartApp.API
dotnet sln add QuickStartApp.API

cd QuickStartApp.API

# Add Whizbang packages
dotnet add package Whizbang.Core
dotnet add package Whizbang.Generators
dotnet add package Whizbang.Data.EFCore.Postgres
dotnet add package Whizbang.Transports.RabbitMQ

Step 2: Start Infrastructure (Docker)

Whizbang's Postgres driver stores the event store, outbox, and inbox in PostgreSQL; RabbitMQ is the local-development transport:

Step 2: Start Infrastructure

docker run -d --name quickstart-postgres \
  -e POSTGRES_PASSWORD=dev_password \
  -e POSTGRES_DB=quickstart \
  -p 5432:5432 \
  postgres:16

docker run -d --name quickstart-rabbitmq \
  -p 5672:5672 -p 15672:15672 \
  rabbitmq:3-management

Add connection strings to appsettings.Development.json:

Step 2: Connection Strings

{
  "ConnectionStrings": {
    "postgres": "Host=localhost;Database=quickstart;Username=postgres;Password=dev_password",
    "rabbitmq": "amqp://guest:guest@localhost:5672"
  }
}

Step 3: Define Your Messages

Create a Messages folder and define your command and event. Commands implement ICommand, events implement IEvent, and the [StreamId] attribute marks the property that identifies the event stream:

Messages/CreateOrder.cs: Step 3: Define Your Messages

using Whizbang.Core;

namespace QuickStartApp.API.Messages;

public record CreateOrder(
    [property: StreamId] Guid OrderId,
    Guid CustomerId,
    string ProductName,
    int Quantity,
    decimal UnitPrice
) : ICommand;

Messages/OrderCreated.cs: Step 3: Define Your Messages - OrderCreated

using Whizbang.Core;

namespace QuickStartApp.API.Messages;

public record OrderCreated(
    [property: StreamId] Guid OrderId,
    Guid CustomerId,
    string ProductName,
    int Quantity,
    decimal UnitPrice,
    decimal Total,
    DateTimeOffset CreatedAt
) : IEvent;

Key Points: - Use records for immutability and value semantics - Commands are requests (CreateOrder) and implement ICommand - Events are facts (OrderCreated - past tense) and implement IEvent - [StreamId] identifies which event stream the message belongs to - Include all necessary data for downstream consumers

Step 4: Create Your First Receptor

Receptors are stateless message handlers that implement business logic.

Receptors/CreateOrderReceptor.cs: Step 4: Create Your First Receptor

using Whizbang.Core;
using QuickStartApp.API.Messages;

namespace QuickStartApp.API.Receptors;

public class CreateOrderReceptor(ILogger<CreateOrderReceptor> logger)
    : IReceptor<CreateOrder, OrderCreated> {

    public async ValueTask<OrderCreated> HandleAsync(
        CreateOrder message,
        CancellationToken cancellationToken = default) {

        // Validation
        if (message.Quantity <= 0) {
            throw new InvalidOperationException("Quantity must be greater than zero");
        }

        if (message.UnitPrice <= 0) {
            throw new InvalidOperationException("Unit price must be greater than zero");
        }

        if (string.IsNullOrWhiteSpace(message.ProductName)) {
            throw new InvalidOperationException("Product name is required");
        }

        // Business logic
        var total = message.Quantity * message.UnitPrice;

        logger.LogInformation(
            "Creating order {OrderId} for customer {CustomerId}: {Quantity}x {ProductName} = {Total:C}",
            message.OrderId, message.CustomerId, message.Quantity, message.ProductName, total
        );

        // Return event (fact of what happened) - it cascades to the
        // event store / outbox automatically
        return new OrderCreated(
            OrderId: message.OrderId,
            CustomerId: message.CustomerId,
            ProductName: message.ProductName,
            Quantity: message.Quantity,
            UnitPrice: message.UnitPrice,
            Total: total,
            CreatedAt: DateTimeOffset.UtcNow
        );
    }
}

Key Patterns: - Implement IReceptor<TMessage, TResponse> - Use constructor injection for dependencies (primary constructors work well) - Validate inputs and throw exceptions for invalid requests - Return domain events (OrderCreated) describing what happened - Use ValueTask<T> for performance (may be synchronous or async)

Step 5: Add the DbContext and Register Whizbang

Create a partial DbContext marked with [WhizbangDbContext]. Source generators add the Inbox/Outbox/EventStore DbSets and the schema-initialization extension:

AppDbContext.cs: Step 5: DbContext

using Microsoft.EntityFrameworkCore;
using Whizbang.Data.EFCore.Custom;

namespace QuickStartApp.API;

[WhizbangDbContext]
public partial class AppDbContext(DbContextOptions<AppDbContext> options)
    : DbContext(options) {
    // DbSet properties and OnModelCreating are auto-generated in a partial class
}

Configure dependency injection in Program.cs:

Step 5: Register Whizbang Services

using Microsoft.EntityFrameworkCore;
using QuickStartApp.API;
using QuickStartApp.API.Generated;
using Whizbang.Core;
using Whizbang.Core.Generated;
using Whizbang.Core.Messaging;
using Whizbang.Core.Observability;
using Whizbang.Data.EFCore.Postgres;
using Whizbang.Transports.RabbitMQ;

var builder = WebApplication.CreateBuilder(args);

var postgresConnection = builder.Configuration.GetConnectionString("postgres")!;
var rabbitMqConnection = builder.Configuration.GetConnectionString("rabbitmq")!;

// Transport (RabbitMQ for local development)
builder.Services.AddRabbitMQTransport(rabbitMqConnection);
builder.Services.AddRabbitMQHealthChecks();

// Observability + worker prerequisites (mirrors samples/ECommerce)
builder.Services.AddSingleton<ITraceStore, InMemoryTraceStore>();
builder.Services.AddSingleton<IServiceInstanceProvider, ServiceInstanceProvider>();
builder.Services.AddSingleton<OrderedStreamProcessor>();

// EF Core DbContext for Inbox/Outbox/EventStore
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(postgresConnection));

// Unified Whizbang API with the EF Core Postgres driver
builder.Services
    .AddWhizbang()
    .WithEFCore<AppDbContext>()
    .WithDriver.Postgres;

// Generated registrations (produced by Whizbang.Generators)
builder.Services.AddReceptors();
builder.Services.AddWhizbangDispatcher();

// Controllers
builder.Services.AddControllers();

var app = builder.Build();

// Initialize Whizbang database schema on startup (generated, idempotent)
using (var scope = app.Services.CreateScope()) {
    var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
    await dbContext.EnsureWhizbangDatabaseInitializedAsync(logger);
}

app.MapControllers();

app.Run();

Important: - AddWhizbang().WithEFCore<AppDbContext>().WithDriver.Postgres registers the dispatcher infrastructure, IInbox, IOutbox, IEventStore, background workers, and per-model lenses - AddReceptors() and AddWhizbangDispatcher() are generated extension methods - they appear after your first build (in the QuickStartApp.API.Generated / Whizbang.Core.Generated namespaces) - EnsureWhizbangDatabaseInitializedAsync() creates all wh_* tables and PostgreSQL functions; it is idempotent and safe on every startup

Step 6: Create API Endpoint

Create a controller to dispatch your command:

Controllers/OrdersController.cs: Step 6: Create API Endpoint

using Microsoft.AspNetCore.Mvc;
using Whizbang.Core;
using Whizbang.Core.ValueObjects;
using QuickStartApp.API.Messages;

namespace QuickStartApp.API.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase {
    private readonly IDispatcher _dispatcher;
    private readonly ILogger<OrdersController> _logger;

    public OrdersController(IDispatcher dispatcher, ILogger<OrdersController> logger) {
        _dispatcher = dispatcher;
        _logger = logger;
    }

    [HttpPost]
    public async Task<ActionResult<OrderCreated>> CreateOrder(
        [FromBody] CreateOrderRequest request) {

        try {
            var command = new CreateOrder(
                OrderId: TrackedGuid.NewMedo(),  // time-ordered UUIDv7
                CustomerId: request.CustomerId,
                ProductName: request.ProductName,
                Quantity: request.Quantity,
                UnitPrice: request.UnitPrice
            );

            // Dispatch command and get typed result (< 20ns dispatch overhead)
            var result = await _dispatcher.LocalInvokeAsync<CreateOrder, OrderCreated>(command);

            _logger.LogInformation("Order {OrderId} created successfully", result.OrderId);

            return CreatedAtAction(
                nameof(GetOrder),
                new { orderId = result.OrderId },
                result
            );
        } catch (InvalidOperationException ex) {
            _logger.LogWarning(ex, "Invalid order request");
            return BadRequest(new { error = ex.Message });
        }
    }

    [HttpGet("{orderId:guid}")]
    public ActionResult<OrderCreated> GetOrder(Guid orderId) {
        // Placeholder - in a real app, query the read model via ILensQuery<T>
        return NotFound(new { error = "Order retrieval not implemented in quick start" });
    }
}

// Request DTO for API
public record CreateOrderRequest(
    Guid CustomerId,
    string ProductName,
    int Quantity,
    decimal UnitPrice
);

Key Patterns: - Inject IDispatcher into your controller/endpoint - Use LocalInvokeAsync<TMessage, TResponse> for in-process dispatch with typed result - Generate stream IDs with TrackedGuid.NewMedo() (time-ordered UUIDv7) - Handle exceptions from receptors (validation errors, business rule violations) - Return appropriate HTTP status codes (201 Created, 400 Bad Request)

Step 7: Run and Test

Start the Application

Start the Application

dotnet run

Watch the logs - on first startup Whizbang initializes its schema (you'll see the wh_* tables created in the quickstart database).

Test with curl

Valid request (use the HTTP port from your launch profile): Test with curl

curl -X POST http://localhost:5000/api/orders \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "550e8400-e29b-41d4-a716-446655440000",
    "productName": "Laptop",
    "quantity": 2,
    "unitPrice": 999.99
  }'

Expected response (201 Created): Test with curl (2)

{
  "orderId": "018d8f8e-1234-7890-abcd-ef1234567890",
  "customerId": "550e8400-e29b-41d4-a716-446655440000",
  "productName": "Laptop",
  "quantity": 2,
  "unitPrice": 999.99,
  "total": 1999.98,
  "createdAt": "2026-07-16T10:30:00Z"
}

Invalid request (negative quantity): Test with curl (3)

curl -X POST http://localhost:5000/api/orders \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "550e8400-e29b-41d4-a716-446655440000",
    "productName": "Laptop",
    "quantity": -5,
    "unitPrice": 999.99
  }'

Expected response (400 Bad Request): Test with curl (4)

{
  "error": "Quantity must be greater than zero"
}

Verify the Event Store

Verify the Event Store

docker exec quickstart-postgres psql -U postgres -d quickstart \
  -c "SELECT event_type, stream_id, version FROM wh_event_store ORDER BY created_at;"

What You Just Built

Congratulations! You've created a working Whizbang application with:

Type-safe messaging - Compiler enforces CreateOrder → OrderCreated ✅ Zero reflection - All routing happens at compile time ✅ Durable events - OrderCreated lands in wh_event_store automatically ✅ Clean architecture - Commands, events, and handlers are separated ✅ Business logic isolation - Validation and rules in receptor, not controller

Understanding the Flow

HTTP POST /api/orders
OrdersController.CreateOrder()
dispatcher.LocalInvokeAsync<CreateOrder, OrderCreated>(command)
CreateOrderReceptor.HandleAsync(command)
Validation → Business Logic → Return OrderCreated event
Event cascades to event store / outbox (background workers publish it)
Return 201 Created with OrderCreated response

Next Steps

Add Perspectives (Read Models)

Perspectives are pure functions that fold events into read models. The framework persists the model (in a wh_per_* table) and tracks progress per stream - your code never touches the database:

Perspectives/OrderSummaryPerspective.cs: Add Perspectives (Read Models)

using Whizbang.Core;
using Whizbang.Core.Perspectives;
using QuickStartApp.API.Messages;

namespace QuickStartApp.API.Perspectives;

public sealed record OrderSummary {
    [StreamId]
    public Guid OrderId { get; init; }
    public Guid CustomerId { get; init; }
    public string ProductName { get; init; } = "";
    public decimal Total { get; init; }
    public DateTimeOffset CreatedAt { get; init; }
}

public class OrderSummaryPerspective : IPerspectiveFor<OrderSummary, OrderCreated> {
    public OrderSummary Apply(OrderSummary currentData, OrderCreated eventData) =>
        currentData with {
            OrderId = eventData.OrderId,
            CustomerId = eventData.CustomerId,
            ProductName = eventData.ProductName,
            Total = eventData.Total,
            CreatedAt = eventData.CreatedAt
        };
}

Perspectives are discovered by source generators - no manual registration. Query the read model through the automatically registered lens:

Add Perspectives (Read Models) (2)

[HttpGet("{orderId:guid}")]
public async Task<ActionResult<OrderSummary>> GetOrder(
    Guid orderId,
    [FromServices] ILensQuery<OrderSummary> lens) {

    var order = await lens.DefaultScope.GetByIdAsync(orderId);
    return order is null ? NotFound() : Ok(order);
}

See the Perspectives Guide for multi-event perspectives, actions, and rebuild.

Add Tests

Whizbang uses TUnit for testing. Create a test project and reference your API:

Add Tests

dotnet new classlib -n QuickStartApp.API.Tests
cd QuickStartApp.API.Tests
dotnet add package TUnit
dotnet add reference ../QuickStartApp.API

TUnit runs on Microsoft.Testing.Platform, so the test project must be executable (this mirrors the library's own test projects):

Add Tests (2)

<!-- QuickStartApp.API.Tests.csproj -->
<PropertyGroup>
  <OutputType>Exe</OutputType>
  <IsPackable>false</IsPackable>
</PropertyGroup>

Test your receptor directly - receptors are plain classes:

Add Tests - CreateOrderReceptorTests

using Microsoft.Extensions.Logging.Abstractions;
using Whizbang.Core.ValueObjects;
using QuickStartApp.API.Messages;
using QuickStartApp.API.Receptors;

public class CreateOrderReceptorTests {
    [Test]
    public async Task HandleAsync_ValidOrder_ReturnsOrderCreatedAsync() {
        // Arrange
        var receptor = new CreateOrderReceptor(NullLogger<CreateOrderReceptor>.Instance);

        var command = new CreateOrder(
            OrderId: TrackedGuid.NewMedo(),
            CustomerId: TrackedGuid.NewMedo(),
            ProductName: "Test Product",
            Quantity: 5,
            UnitPrice: 19.99m
        );

        // Act
        var result = await receptor.HandleAsync(command);

        // Assert
        await Assert.That(result.OrderId).IsEqualTo(command.OrderId);
        await Assert.That(result.CustomerId).IsEqualTo(command.CustomerId);
        await Assert.That(result.ProductName).IsEqualTo("Test Product");
        await Assert.That(result.Quantity).IsEqualTo(5);
        await Assert.That(result.UnitPrice).IsEqualTo(19.99m);
        await Assert.That(result.Total).IsEqualTo(99.95m);
    }

    [Test]
    public async Task HandleAsync_InvalidQuantity_ThrowsExceptionAsync() {
        // Arrange
        var receptor = new CreateOrderReceptor(NullLogger<CreateOrderReceptor>.Instance);

        var command = new CreateOrder(
            OrderId: TrackedGuid.NewMedo(),
            CustomerId: TrackedGuid.NewMedo(),
            ProductName: "Test Product",
            Quantity: -1, // Invalid
            UnitPrice: 19.99m
        );

        // Act & Assert
        await Assert.That(async () => await receptor.HandleAsync(command))
            .Throws<InvalidOperationException>();
    }
}

Run tests: Add Tests (3)

dotnet test

Testing tips: never use Task.Delay/polling in tests — use completion signals. For integration tests, .WithDriver.InMemory swaps the Postgres driver for an in-memory one.

Explore the ECommerce Sample

The complete ECommerce sample demonstrates: - Backend for Frontend (BFF) with SignalR real-time updates - Microservices architecture (Order, Inventory, Payment, Shipping, Notification) - Event-driven workflows with Outbox/Inbox patterns - .NET Aspire orchestration for local development - Angular UI with NgRx state management - Integration testing with TUnit

See ECommerce Tutorial for complete walkthrough.

Common Patterns

Pattern 1: Command → Event

Pattern 1: Command → Event

CreateOrder (command)  CreateOrderReceptor  OrderCreated (event)
- Commands express intent (imperative: "create order") - Events express facts (past tense: "order created") - Receptors make decisions and return events

Pattern 2: Event → Perspectives

Pattern 2: Event → Perspectives

OrderCreated (event)  OrderSummaryPerspective.Apply  wh_per_order_summary
                      InventoryPerspective.Apply     wh_per_inventory
                      AnalyticsPerspective.Apply     wh_per_analytics
- One event can feed multiple perspectives - Perspectives are pure functions - the framework persists the results - Perspectives are eventually consistent

Pattern 3: Query via Lenses

Pattern 3: Query via Lenses

GET /api/orders/{id}  ILensQuery<OrderSummary>  wh_per_* table  Return model
- ILensQuery<TModel> is registered automatically per perspective model - Use .DefaultScope.GetByIdAsync(id) or .DefaultScope.Query (LINQ) - Fast, indexed reads over denormalized JSONB rows

Troubleshooting

Issue: "No receptor registered for CreateOrder"

Symptom: Runtime exception when calling LocalInvokeAsync

Solution: 1. Verify the generated registrations are called in Program.cs:

builder.Services.AddReceptors();
builder.Services.AddWhizbangDispatcher();
2. Rebuild so the generators re-run: dotnet clean && dotnet build

Issue: "Type 'IDispatcher' not found"

Symptom: Compiler error when injecting IDispatcher

Solution: 1. Add using directive:

using Whizbang.Core;
2. Verify package reference:
dotnet list package | grep Whizbang.Core
3. Restore if missing:
dotnet restore

Issue: Generated files not appearing

Symptom: AddReceptors() / EnsureWhizbangDatabaseInitializedAsync() don't exist, or no files in .whizbang/cache/

Solution: 1. Ensure the Whizbang.Generators package is referenced (and Whizbang.Data.EFCore.Postgres for the DbContext extension) 2. To inspect generated sources on disk, add MSBuild properties in .csproj:

<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>$(MSBuildProjectDirectory)/.whizbang/cache</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
3. Rebuild:
dotnet clean && dotnet build

Key Takeaways

🎯 Receptors handle commands and return events 🎯 Dispatcher routes messages with compile-time type safety 🎯 Zero Reflection - all wiring happens via source generators 🎯 Durable by default - events cascade to wh_event_store and the outbox 🎯 Pure perspectives - read models are folds over events, persisted by the framework

Further Reading

Core Concepts: - Dispatcher Deep Dive - Three dispatch patterns explained - Receptors Guide - Advanced receptor patterns - Perspectives Guide - Building read models - Lenses Guide - Query optimization

Messaging Patterns: - Outbox Pattern - Reliable cross-service messaging - Inbox Pattern - Exactly-once message processing - Work Coordination - Distributed work coordination

Advanced Topics: - Source Generators - Auto-discovery internals - Performance Tuning - Optimize for scale - Testing Strategies - Comprehensive testing guide


Next: Project Structure Guide - Organize your Whizbang application


Version 1.0.0 - Foundation Release | Last Updated: 2026-07-16