eshop-full — event bus & integration events

One publish call, three decorators deep — and the bug hiding in the middle one.

Services integrate through published/consumed events, not direct calls — EventBus defines the abstraction (IEventBus), EventBusRabbitMQ is the only implementation today, and IntegrationEventLogEF (done) gives every publisher a transactional outbox instead of a fire-and-forget publish — not wired into a consumer yet, since no service that would call it exists on disk. Reviewing RabbitMQEventBus closely enough to split it turned up two real bugs, both present verbatim in Microsoft's own reference source. See System Architecture for where this foundation layer sits relative to everything still to come.

Publish Path

Ordering.API (planned) IEventBus.PublishAsync ResilientEventBusDecorator TelemetryEventBusDecorator RabbitMQEventBus RabbitMQ broker

Three separate IEventBus implementations, composed by wrapping — a caller only ever sees IEventBus, and doesn't know or care how many layers are behind it.

Zoomed In

Outer layer

ResilientEventBusDecorator — Polly retry, now genuinely async

Wraps the inner bus in a Polly ResiliencePipeline so transient failures (BrokerUnreachableException, SocketException) get retried before the caller ever sees them. This is the layer that had to be rebuilt to fix bug #1 below — see Bug: retry pipeline never retried.

Middle layer

TelemetryEventBusDecorator — OpenTelemetry tracing

Starts an activity named "{EventTypeName} publish" with the OpenTelemetry messaging semantic-convention tags applied, tags any exception onto that activity, and rethrows rather than swallowing it. RabbitMQEventBus.PublishAsync's own context-propagation now sources its ActivityContext from this layer's ambient Activity.Current (AsyncLocal-backed, flows through the await chain automatically) instead of a locally-created activity threaded manually through nested lambdas.

Inner layer

RabbitMQEventBus — transport only

Connection/channel management, the real BasicPublishAsync call, and consumer wiring — nothing else. Splitting telemetry and resilience out of this class is what surfaced both bugs below in the first place: each concern is small enough now to read closely and get right on its own, where before it was one class doing three unrelated jobs.

The Bugs This Split Found

Flagship bug

The retry pipeline never actually retried

PublishAsync ran its publish step through _pipeline.Execute(async () => {...}) — the synchronous Execute<TResult> overload with TResult inferred as Task, confirmed by reflecting the real Polly.Core 8.6.6 assembly rather than assumed from documentation. It invoked the lambda once and treated the returned Task object itself as the outcome, without awaiting it — so any exception thrown after the lambda's first await (exactly where channel.BasicPublishAsync would throw) happened after Execute had already returned. The retry pipeline's ShouldHandle never observed it.

Before — inert

_pipeline.Execute(async () => { await channel.BasicPublishAsync(...); }); // exception after the await // never reaches ShouldHandle

After — genuinely retries

await _pipeline.ExecuteAsync( static async (state, ct) => await state.channel .BasicPublishAsync(...), state, cancellationToken);
Second bug

A null-conditional that made its own error path unreachable

(await _rabbitMQConnection?.CreateChannelAsync()) ?? throw new InvalidOperationException("RabbitMQ connection is not open") looks like it handles a closed connection gracefully. It doesn't — the ?. short-circuits the entire parenthesized expression to a null Task<IChannel> when _rabbitMQConnection is null, and awaiting a null task throws NullReferenceException immediately — so the ?? throw fallback was dead code. A caller hitting this path during startup (before StartAsync finishes setting the connection) got an opaque NRE instead of the intended message. Fixed with an explicit if (_rabbitMQConnection is null) throw ... before the await.

The Outbox (done, not wired up yet)

IntegrationEventLogEF

Domain write and event-to-publish, one transaction

All 7 source files reviewed, but not wired up yet — no service that would call it exists on disk. The target shape: a publishing service writes its domain change and an IntegrationEventLogEntry row in the same database transaction, so a crash between "domain write committed" and "event published" can't lose the event — a separate process picks up NotPublished-state rows and ships them to IEventBus after the fact. The same cast-can-return-null bug already fixed once in RabbitMQEventBus.DeserializeMessage recurred in IntegrationEventLogEntry.cs's own JSON deserialization — caught the second time because the first fix was still fresh.

i

Neither bug above was provable end-to-end until the Decorator split made the resilience layer testable on its own — no real RabbitMQ broker exists yet (no eShop.AppHost). See Testing Strategy for how a fake inner IEventBus finally closed that gap.