Project Structure Guide¶
Verified by tests
CreateProductCommandTests, OrderServiceIntegrationTests — library CI run #31657041675 (2026-08-13)
This guide shows recommended project structures for Whizbang applications, from simple single-project apps to complex multi-service architectures.
Quick Reference¶
| Architecture | When to Use | Example |
|---|---|---|
| Single Project | Simple apps, prototypes, learning | Todo app, simple API |
| Clean Architecture | Medium apps, clear boundaries | E-commerce site, CRM |
| Microservices | Distributed systems, team scaling | Multi-tenant SaaS, complex domains |
Core Principles¶
Regardless of project size, follow these principles:
- Separate Messages from Logic - Commands/Events in dedicated projects
- Stateless Receptors - No state in message handlers
- Read Model Isolation - Perspectives maintain their own data
- Explicit Dependencies - Clear project references, no circular dependencies
- Configuration by Environment - appsettings.{Environment}.json pattern
Single Project Structure¶
Best for: Learning, prototypes, simple APIs (< 10 message types)
MyApp/
├── MyApp.API/ # Single ASP.NET Core project
│ ├── Program.cs # DI configuration + app setup
│ ├── appsettings.json
│ ├── appsettings.Development.json
│ │
│ ├── Messages/ # Commands and Events
│ │ ├── Commands/
│ │ │ ├── CreateOrder.cs
│ │ │ └── CancelOrder.cs
│ │ └── Events/
│ │ ├── OrderCreated.cs
│ │ └── OrderCancelled.cs
│ │
│ ├── Receptors/ # Message handlers
│ │ ├── CreateOrderReceptor.cs
│ │ └── CancelOrderReceptor.cs
│ │
│ ├── Perspectives/ # Read model updaters
│ │ └── OrderSummaryPerspective.cs
│ │
│ ├── Lenses/ # Query interfaces
│ │ └── OrderLens.cs
│ │
│ ├── Endpoints/ # HTTP endpoints
│ │ └── OrdersController.cs
│ │
│ └── Models/ # Read models / DTOs
│ └── OrderSummary.cs
│
└── MyApp.API.Tests/ # Tests
├── Receptors/
│ └── CreateOrderReceptorTests.cs
└── Perspectives/
└── OrderSummaryPerspectiveTests.cs
Program.cs Setup¶
Program.cs Setup
using Microsoft.EntityFrameworkCore;
using Whizbang.Core;
using Whizbang.Data.EFCore.Postgres;
var builder = WebApplication.CreateBuilder(args);
// EF Core DbContext (provides Inbox/Outbox/EventStore via [WhizbangDbContext])
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")!;
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString));
// Unified Whizbang registration 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();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Initialize Whizbang schema (generated, idempotent)
using (var scope = app.Services.CreateScope()) {
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.EnsureWhizbangDatabaseInitializedAsync();
}
if (app.Environment.IsDevelopment()) {
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Message Organization¶
Commands (imperative - intent to change state): Message Organization
// Messages/Commands/CreateOrder.cs
using Whizbang.Core;
namespace MyApp.API.Messages.Commands;
public record CreateOrder(
[property: StreamId] Guid OrderId,
Guid CustomerId,
OrderLineItem[] Items
) : ICommand;
public record OrderLineItem(
Guid ProductId,
int Quantity,
decimal UnitPrice
);
Events (past tense - fact of what happened): Message Organization - OrderCreated
// Messages/Events/OrderCreated.cs
using Whizbang.Core;
namespace MyApp.API.Messages.Events;
public record OrderCreated(
[property: StreamId] Guid OrderId,
Guid CustomerId,
OrderLineItem[] Items,
decimal Total,
DateTimeOffset CreatedAt
) : IEvent;
Pros and Cons¶
Pros: - ✅ Simple to understand and navigate - ✅ Fast to set up and iterate - ✅ Single deployment unit - ✅ Easy debugging (single process)
Cons: - ❌ Limited scalability (single service) - ❌ Can become cluttered as app grows - ❌ All logic in one deployable - ❌ Hard to scale specific components independently
Clean Architecture Structure¶
Best for: Medium-sized applications with clear domain boundaries
MyApp/
├── src/
│ ├── MyApp.Messages/ # Shared message contracts
│ │ ├── Commands/
│ │ │ ├── CreateOrder.cs
│ │ │ └── CancelOrder.cs
│ │ ├── Events/
│ │ │ ├── OrderCreated.cs
│ │ │ └── OrderCancelled.cs
│ │ └── MyApp.Messages.csproj
│ │
│ ├── MyApp.Domain/ # Business logic (receptors)
│ │ ├── Receptors/
│ │ │ ├── CreateOrderReceptor.cs
│ │ │ └── CancelOrderReceptor.cs
│ │ └── MyApp.Domain.csproj # References: Messages, Whizbang.Core
│ │
│ ├── MyApp.ReadModels/ # Perspectives and Lenses
│ │ ├── Perspectives/
│ │ │ ├── OrderSummaryPerspective.cs
│ │ │ └── InventoryPerspective.cs
│ │ ├── Lenses/
│ │ │ ├── OrderLens.cs
│ │ │ └── InventoryLens.cs
│ │ ├── Models/
│ │ │ ├── OrderSummary.cs
│ │ │ └── InventoryLevel.cs
│ │ └── MyApp.ReadModels.csproj # References: Messages, Whizbang.Core
│ │
│ └── MyApp.API/ # HTTP API
│ ├── Program.cs
│ ├── Endpoints/
│ │ ├── OrderEndpoints.cs
│ │ └── InventoryEndpoints.cs
│ └── MyApp.API.csproj # References: Domain, ReadModels
│
├── tests/
│ ├── MyApp.Domain.Tests/
│ │ └── Receptors/
│ │ └── CreateOrderReceptorTests.cs
│ ├── MyApp.ReadModels.Tests/
│ │ └── Perspectives/
│ │ └── OrderSummaryPerspectiveTests.cs
│ └── MyApp.Integration.Tests/
│ └── OrderWorkflowTests.cs
│
└── MyApp.sln
Project Dependencies¶
flowchart TD
API["MyApp.API"]
Domain["MyApp.Domain"]
ReadModels["MyApp.ReadModels"]
Messages["MyApp.Messages<br/>(references only Whizbang.Core)"]
Core["Whizbang.Core"]
EFCore["Whizbang.Data.EFCore.Postgres"]
API --> Domain
API --> ReadModels
API --> Core
Domain --> Messages
Domain --> Core
ReadModels --> Messages
ReadModels --> Core
ReadModels --> EFCore
Messages --> Core
class API,Domain layer-core
class ReadModels layer-read
class Messages layer-command
class Core,EFCore layer-infrastructure
Key Point: The Messages project references only Whizbang.Core (for ICommand, IEvent, [StreamId], [WhizbangId]) - making it easy to share across services.
Central Package Management¶
Use Directory.Packages.props for version consistency:
Central Package Management
<!-- Directory.Packages.props (solution root) -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Whizbang.Core" Version="x.x.x" />
<PackageVersion Include="Whizbang.Generators" Version="x.x.x" />
<PackageVersion Include="Whizbang.Data.Dapper.Postgres" Version="x.x.x" />
<PackageVersion Include="Whizbang.Transports.AzureServiceBus" Version="x.x.x" />
</ItemGroup>
</Project>
Then in project files: Central Package Management (2)
<!-- MyApp.API.csproj -->
<ItemGroup>
<PackageReference Include="Whizbang.Core" /> <!-- Version comes from Directory.Packages.props -->
<PackageReference Include="Whizbang.Generators" />
</ItemGroup>
Shared Build Properties¶
Use Directory.Build.props for consistent settings:
Shared Build Properties
<!-- Directory.Build.props (solution root) -->
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>13</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<!-- Source Generator Settings -->
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(MSBuildProjectDirectory)/.whizbang/cache</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
</Project>
Pros and Cons¶
Pros: - ✅ Clear separation of concerns - ✅ Testable in isolation - ✅ Reusable message contracts - ✅ Easy to understand dependencies - ✅ Can grow to microservices later
Cons: - ❌ More projects to manage - ❌ Still a single deployable - ❌ Some indirection (navigate across projects)
Microservices Structure¶
Best for: Distributed systems, team scaling, independent deployment needs
This is the structure used in the ECommerce sample (12 projects).
ECommerce/
├── ECommerce.Contracts/ # Shared contracts (commands + events + IDs)
│ ├── Commands/
│ │ ├── CreateOrderCommand.cs
│ │ ├── ReserveInventoryCommand.cs
│ │ └── ProcessPaymentCommand.cs
│ ├── Events/
│ │ ├── OrderCreatedEvent.cs
│ │ ├── InventoryReservedEvent.cs
│ │ └── PaymentProcessedEvent.cs
│ ├── Lenses/ # Shared read model DTOs
│ │ ├── ProductDto.cs
│ │ └── InventoryLevelDto.cs
│ └── Ids.cs # [WhizbangId] strongly-typed IDs
│
├── ECommerce.BFF.API/ # Backend for Frontend (UI layer)
│ ├── Program.cs
│ ├── Perspectives/ # Read models for UI (pure Apply)
│ ├── Lenses/ # Custom query services
│ ├── Hubs/ # SignalR real-time
│ ├── GraphQL/ # HotChocolate queries
│ └── Endpoints/ # FastEndpoints REST API
│
├── ECommerce.OrderService.API/ # Order management service
│ ├── Program.cs
│ ├── OrderDbContext.cs # [WhizbangDbContext] partial DbContext
│ ├── Receptors/
│ │ └── CreateOrderReceptor.cs
│ ├── Endpoints/ # FastEndpoints REST API
│ └── GraphQL/ # HotChocolate mutations/queries
│
├── ECommerce.InventoryWorker/ # Inventory reservation (background worker)
│ ├── Program.cs
│ ├── InventoryDbContext.cs
│ ├── Receptors/
│ ├── Perspectives/
│ └── Worker.cs
│
├── ECommerce.PaymentWorker/ # Payment processing (background worker)
├── ECommerce.ShippingWorker/ # Fulfillment coordination (background worker)
├── ECommerce.NotificationWorker/ # Cross-cutting notifications (background worker)
│
├── ECommerce.UI/ # Angular frontend
│
├── ECommerce.AppHost/ # .NET Aspire orchestration
│ └── Program.cs
├── ECommerce.ServiceDefaults/ # Shared telemetry/health-check defaults
│
├── ECommerce.Contracts.Tests/ # Contract tests (TUnit)
└── ECommerce.IntegrationTests/ # End-to-end tests (TUnit)
Service Responsibilities¶
| Service | Type | Responsibilities |
|---|---|---|
| BFF.API | ASP.NET Core API | UI aggregation, SignalR, read models, GraphQL |
| OrderService.API | ASP.NET Core API | Order creation, REST + GraphQL |
| InventoryWorker | Background Worker | Inventory reservation, stock management |
| PaymentWorker | Background Worker | Payment processing, refunds |
| ShippingWorker | Background Worker | Fulfillment coordination |
| NotificationWorker | Background Worker | Email, SMS, push notifications |
Communication Pattern¶
sequenceDiagram
participant UI
participant BFF as BFF.API
participant Dispatcher as Dispatcher (local)
participant Receptor
participant Outbox
participant Publisher as WorkCoordinatorPublisher
participant ASB as Azure Service Bus
participant Inventory as InventoryWorker
participant Payment as PaymentWorker
participant Perspectives as BFF Perspectives
UI->>BFF: 1. Send CreateOrder command (via HTTP POST)
BFF->>Dispatcher: 2. LocalInvokeAsync<CreateOrder, OrderCreated>()
Receptor->>Outbox: 3. Stores OrderCreated event in outbox
Publisher->>ASB: 4. Publishes OrderCreated to topic
ASB->>Inventory: 5. Subscribes to OrderCreated - processes event, publishes InventoryReserved
ASB->>Payment: 6. Subscribes to InventoryReserved - processes event, publishes PaymentProcessed
ASB->>Perspectives: 7. Subscribe to all events - update read models, trigger SignalR updates to UI
.NET Aspire Orchestration¶
ECommerce.AppHost/Program.cs: .NET Aspire Orchestration
var builder = DistributedApplication.CreateBuilder(args);
// Infrastructure - one PostgreSQL server, one database per service
var postgres = builder.AddPostgres("postgres")
.WithPgAdmin();
var ordersDb = postgres.AddDatabase("ordersdb");
var inventoryDb = postgres.AddDatabase("inventorydb");
var paymentDb = postgres.AddDatabase("paymentdb");
var shippingDb = postgres.AddDatabase("shippingdb");
var notificationDb = postgres.AddDatabase("notificationdb");
var bffDb = postgres.AddDatabase("bffdb");
// Azure Service Bus emulator with topics + per-service subscriptions
var serviceBus = builder.AddAzureServiceBus("servicebus")
.RunAsEmulator();
// Services (each references its own database + the shared transport)
var orderService = builder.AddProject<Projects.ECommerce_OrderService_API>("orderservice")
.WithReference(ordersDb)
.WithReference(serviceBus);
var inventoryWorker = builder.AddProject<Projects.ECommerce_InventoryWorker>("inventoryworker")
.WithReference(inventoryDb)
.WithReference(serviceBus);
var paymentWorker = builder.AddProject<Projects.ECommerce_PaymentWorker>("paymentworker")
.WithReference(paymentDb)
.WithReference(serviceBus);
var shippingWorker = builder.AddProject<Projects.ECommerce_ShippingWorker>("shippingworker")
.WithReference(shippingDb)
.WithReference(serviceBus);
var notificationWorker = builder.AddProject<Projects.ECommerce_NotificationWorker>("notificationworker")
.WithReference(notificationDb)
.WithReference(serviceBus);
var ui = builder.AddNpmApp("ui", "../ECommerce.UI", "start")
.WithHttpEndpoint(port: 4200, env: "PORT")
.WithExternalHttpEndpoints();
var bff = builder.AddProject<Projects.ECommerce_BFF_API>("bff")
.WithReference(bffDb)
.WithReference(serviceBus)
.WithReference(ui) // BFF discovers the Angular URL for CORS
.WithExternalHttpEndpoints();
builder.Build().Run();
Benefits:
- One-command local development: dotnet run --project ECommerce.AppHost
- Automatic service discovery
- Built-in Aspire dashboard
- PostgreSQL and Service Bus emulators
- Health checks and observability
Pros and Cons¶
Pros: - ✅ Independent deployment per service - ✅ Scalability (scale specific services) - ✅ Team autonomy (own services) - ✅ Technology diversity (different stacks per service if needed) - ✅ Fault isolation
Cons: - ❌ Complexity (distributed system challenges) - ❌ Eventual consistency - ❌ Debugging across services - ❌ Infrastructure overhead
Configuration Patterns¶
appsettings.json Structure¶
Development (appsettings.Development.json):
appsettings.json Structure
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Whizbang": "Debug",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=myapp;Username=postgres;Password=dev_password"
},
"WorkCoordinatorPublisher": {
"PollingIntervalMilliseconds": 1000,
"LeaseSeconds": 300,
"DebugMode": true
},
"PerspectiveWorker": {
"PollingIntervalMilliseconds": 1000,
"LeaseSeconds": 300
}
}
Production (appsettings.Production.json):
appsettings.json Structure (2)
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Whizbang": "Information"
}
},
"ConnectionStrings": {
"DefaultConnection": "${DATABASE_URL}" // Injected from environment
},
"WorkCoordinatorPublisher": {
"PollingIntervalMilliseconds": 5000,
"LeaseSeconds": 600,
"DebugMode": false
}
}
Environment Variables¶
Use environment variables for secrets:
Environment Variables
# .env (local development - NOT committed)
DATABASE_URL=Host=localhost;Database=myapp;Username=postgres;Password=dev_password
SERVICE_BUS_CONNECTION_STRING=Endpoint=sb://localhost;...
# Kubernetes/Docker secrets
kubectl create secret generic myapp-db --from-literal=connection-string="Host=..."
Dependency Injection Patterns¶
Service Registration Layers¶
Layer 1: DbContext + Unified Whizbang API (EF Core Postgres driver): Service Registration Layers
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString));
builder.Services
.AddWhizbang() // IDispatcher, workers, envelope infrastructure
.WithEFCore<AppDbContext>() // IInbox, IOutbox, IEventStore via EF Core
.WithDriver.Postgres; // IPerspectiveStore<T> + ILensQuery<T> per model
Layer 2: Generated Registrations (produced by Whizbang.Generators): Service Registration Layers (2)
builder.Services.AddReceptors(); // All discovered IReceptor implementations
builder.Services.AddWhizbangDispatcher(); // Generated zero-reflection dispatcher
builder.Services.AddPerspectiveRunners(); // All discovered perspective runners
Layer 3: Transports: Service Registration Layers (3)
builder.Services.AddAzureServiceBusTransport(serviceBusConnection);
// OR
builder.Services.AddRabbitMQTransport(rabbitMqConnection);
Layer 4: Application Services: Service Registration Layers (4)
builder.Services.AddTransient<OrderQueryService>(); // Wraps ILensQuery<OrderSummary>
builder.Services.AddSingleton<IEmailService, SendGridEmailService>();
Lifetime Guidelines¶
| Component | Lifetime | Reason |
|---|---|---|
IDispatcher |
Singleton | Shared router, no state |
IReceptor<,> |
Transient | May inject scoped services (DbContext) |
IPerspectiveFor<,...> |
Stateless | Pure Apply functions - no injected services |
ILensQuery<T> |
Scoped | Wraps the scoped EF Core DbContext |
DbContext |
Scoped | Per-request database context |
Testing Structure¶
Unit Tests¶
Test receptors in isolation: Unit Tests
// tests/MyApp.Domain.Tests/Receptors/CreateOrderReceptorTests.cs
public class CreateOrderReceptorTests {
[Test]
public async Task HandleAsync_ValidOrder_ReturnsOrderCreatedAsync() {
// Arrange
var receptor = new CreateOrderReceptor(/* mock dependencies */);
var command = new CreateOrder(/* ... */);
// Act
var result = await receptor.HandleAsync(command);
// Assert
await Assert.That(result.OrderId).IsNotEqualTo(Guid.Empty);
}
}
Integration Tests¶
Test full message flow: Integration Tests
// tests/MyApp.Integration.Tests/OrderWorkflowTests.cs
public class OrderWorkflowTests {
private WebApplicationFactory<Program> _factory;
private IDispatcher _dispatcher;
[Before(Test)]
public async Task SetupAsync() {
_factory = new WebApplicationFactory<Program>();
_dispatcher = _factory.Services.GetRequiredService<IDispatcher>();
}
[Test]
public async Task CreateOrder_FullWorkflow_UpdatesReadModelAsync() {
// Arrange
var command = new CreateOrder(/* ... */);
// Act - dispatch command (returned event cascades to perspectives)
var result = await _dispatcher.LocalInvokeAsync<CreateOrder, OrderCreated>(command);
// Assert - query read model via lens (use a completion signal in real tests
// rather than polling; see AppendAndWaitAsync / lifecycle hooks)
using var scope = _factory.Services.CreateScope();
var lens = scope.ServiceProvider.GetRequiredService<ILensQuery<OrderSummary>>();
var order = await lens.DefaultScope.GetByIdAsync(result.OrderId);
await Assert.That(order).IsNotNull();
await Assert.That(order!.Status).IsEqualTo("Created");
}
}
Migration Paths¶
Single → Clean Architecture¶
- Create
MyApp.Messagesproject - Move commands/events to Messages project
- Create
MyApp.Domainproject - Move receptors to Domain project
- Create
MyApp.ReadModelsproject - Move perspectives/lenses to ReadModels project
- Update API project to reference Domain + ReadModels
Timeline: 1-2 hours for small app
Clean Architecture → Microservices¶
- Identify service boundaries (order, inventory, payment, etc.)
- Create service projects (API or Worker)
- Add transport (Azure Service Bus)
- Implement Outbox/Inbox patterns
- Split receptors across services
- Create BFF for UI aggregation
- Add .NET Aspire AppHost for orchestration
Timeline: 1-2 weeks for initial split, iterative refinement
Best Practices¶
DO ✅¶
- ✅ Use central package management (
Directory.Packages.props) - ✅ Use shared build properties (
Directory.Build.props) - ✅ Keep messages in separate project (no dependencies)
- ✅ Use auto-discovery for receptors/perspectives (Whizbang.Generators)
- ✅ Follow namespace conventions (Messages.Commands, Messages.Events)
- ✅ Use environment-specific appsettings
- ✅ Keep receptors stateless
- ✅ Test receptors in isolation
DON'T ❌¶
- ❌ Put business logic in controllers/endpoints
- ❌ Create circular dependencies between projects
- ❌ Reference domain projects from Messages project
- ❌ Hard-code connection strings
- ❌ Share database contexts across services
- ❌ Use static state in receptors
- ❌ Mix read and write logic in same class
Example: Adding a New Feature¶
Scenario: Add "Cancel Order" feature to Clean Architecture app
Step 1: Define Message¶
Step 1: Define Message
// MyApp.Messages/Commands/CancelOrder.cs
public record CancelOrder(
[property: StreamId] Guid OrderId,
string Reason
) : ICommand;
// MyApp.Messages/Events/OrderCancelled.cs
public record OrderCancelled(
[property: StreamId] Guid OrderId,
string Reason,
DateTimeOffset CancelledAt
) : IEvent;
Step 2: Create Receptor¶
Step 2: Create Receptor
// MyApp.Domain/Receptors/CancelOrderReceptor.cs
using Whizbang.Core;
using MyApp.Messages.Commands;
using MyApp.Messages.Events;
public class CancelOrderReceptor(ILensQuery<OrderSummary> orders)
: IReceptor<CancelOrder, OrderCancelled> {
public async ValueTask<OrderCancelled> HandleAsync(
CancelOrder message,
CancellationToken ct = default) {
// Validation against the read model
var order = await orders.DefaultScope.GetByIdAsync(message.OrderId, ct);
if (order is null) {
throw new InvalidOperationException($"Order {message.OrderId} not found");
}
if (order.Status == "Shipped") {
throw new InvalidOperationException("Cannot cancel shipped order");
}
// Return event (cascades to event store / outbox automatically)
return new OrderCancelled(
OrderId: message.OrderId,
Reason: message.Reason,
CancelledAt: DateTimeOffset.UtcNow
);
}
}
Step 3: Update Perspective¶
Step 3: Update Perspective
// MyApp.ReadModels/Perspectives/OrderSummaryPerspective.cs
public class OrderSummaryPerspective :
IPerspectiveFor<OrderSummary, OrderCreated, OrderCancelled> { // Add new event type
public OrderSummary Apply(OrderSummary currentData, OrderCreated eventData) =>
currentData with {
OrderId = eventData.OrderId,
Status = "Created",
Total = eventData.Total,
CreatedAt = eventData.CreatedAt
};
public OrderSummary Apply(OrderSummary currentData, OrderCancelled eventData) =>
currentData with {
Status = "Cancelled",
CancelledAt = eventData.CancelledAt
};
}
Step 4: Add Endpoint¶
Step 4: Add Endpoint
// MyApp.API/Endpoints/OrdersController.cs
[HttpPost("{orderId:guid}/cancel")]
public async Task<ActionResult> CancelOrder(
Guid orderId,
[FromBody] CancelOrderRequest request,
CancellationToken ct) {
var command = new CancelOrder(orderId, request.Reason);
var result = await _dispatcher.LocalInvokeAsync<CancelOrder, OrderCancelled>(command);
return Ok(result);
}
Step 5: Test¶
Step 5: Test
// MyApp.Domain.Tests/Receptors/CancelOrderReceptorTests.cs
[Test]
public async Task HandleAsync_ValidOrder_ReturnsOrderCancelledAsync() {
// Arrange
var receptor = new CancelOrderReceptor(mockDb);
var command = new CancelOrder(Guid.NewGuid(), "Customer request");
// Act
var result = await receptor.HandleAsync(command);
// Assert
await Assert.That(result.Reason).IsEqualTo("Customer request");
}
Done! Auto-discovery registers the receptor automatically on next build.
Further Reading¶
Architecture Patterns: - Core Concepts: Dispatcher - Core Concepts: Receptors - Core Concepts: Perspectives
Messaging: - Outbox Pattern - Inbox Pattern - Work Coordination
Examples: - ECommerce Sample Overview - BFF Pattern
Next: Dive into Core Concepts: Dispatcher to master message routing patterns.
Version 1.0.0 - Foundation Release | Last Updated: 2024-12-12