Shipping Service¶
Verified by tests
CreateShipmentReceptorTests — library CI run #31657041675 (2026-08-13)
Build the Shipping Worker - a background service that reacts to PaymentProcessedEvent, dispatches CreateShipmentCommand, creates shipments, and publishes ShipmentCreatedEvent with tracking information.
:::note This is Part 5 of the ECommerce Tutorial. Complete Notification Service first. :::
What You'll Build¶
flowchart TD
subgraph SSA["Shipping Service Architecture"]
ASBIn["Azure Service Bus"]
EventReceptor["PaymentShippingReceptor<br/>(event receptor)<br/>- Dispatch CreateShipmentCommand"]
CmdReceptor["CreateShipmentReceptor<br/>- Call carrier API<br/>- Publish ShipmentCreatedEvent"]
EventStore["wh_event_store (Event Store)"]
OutboxTable["wh_outbox (Outbox)"]
CarrierAPI["Carrier API<br/>(FedEx)"]
ASBOut["Azure Service Bus<br/>ShipmentCreatedEvent"]
ASBIn -->|"PaymentProcessedEvent"| EventReceptor
EventReceptor -->|"CreateShipmentCommand"| CmdReceptor
CmdReceptor --> CarrierAPI
CmdReceptor --> EventStore
CmdReceptor --> OutboxTable
OutboxTable --> ASBOut
end
class ASBIn,ASBOut,OutboxTable layer-command
class EventReceptor,CmdReceptor layer-core
class EventStore layer-event
class CarrierAPI layer-infrastructure
Features:
- ✅ Events can have receptors — react to PaymentProcessedEvent directly
- ✅ Event → command chaining (PaymentProcessedEvent → CreateShipmentCommand)
- ✅ Shipment creation with tracking numbers
- ✅ Carrier API abstraction (production pattern)
Step 1: Define Messages¶
CreateShipmentCommand¶
ECommerce.Contracts/Commands/CreateShipmentCommand.cs:
CreateShipmentCommand
using Whizbang.Core;
namespace ECommerce.Contracts.Commands;
/// <summary>
/// Command to create a shipment after payment is processed
/// </summary>
public record CreateShipmentCommand : ICommand {
public required string OrderId { get; init; }
public required string ShippingAddress { get; init; }
}
ShipmentCreatedEvent¶
ECommerce.Contracts/Events/ShipmentCreatedEvent.cs:
ShipmentCreated Event
using Whizbang.Core;
namespace ECommerce.Contracts.Events;
/// <summary>
/// Event published when a shipment is created
/// </summary>
public record ShipmentCreatedEvent : IEvent {
[StreamId]
public required string OrderId { get; init; }
public required string ShipmentId { get; init; }
public required string TrackingNumber { get; init; }
}
Step 2: Event Receptor (Event → Command)¶
ECommerce.ShippingWorker/Receptors/PaymentShippingReceptor.cs:
Step 2: Event Receptor
using ECommerce.Contracts.Commands;
using ECommerce.Contracts.Events;
using Microsoft.Extensions.Logging;
using Whizbang.Core;
namespace ECommerce.ShippingWorker.Receptors;
/// <summary>
/// Handles PaymentProcessedEvent DIRECTLY and dispatches CreateShipmentCommand
/// This demonstrates that EVENTS can have RECEPTORS (not just perspectives!)
/// </summary>
public class PaymentShippingReceptor(IDispatcher dispatcher, ILogger<PaymentShippingReceptor> logger) : IReceptor<PaymentProcessedEvent, CreateShipmentCommand> {
public async ValueTask<CreateShipmentCommand> HandleAsync(
PaymentProcessedEvent message,
CancellationToken cancellationToken = default) {
logger.LogInformation(
"Payment processed for order {OrderId}, initiating shipment creation",
message.OrderId);
// In a real system, would look up shipping address from order
var createShipmentCommand = new CreateShipmentCommand {
OrderId = message.OrderId,
ShippingAddress = "123 Main St, City, State 12345"
};
// Dispatch the command
await dispatcher.SendAsync(createShipmentCommand);
logger.LogInformation(
"Dispatched create shipment command for order {OrderId}",
message.OrderId);
return createShipmentCommand;
}
}
SendAsync returns a Task<IDeliveryReceipt> — the command is routed to whichever service owns ecommerce.shipping.commands (this same worker, via the shared inbox topic).
Step 3: Command Receptor¶
ECommerce.ShippingWorker/Receptors/CreateShipmentReceptor.cs:
Step 3: Implement Receptor
using ECommerce.Contracts.Commands;
using ECommerce.Contracts.Events;
using Microsoft.Extensions.Logging;
using Whizbang.Core;
namespace ECommerce.ShippingWorker.Receptors;
/// <summary>
/// Handles CreateShipmentCommand and publishes ShipmentCreatedEvent
/// </summary>
public class CreateShipmentReceptor(IDispatcher dispatcher, ILogger<CreateShipmentReceptor> logger) : IReceptor<CreateShipmentCommand, ShipmentCreatedEvent> {
public async ValueTask<ShipmentCreatedEvent> HandleAsync(
CreateShipmentCommand message,
CancellationToken cancellationToken = default) {
logger.LogInformation(
"Creating shipment for order {OrderId} to address: {Address}",
message.OrderId,
message.ShippingAddress);
// Simulate shipment creation
// In a real system, this would integrate with a shipping provider API
var shipmentCreated = new ShipmentCreatedEvent {
OrderId = message.OrderId,
ShipmentId = $"SHIP-{Guid.NewGuid():N}",
TrackingNumber = $"TRK{Random.Shared.Next(100000, 999999)}"
};
// Publish the event
await dispatcher.PublishAsync(shipmentCreated);
logger.LogInformation(
"Shipment created for order {OrderId} with tracking number {TrackingNumber}",
message.OrderId,
shipmentCreated.TrackingNumber);
return shipmentCreated;
}
}
Updated
ShipmentId/TrackingNumber here are external reference strings (carrier-style labels), not Whizbang ids — Whizbang stream and message ids are framework-generated UUIDv7 values. Earlier drafts also hand-wrote shipments/tracking_events tables and outbox SQL; the event stream plus a perspective replaces that.
Step 4: Carrier API Abstraction (Production)¶
The sample simulates the carrier. For production, hide the carrier behind an interface — the receptor stays the same, only the injected implementation changes:
Step 4: Carrier API Abstraction
namespace ECommerce.ShippingWorker.Services;
public interface ICarrierService {
Task<ShipmentResult> CreateShipmentAsync(
ShipmentRequest request,
CancellationToken ct = default
);
Task<TrackingResult> GetTrackingAsync(
string trackingNumber,
CancellationToken ct = default
);
}
public record ShipmentRequest(
string OrderId,
string From,
string To,
decimal WeightPounds,
string ServiceLevel // "Standard", "Express", "Overnight"
);
public record ShipmentResult(
bool Success,
string? ShipmentId,
string? TrackingNumber,
string? LabelUrl,
decimal? ShippingCost,
DateTime? EstimatedDelivery,
string? ErrorMessage
);
public record TrackingResult(
string TrackingNumber,
string Status,
DateTime? EstimatedDelivery
);
A FedEx/UPS/USPS implementation is ordinary HttpClient code (OAuth token, POST shipment request, parse tracking/label from the response) — see the carrier's REST documentation. Wrap calls with Polly retry/circuit-breaker policies as shown in Payment Processing.
Tracking webhooks: carriers push status updates to an HTTP endpoint you host. Translate each webhook into a domain event (e.g., ShipmentStatusChangedEvent) and publish it via IDispatcher.PublishAsync — then perspectives keep shipment status queryable, and the Notification worker can react to it.
Step 5: Service Configuration¶
ECommerce.ShippingWorker/Program.cs (condensed from the sample):
Step 5: Service Configuration
using Whizbang.Core;
using Whizbang.Core.Generated;
using Whizbang.Data.EFCore.Postgres;
using Whizbang.Transports.AzureServiceBus;
using ECommerce.Contracts.Generated;
using ECommerce.ShippingWorker;
using ECommerce.ShippingWorker.Generated;
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
var serviceBusConnection = builder.Configuration.GetConnectionString("servicebus")
?? throw new InvalidOperationException("Azure Service Bus connection string 'servicebus' not found");
builder.Services.AddAzureServiceBusTransport(serviceBusConnection);
builder.Services.AddAzureServiceBusHealthChecks();
// Unified Whizbang API: routing + EF Core Postgres driver + transport consumer
_ = builder.Services
.AddWhizbang()
.WithRouting(routing => {
routing
.OwnDomains("ecommerce.shipping.commands")
.SubscribeTo("ecommerce.orders.events")
.Inbox.UseSharedTopic("inbox");
})
.WithEFCore<ShippingDbContext>()
.WithDriver.Postgres
.AddTransportConsumer();
builder.Services.AddReceptors();
builder.Services.AddWhizbangDispatcher();
var host = builder.Build();
using (var scope = host.Services.CreateScope()) {
var dbContext = scope.ServiceProvider.GetRequiredService<ShippingDbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
await dbContext.EnsureWhizbangDatabaseInitializedAsync(logger);
}
host.Run();
Step 6: Test Shipping Flow¶
1. Create Order (Full End-to-End)¶
Create Order (Full End-to-End)
curl -X POST http://localhost:5000/api/orders \
-H "Content-Type: application/json" \
-d '{ "customerId": "...", "lineItems": [ ... ] }'
2. Observe Event Flow¶
Aspire Dashboard:
1. Order Service: OrderCreatedEvent
2. Inventory Worker: InventoryReservedEvent
3. Payment Worker: PaymentProcessedEvent
4. Shipping Worker: PaymentShippingReceptor → CreateShipmentCommand → ShipmentCreatedEvent (THIS STEP)
5. Notification Worker: shipping notification
3. Verify Shipment Events¶
Verify Shipment
SELECT stream_id, event_type, created_at
FROM wh_event_store
WHERE event_type LIKE '%Shipment%'
ORDER BY created_at DESC;
Expected: a ShipmentCreatedEvent row streamed by OrderId, with SHIP-.../TRK... references in the payload.
Testing¶
tests/ECommerce.ShippingWorker.Tests/CreateShipmentReceptorTests.cs follows the same pattern as the other workers:
Unit Test - Create Shipment
[Test]
public async Task HandleAsync_CreatesShipment_PublishesEventAsync() {
// Arrange
var dispatcher = new TestDispatcher(); // records PublishAsync calls
var receptor = new CreateShipmentReceptor(dispatcher, NullLogger<CreateShipmentReceptor>.Instance);
var command = new CreateShipmentCommand {
OrderId = "order-123",
ShippingAddress = "123 Main St, City, State 12345"
};
// Act
var result = await receptor.HandleAsync(command);
// Assert
await Assert.That(result.OrderId).IsEqualTo("order-123");
await Assert.That(result.ShipmentId).StartsWith("SHIP-");
await Assert.That(result.TrackingNumber).StartsWith("TRK");
await Assert.That(dispatcher.PublishedEvents).Count().IsEqualTo(1);
}
Key Takeaways¶
✅ Events Have Receptors - IReceptor<PaymentProcessedEvent, CreateShipmentCommand> chains flows
✅ Event → Command - SendAsync routes commands to the owning service
✅ Carrier API Abstraction - Swap carriers easily (FedEx, UPS, USPS)
✅ Webhook → Event - Translate carrier callbacks into domain events
✅ Event-Driven - ShipmentCreatedEvent triggers notifications
Next Steps¶
Continue to Customer Service to: - Build BFF (Backend for Frontend) API - Implement perspectives for read models - Query order summaries - Aggregate data from multiple services
Version 1.0.0 - Foundation Release | Last Updated: 2026-07-16