The Same File in Seven Repos
MessageBroker.cs exists in seven Pacco repositories and hashes identically once you substitute the service name - this part inventories the copied glue, finds the double registrations hiding in seventeen-call chains, and weighs copy-paste against a shared kernel.
Part 1 claimed that Convey's real cost lives in the glue it leaves to every consumer. Claims like that deserve measurement, so I measured. I took every service repository in the Pacco estate, normalised the one thing that legitimately differs between them — the service name embedded in namespaces — and hashed the infrastructure files. This part is the inventory that fell out, and it is the spine of the whole series: the estate's most load-bearing class is not in the framework, not in a shared package, and not in one place — it is in seven.
Eighty-seven lines, seven times
MessageBroker.cs lives in the Infrastructure/Services folder of Availability, Customers, Deliveries, Identity, Orders, Parcels, and Vehicles — 87 lines in each. Raw hashes differ, because Pacco.Services.Deliveries.Infrastructure.Services is not Pacco.Services.Vehicles.Infrastructure.Services; substitute the service name and normalise line endings, and all seven copies produce the same md5. Not “similar”. Identical.
Here is the half of it that earns the description “load-bearing”, from the Deliveries copy:
// Pacco.Services.Deliveries.Infrastructure/Services/MessageBroker.cs (Convey 0.4.* era)
public async Task PublishAsync(IEnumerable<IEvent> events)
{
if (events is null)
{
return;
}
var messageProperties = _messagePropertiesAccessor.MessageProperties;
var originatedMessageId = messageProperties?.MessageId;
var correlationId = messageProperties?.CorrelationId;
var spanContext = messageProperties?.GetSpanContext(_spanContextHeader);
if (string.IsNullOrWhiteSpace(spanContext))
{
spanContext = _tracer.ActiveSpan is null ? string.Empty : _tracer.ActiveSpan.Context.ToString();
}
var headers = messageProperties.GetHeadersToForward();
var correlationContext = _contextAccessor.CorrelationContext ??
_httpContextAccessor.GetCorrelationContext();
foreach (var @event in events)
{
if (@event is null)
{
continue;
}
var messageId = Guid.NewGuid().ToString("N");
_logger.LogTrace($"Publishing integration event: {@event.GetType().Name} [id: '{messageId}'].");
if (_outbox.Enabled)
{
await _outbox.SendAsync(@event, originatedMessageId, messageId, correlationId, spanContext,
correlationContext, headers);
continue;
}
await _busPublisher.PublishAsync(@event, messageId, correlationId, spanContext, correlationContext,
headers);
}
}
Read what this class actually does, because it explains why every service needs it. It bridges four Convey abstractions that ship separately — IBusPublisher, IMessageOutbox, the correlation-context accessor, and the OpenTracing tracer — and adds the estate's own conventions on top: the originated-message id, the span context fallback, the outbox-or-direct switch per event, and GetHeadersToForward(), which forwards exactly one header, "Saga", so that an orchestrator's state can ride every hop of every conversation. Convey 0.4.* gives you the publisher and the outbox as interfaces; it does not give you the place where messaging metadata meets both. So every consumer wrote that place — or rather, one consumer wrote it and six pasted it.
One line deserves a second look before we move on: if (@event is null) continue;. That null-skip is not defensive noise — it is estate-wide policy. Every aggregate service maps domain events to integration events through an EventMapper that returns null for anything it does not recognise, and this loop silently swallows those nulls. Rename a domain event, forget the mapper, and the event simply stops being published — no exception, no log line above Trace, no test failure. Whether that is a reasonable trade-off is arguable; what is not arguable is that the policy is now pinned in seven separate files, so changing it — say, to throw on an unmapped event — is seven coordinated edits, none of which any compiler will demand. I traced the outbox half of this seam in the Convey series itself in two outboxes, one interface; Pacco is where you see what consumers do with the gap.
The rest of the family
MessageBroker.cs travels with an entourage, and the hashes tell you how each member was distributed:
| File | Copies | Identical after name substitution? |
|---|---|---|
MessageBroker.cs |
7 | yes — all seven |
OutboxCommandHandlerDecorator.cs |
7 | yes — all seven |
OutboxEventHandlerDecorator.cs |
7 | yes — all seven |
ContractAttribute.cs |
7 | yes — all seven |
ExceptionToResponseMapper.cs |
10 | seven identical; Operations, OrderMaker, Pricing drifted |
AppContextFactory.cs |
7 | six identical; Orders differs |
CorrelationContext.cs |
9 | six identical; Availability, Operations, OrderMaker differ |
The drift is the interesting column, so I diffed the outliers. Orders' AppContextFactory differs from the other six by trailing whitespace on one line. Availability's CorrelationContext differs by a single sealed keyword. That is not divergence with intent; that is proof of copy-paste-then-touch distribution — someone tidied one copy and the tidy-up never travelled. Today the differences are cosmetic. The mechanism that produced them has no way to keep it that way, and later in the series we will meet the copy that drifted semantically: an AggregateRoot base class that bumps its version in one service and never does in two others, same name, three meanings. The correlation classes have their own arc on the estate side of the story — the copied spine is S2's territory — but the distribution mechanism is this one, everywhere.
Where duplicates go to hide
The second place the tax shows up is the composition root. AddInfrastructure() in Deliveries is seven plain service registrations followed by a seventeen-call fluent chain. Vehicles has the same chain — with one extra call. Look closely:
// Pacco.Services.Vehicles.Infrastructure/Extensions.cs
return builder
.AddErrorHandler<ExceptionToResponseMapper>()
.AddQueryHandlers()
.AddInMemoryQueryDispatcher()
.AddHttpClient()
.AddConsul()
.AddFabio()
.AddRabbitMq(plugins: p => p.AddJaegerRabbitMqPlugin())
.AddMessageOutbox(o => o.AddMongo())
.AddExceptionToMessageMapper<ExceptionToMessageMapper>()
.AddMongo()
.AddRedis()
.AddMetrics()
.AddJaeger()
.AddMongo() // <- again
.AddHandlersLogging()
.AddMongoRepository<VehicleDocument, Guid>("vehicles")
.AddWebApiSwaggerDocs()
.AddSecurity();
.AddMongo() appears twice, ten lines apart. Operations does the same thing with .AddRedis(). Both are harmless at runtime — the registrations are idempotent in practice — and both are damning at review time, because they demonstrate that nobody reads a twenty-line fluent chain; they pattern-match its shape and move on. The chain is too fine-grained for consumers who always want the same eighteen things, and too boilerplate-shaped for anyone to spot an intruder in it. A framework-level meta-package — call it Convey.Bootstrap, “the standard Pacco stack in one call” — would have deleted seventeen lines from ten files and made a duplicate registration impossible to write. It never existed, so every service carries the chain, and two of them carry a typo fossilised since whenever the paste happened.
The part they got right
Fairness requires the counter-example, because one piece of this copied infrastructure is genuinely excellent. The two outbox decorators are the estate's cross-cutting mechanism, and they are wired like this:
builder.Services.TryDecorate(typeof(ICommandHandler<>), typeof(OutboxCommandHandlerDecorator<>));
builder.Services.TryDecorate(typeof(IEventHandler<>), typeof(OutboxEventHandlerDecorator<>));
Scrutor's TryDecorate wraps every command and event handler in the service with outbox handling — inbox-style dedup keyed on the incoming message id — in two lines, with no handler knowing it happens. The decorator itself carries a [Decorator] marker attribute (from Convey.Types) so Convey's handler scan knows to skip it; without the marker, the scan would register the wrapper as a handler in its own right and the composition would eat itself. Open-generic decoration plus an opt-out marker is composition-root hygiene done properly, and it is the pattern I now steal for any estate that needs a cross-cutting wrap. It is also, of course, copied byte-for-byte into seven repositories — the estate's best idea and its distribution problem in one folder.
The tension, stated honestly
Why copy at all? Because the alternative is a shared kernel, and this team has lived that story. Their previous estate, DShop, kept its glue in a common library that every service referenced, and that library's gravity — every fix a coordinated upgrade, every service coupled to its release cadence — is what they escaped by distilling it into Convey as versioned packages. Pacco then takes the next step: whatever Convey doesn't own, each repo owns alone. Full autonomy: any service can change its glue without asking anyone.
The estate is the measured result of that stance. Autonomy was exercised approximately never — the copies agree to the byte — while the costs accrued anyway: a fix to MessageBroker is a seven-repo shotgun surgery, drift is structurally guaranteed and only cosmetic so far, and duplicate registrations survive indefinitely inside chains nobody reads. They paid for flexibility they never used, in a currency — synchronised edits across seven repositories — that gets more expensive every month. To be fair: for a teaching estate, making that price visible may be the point, and MIT-licensed honesty about it is worth more than a tidied-up fiction. But if you are building ten real services, the lesson reads the other way — glue that is identical across seven repos is a package begging to exist, and “we value repo autonomy” is only true if someone, ever, diverges on purpose.
The tail is now priced. Next, the other half of the bargain, taken seriously on its own terms: a service in thirty lines, where we walk the surface Convey genuinely compresses — controller-less endpoints, self-identifying commands, and a wire format you can predict from a class name.