Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/audits/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ Persistent log of every `/article-audit` run. One row per audit; full reasoning
| 2026-06-12 | [The Async/Await Habit That Quietly Slows ASP.NET Core Under Load](2026-06-12-async-await-habits-under-load.md) (Kerim Kara, Activated Thinker) | ✅ All five teaser categories encoded, mostly more rigorously (build-time bans + hooks vs advice); paywall-limited — intro only, re-audit on paste | No action |
| 2026-06-12 | [.NET Microservices Guide — What To Do and What To Avoid](2026-06-12-dotnet-microservices-do-avoid.md) (Kerim Kara, Real World .NET) | ⚙️ Re-audited after user pasted full body (initial pass paywall-limited). All 10 do/avoid items covered; article's own examples violate the canon 3× (publish-after-save dual-write vs outbox; ValidateAudience=false vs JWT rule; IOrderService layer vs VSA). Recurring implicit stance: internal-gRPC trust + no-gateway undocumented (2nd hit today) | Offered small encoding issue (internal-trust + no-gateway triggers); pending user decision |
| 2026-06-12 | [Building Dapr Workflows in .NET With Aspire](2026-06-12-dapr-workflows-aspire.md) (Milan Jovanović, The .NET Weekly) | ✅ Considered + rejected with depth — project-decisions.md §22 ("Dapr — considered, not adopted") + architecture.md:283 (choreography saga, no central orchestrator) + perf doc already names Temporal/Durable Functions/Step Functions as the durable-execution alternatives. 202-Accepted + idempotency + outbox all encoded | No new issue — §22's table maps the classic 5 building blocks but not **Dapr Workflow**; fold a Workflow row + orchestration-vs-choreography trigger into open #86 |
| 2026-06-15 | [Wolverine + Azure Service Bus Emulator](2026-06-15-wolverine-asb-emulator.md) (WolverineFx official docs) | ⚙️ Official recipe (`UseAzureServiceBus` + `transport.ManagementConnectionString` on :5300 + AutoProvision) matches in-flight feat-branch fix and **contradicts two `main` rules** — CLAUDE.md:230 + architecture.md:399 claim "emulator has no management API" (false; it does, only subscription admin returns 500). Also confirms the `ListenToAzureServiceBusSubscription(sub, c => c.TopicName=topic)` API vs our wrong `"{topic}/{sub}"` form | No new issue — tracked on #148; doc corrections land with that branch. Confirms feat-branch "Approach B" is officially correct; subscription-admin-500 limitation is ours to document (upstream omits it) |
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,10 @@ This rule is for humans, AI assistants, and future-you. Don't wait to be asked.
- Individual `.csproj` files reference packages WITHOUT version attributes
- Shared build settings in `Directory.Build.props`
- **Aspire SDK and runtime packages must match — including minor versions.** The `Aspire.AppHost.Sdk/X.Y.Z` declared in `NextAurora.AppHost.csproj` and the `Aspire.Hosting.*` package versions in `Directory.Packages.props` need to match exactly (or the SDK ≥ packages). Major mismatches surface at *build/startup* as `TypeLoadException` (internal types like `PublishingContext`). Minor mismatches surface at *runtime* as DCP rejecting startup with `Newer version of the Aspire.Hosting.AppHost package is required`. Bump SDK and packages together as one change.
- **Service Bus subscription names are globally unique within the namespace** (Aspire 13+). Don't reuse the same subscription name on different topics — `DistributedApplicationException` at AppHost startup. Convention: `{consumer}-{source-events}-sub` (e.g. `notify-orders-sub`, `notify-payments-sub`). When adding a new subscription in `AppHost.cs`, also update the matching `ListenToAzureServiceBusSubscription("{topic}/{sub}")` string in the consuming service's `Program.cs`.
- **(ASB opt-in path) Service Bus subscription naming + listener API.** Subscription names are globally unique within the namespace (Aspire 13+) — convention `{consumer}-{source-events}-sub` (e.g. `notify-orders-sub`); reusing one across topics throws `DistributedApplicationException` at AppHost startup. The `AppHost.cs` subscription must match the consuming service's `ListenToAzureServiceBusSubscription("{sub}", c => c.TopicName = "{topic}")` — name and topic are SEPARATE arguments, NEVER a combined `"{topic}/{sub}"` string (silently mis-registers the listener so it never attaches; fails against real Azure too, not just the emulator). Only relevant when `Messaging:Transport=azureservicebus`; RabbitMQ is the default (see the transport rule below).
- **Aspire 13+ Azure resources need explicit local-dev fallbacks.** `AddAzureServiceBus` requires a chained `.RunAsEmulator()` for local runs (the implicit emulator behavior from Aspire 9 is gone). `AddAzureApplicationInsights` has no local emulator at all — gate it on `builder.ExecutionContext.IsPublishMode` and skip in dev. Without these, AppHost's resource pane shows "Missing subscription configuration" and every service that `WithReference`s the resource fails to start.
- **`WithReference(x)` ≠ wait-for-healthy in Aspire 13.** `WithReference` only injects connection strings / endpoints; the service starts as soon as its env vars are resolvable, even if the target is still warming up. Containers like the Service Bus emulator take 30-60s to be healthy on first run; services that race past that crash with "connection refused" and exit. **Hard rule: every `WithReference` on a non-trivial dependency (DB, messaging, identity, peer service) gets a matching `.WaitFor(x)`.** Without it, the Aspire dashboard shows services as "Finished" instead of "Running" because they exited before infra was ready.
- **Wolverine `AutoProvision()` is incompatible with the Service Bus emulator — disable it for local dev.** `AutoProvision()` (default on; `Wolverine:AutoProvision` true) provisions topics/subscriptions at startup via the Service Bus **management API** (`ServiceBusAdministrationClient`). The **emulator implements only the AMQP data plane, not the management API**, so every check fails, retries 4×, and the host dies with `BrokerInitializationException: Unable to initialize the Broker asb in time` — a sub-second, deterministic startup crash hitting *every* Wolverine service (catalog has no ASB, so it survives, which makes it look selective). Locally the topology is already declared in `AppHost.cs` (`AddServiceBusTopic`/`AddServiceBusSubscription` write the emulator's config), so provisioning is impossible *and* redundant. **Fix: AppHost injects `Wolverine__AutoProvision=false` into each Wolverine service** (mirrors the integration-test harnesses); in Publish mode against real Azure it stays on to create entities. See [docs/architecture.md](docs/architecture.md) Infrastructure section.
- **Messaging transport is config-selectable via `Messaging:Transport` — RabbitMQ (default) or Azure Service Bus.** Wolverine abstracts the broker, so only the per-service transport block in `Program.cs` differs (handlers, outbox, idempotency, and the saga are identical). **RabbitMQ is the default** for local dev, CI, and the Hetzner deployment — it runs as one container, AutoProvisions cleanly against the live broker, and the **full saga flows locally** (verified end-to-end: order → Shipped). **Azure Service Bus is opt-in** (`Messaging:Transport=azureservicebus`); its real target is Azure, because the local **ASB emulator is limited** — its subscription admin endpoints return HTTP 500 *and* Wolverine's system queues can't auto-provision, so the saga does NOT flow on the emulator. Use RabbitMQ locally. The AppHost provisions whichever broker is selected and injects `Messaging__Transport` (plus `Wolverine__AutoProvision=false` only for the ASB emulator). RabbitMQ maps ASB topics → fanout exchanges and subscriptions → queues bound to them. Full detail: [docs/architecture.md](docs/architecture.md) Infrastructure section + [docs/full-saga-deployment-plan.md](docs/full-saga-deployment-plan.md) (D3) + #148.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
## Communication Patterns

Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<PackageVersion Include="Aspire.Hosting.Azure.ApplicationInsights" Version="13.3.0" />
<PackageVersion Include="Aspire.Hosting.Azure.ServiceBus" Version="13.3.0" />
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.3.0" />
<PackageVersion Include="Aspire.Hosting.RabbitMQ" Version="13.3.0" />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<PackageVersion Include="Aspire.Hosting.Redis" Version="13.3.0" />
<PackageVersion Include="Aspire.Hosting.SqlServer" Version="13.3.0" />

Expand All @@ -37,6 +38,7 @@
static codegen (TypeLoadMode.Static) — deferred. See https://wolverinefx.net/guide/codegen.html. -->
<PackageVersion Include="WolverineFx.RuntimeCompilation" Version="6.8.0" />
<PackageVersion Include="WolverineFx.AzureServiceBus" Version="6.8.0" />
<PackageVersion Include="WolverineFx.RabbitMQ" Version="6.8.0" />
<PackageVersion Include="WolverineFx.EntityFrameworkCore" Version="6.8.0" />
<PackageVersion Include="WolverineFx.FluentValidation" Version="6.8.0" />
<PackageVersion Include="WolverineFx.SqlServer" Version="6.8.0" />
Expand Down
127 changes: 69 additions & 58 deletions NextAurora.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,36 +34,49 @@
// once we wire it (architecture.md "Future Considerations").
var redis = builder.AddRedis("cache");

// Azure Service Bus — Aspire 13 requires explicit `.RunAsEmulator()` for local dev (in 9.x
// the emulator was implicit). Without this, AppHost reports "Missing subscription configuration"
// at startup because Aspire treats the resource as needing a real Azure subscription.
// See CLAUDE.md.
var serviceBus = builder.AddAzureServiceBus("messaging")
.RunAsEmulator();

// Topic / subscription topology. Each service that publishes events owns a topic; subscribers
// get their own subscription per topic so they can be scaled and dead-lettered independently.
//
// Subscription naming: `{consumer}-{source-events}-sub`. Aspire 13 requires subscription names
// to be globally unique within the bus namespace (not scoped per topic), hence the source
// suffix. The strings here must match the `ListenToAzureServiceBusSubscription("{topic}/{sub}")`
// calls in each service's Program.cs.
var orderEventsTopic = serviceBus.AddServiceBusTopic("order-events");
orderEventsTopic.AddServiceBusSubscription("payment-orders-sub"); // PaymentService consumes
orderEventsTopic.AddServiceBusSubscription("notify-orders-sub"); // NotificationService consumes

var paymentEventsTopic = serviceBus.AddServiceBusTopic("payment-events");
paymentEventsTopic.AddServiceBusSubscription("order-payments-sub"); // OrderService consumes
paymentEventsTopic.AddServiceBusSubscription("shipping-payments-sub"); // ShippingService consumes
paymentEventsTopic.AddServiceBusSubscription("notify-payments-sub"); // NotificationService consumes (failure notifications)

var shippingEventsTopic = serviceBus.AddServiceBusTopic("shipping-events");
shippingEventsTopic.AddServiceBusSubscription("order-shipping-sub"); // OrderService consumes
shippingEventsTopic.AddServiceBusSubscription("notify-shipping-sub"); // NotificationService consumes

// Direct queue (not topic) for "send a notification right now" requests — single consumer,
// fan-in only. NotificationService listens here in addition to the topic subscriptions.
serviceBus.AddServiceBusQueue("send-notification");
// --- Messaging broker (config-selectable: Messaging:Transport = rabbitmq | azureservicebus) ---
// RabbitMQ is the DEFAULT: it's the deployed (Hetzner) broker, runs cleanly as a single container
// with a management UI, and — unlike the ASB emulator — works end-to-end locally. Azure Service
// Bus is opt-in; its real target is Azure (the local emulator has known limitations, see #148).
// Wolverine abstracts the broker, so each service's Program.cs branches on the same Messaging:
// Transport flag. The connection string is exposed under "messaging" for whichever broker wins.
// See CLAUDE.md + docs/full-saga-deployment-plan.md (D3).
var messagingTransport = builder.Configuration["Messaging:Transport"] ?? "rabbitmq";
var useAzureServiceBus = string.Equals(messagingTransport, "azureservicebus", StringComparison.OrdinalIgnoreCase);

IResourceBuilder<IResourceWithConnectionString> messaging;
if (useAzureServiceBus)
{
// Azure Service Bus emulator (Aspire 13 needs explicit .RunAsEmulator()). Topic/subscription
// topology declared here; subscription naming `{consumer}-{source-events}-sub` (globally unique
// within the namespace). Must match each service's ListenToAzureServiceBusSubscription("{sub}",
// c => c.TopicName = "{topic}") — separate args, NOT a combined "{topic}/{sub}" string. See #148.
var serviceBus = builder.AddAzureServiceBus("messaging").RunAsEmulator();

var orderEventsTopic = serviceBus.AddServiceBusTopic("order-events");
orderEventsTopic.AddServiceBusSubscription("payment-orders-sub"); // PaymentService consumes
orderEventsTopic.AddServiceBusSubscription("notify-orders-sub"); // NotificationService consumes

var paymentEventsTopic = serviceBus.AddServiceBusTopic("payment-events");
paymentEventsTopic.AddServiceBusSubscription("order-payments-sub"); // OrderService consumes
paymentEventsTopic.AddServiceBusSubscription("shipping-payments-sub"); // ShippingService consumes
paymentEventsTopic.AddServiceBusSubscription("notify-payments-sub"); // NotificationService consumes

var shippingEventsTopic = serviceBus.AddServiceBusTopic("shipping-events");
shippingEventsTopic.AddServiceBusSubscription("order-shipping-sub"); // OrderService consumes
shippingEventsTopic.AddServiceBusSubscription("notify-shipping-sub"); // NotificationService consumes

serviceBus.AddServiceBusQueue("send-notification"); // direct command queue

messaging = serviceBus;
}
else
{
// RabbitMQ: one container + the management UI (a demo artifact at :15672). Wolverine declares
// the exchanges/queues/bindings itself via AutoProvision against the live broker, so no
// hand-declared topology is needed here (contrast the ASB branch above).
messaging = builder.AddRabbitMQ("messaging").WithManagementPlugin();
}

// Application Insights only when running in Publish mode (i.e. real deploys to Azure).
// Aspire 13 has no local emulator for App Insights — keeping this in for local dev causes
Expand Down Expand Up @@ -114,48 +127,46 @@ static IResourceBuilder<ProjectResource> WithOptionalAppInsights(
.WithReference(realm, configurationPrefix: keycloakConfigPrefix).WaitFor(realm)
.WithEnvironment("Frontend__AllowedOrigins", SpaDevOrigin);

// Wolverine's AutoProvision() uses the Service Bus *management* API (ServiceBusAdministrationClient)
// to create/verify topics + subscriptions at host startup. The Service Bus emulator does NOT
// implement the management API — only the AMQP data plane — so AutoProvision retries, times out,
// and the host dies with `BrokerInitializationException: Unable to initialize the Broker asb in
// time`. Locally the topology is already declared above (AddServiceBusTopic/AddServiceBusSubscription
// write the emulator's config), so provisioning is both impossible AND redundant. Disable it for
// the four Wolverine services in dev; in Publish mode against real Azure, AutoProvision stays on
// (Wolverine:AutoProvision defaults true) to create the entities. Mirrors the test harnesses'
// `Wolverine:AutoProvision=false`. See CLAUDE.md.
const string disableAutoProvision = "Wolverine__AutoProvision";
// Wires a Wolverine service to the selected broker: connection-string reference + WaitFor (Aspire 13
// needs an explicit WaitFor for health) + the Messaging:Transport flag so the service picks the
// matching transport branch in its Program.cs. For the ASB *emulator* only, also disable Wolverine
// AutoProvision — its subscription admin endpoints return HTTP 500 (see #148). RabbitMQ keeps
// AutoProvision on and provisions its exchanges/queues against the live broker. See CLAUDE.md.
IResourceBuilder<ProjectResource> WithMessaging(IResourceBuilder<ProjectResource> project)
{
project = project.WithReference(messaging).WaitFor(messaging)
.WithEnvironment("Messaging__Transport", messagingTransport);
if (useAzureServiceBus)
{
project = project.WithEnvironment("Wolverine__AutoProvision", "false");
}

return project;
}

// OrderService also references catalogService — that gives it the gRPC client config to call
// into Catalog for product validation during order placement.
var orderService = WithOptionalAppInsights(
var orderService = WithMessaging(WithOptionalAppInsights(
builder.AddProject<Projects.OrderService>("order-service")
.WithReference(ordersDb).WaitFor(ordersDb)
.WithReference(serviceBus).WaitFor(serviceBus)
.WithReference(catalogService).WaitFor(catalogService), appInsights)
.WithReference(realm, configurationPrefix: keycloakConfigPrefix).WaitFor(realm)
.WithEnvironment("Frontend__AllowedOrigins", SpaDevOrigin)
.WithEnvironment(disableAutoProvision, "false");
.WithEnvironment("Frontend__AllowedOrigins", SpaDevOrigin));

WithOptionalAppInsights(
WithMessaging(WithOptionalAppInsights(
builder.AddProject<Projects.PaymentService>("payment-service")
.WithReference(paymentsDb).WaitFor(paymentsDb)
.WithReference(serviceBus).WaitFor(serviceBus), appInsights)
.WithReference(realm, configurationPrefix: keycloakConfigPrefix).WaitFor(realm)
.WithEnvironment(disableAutoProvision, "false");
.WithReference(paymentsDb).WaitFor(paymentsDb), appInsights)
.WithReference(realm, configurationPrefix: keycloakConfigPrefix).WaitFor(realm));

WithOptionalAppInsights(
WithMessaging(WithOptionalAppInsights(
builder.AddProject<Projects.ShippingService>("shipping-service")
.WithReference(shippingDb).WaitFor(shippingDb)
.WithReference(serviceBus).WaitFor(serviceBus), appInsights)
.WithReference(shippingDb).WaitFor(shippingDb), appInsights)
.WithReference(realm, configurationPrefix: keycloakConfigPrefix).WaitFor(realm)
.WithEnvironment("Frontend__AllowedOrigins", SpaDevOrigin)
.WithEnvironment(disableAutoProvision, "false");
.WithEnvironment("Frontend__AllowedOrigins", SpaDevOrigin));

// NotificationService is stateless — no DB reference, just messaging + telemetry.
WithOptionalAppInsights(
builder.AddProject<Projects.NotificationService>("notification-service")
.WithReference(serviceBus).WaitFor(serviceBus), appInsights)
.WithEnvironment(disableAutoProvision, "false");
WithMessaging(WithOptionalAppInsights(
builder.AddProject<Projects.NotificationService>("notification-service"), appInsights));

// --- Frontend ---
// Storefront and SellerPortal reference the API services so service-discovery resolves
Expand Down
1 change: 1 addition & 0 deletions NextAurora.AppHost/NextAurora.AppHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="Aspire.Hosting.PostgreSQL" />
<PackageReference Include="Aspire.Hosting.SqlServer" />
<PackageReference Include="Aspire.Hosting.RabbitMQ" />
<PackageReference Include="Aspire.Hosting.Redis" />
<PackageReference Include="Aspire.Hosting.Azure.ServiceBus" />
<PackageReference Include="Aspire.Hosting.Azure.ApplicationInsights" />
Expand Down
1 change: 1 addition & 0 deletions NotificationService/NotificationService.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="WolverineFx" />
<PackageReference Include="WolverineFx.AzureServiceBus" />
<PackageReference Include="WolverineFx.RabbitMQ" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../NextAurora.ServiceDefaults/NextAurora.ServiceDefaults.csproj" />
Expand Down
Loading
Loading