eshop-full — event bus & integration events
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.
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.
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.
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.
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 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);
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.
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.
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.