Your Contract Project Is Not Your Contract
Seven of thirteen published integration events in GroupFlights have exactly one consumer - the module that published them - and one has none at all.
A published contract is a promise you cannot cheaply withdraw. The moment a type leaves your assembly, every field on it becomes a thing somebody may be reading, every rename becomes a coordinated release, and every addition becomes permanent. That cost is worth paying for the things other people genuinely consume. It is pure loss for the things nobody does.
Part 7 walked the seven <Module>.Shared projects that make up GroupFlights' entire cross-module surface and found them admirably small. This part counts who is actually on the other end of each promise, and the count changes how the surface reads.
The matrix
Thirteen integration event types are declared across five contract projects. Here is every one of them with its publisher and its consumers, derived by joining the IEventHandler<T> implementations against the publish call sites.
| Event | Published by | Consumed by |
|---|---|---|
OfferAcceptedIntegrationEvent |
Sales | Sales |
DeadlineRequestedIntegrationEvent |
Sales | Sales |
DeadlineMetIntegrationEvent |
Sales | Sales |
PaymentRequestedIntegrationEvent |
Sales | Sales |
ContractGenerationRequestedIntegrationEvent |
Sales | Backoffice |
ReservationChangesAppliedIntegrationEvent |
Sales | Postsale |
ContractSignedIntegrationEvent |
Backoffice | Sales |
PaymentCompleted |
Finance | Sales + Postsale |
PaymentCanceled |
nobody | nobody |
DeadlineOverdueIntegrationEvent |
TimeManagement | Sales + Postsale |
PostsalePaymentRequestedIntegrationEvent |
Postsale | Postsale |
PostsaleDeadlineRequestedIntegrationEvent |
Postsale | Postsale |
ReservationChangeAcceptedIntegrationEvent |
Postsale | Postsale |
Seven of thirteen have exactly one consumer, and it is the publisher. Four more have exactly one consumer somewhere else. Two are genuine publish-subscribe channels with multiple subscribers, and those two turn out to be precisely where the estate breaks — part 13 is that story. One has no publisher and no consumer at all.
What a self-consumed event is doing
Before criticising the count, look at what those handlers are for, because the pattern is deliberate and it is not silly.
Sales' domain layer raises domain events. The application layer translates them to integration events through a switch — DomainEventsRemapping.cs:14-25 maps eight domain event types to six integration types, with three of the eight fanning out to two integration events each — and publishes the results. One of those results is DeadlineRequestedIntegrationEvent, and Sales handles it itself:
public async Task HandleAsync(DeadlineRequestedIntegrationEvent @event, CancellationToken cancellationToken = default)
{
await _timeManagementApi.SetUpDeadline(new SetUpDeadlineDto(
new DeadlineId(@event.Id),
CommunicationChannel.EmailAndNotification,
@event.Participants.Select(p => new DeadlineParticipantDto(p.UserId, p.Email)).ToArray(),
@event.Message,
@event.DueDate),
cancellationToken);
await _deadlineRegistry.SaveMapping(
new DeadlineRegistryEntry(@event.Id, @event.Source.SourceType, @event.Source.SourceId), cancellationToken);
}
Sales.Application/EventHandlers/Internal/DeadlineRequestedIntegrationEventHandler.cs:21-33, GroupFlights at commit a19b337. Read what this achieves.
- The domain does not call TimeManagement. The aggregate says “a deadline is required” and stops. The outbound call to another bounded context happens in an application-layer handler, on the dispatcher's thread, after the transaction. That is exactly the layering Clean Architecture asks for.
- The correlation is recorded in the same place as the request.
SaveMappingwrites the deadline id against the aggregate that asked for it, because the overdue event that comes back later carries only a deadline id. Part 12 follows that registry and its two siblings. - The handler is in
EventHandlers/Internal/, so the folder name already tells you Sales published this to itself.
So the mechanism is sound. Sales is using the dispatcher as an internal decoupling device — a deferred, out-of-transaction continuation — and that is a legitimate use of an in-process bus. The README even advertises the delay as intentional, so that multiple modules do not unknowingly participate in one transaction.
The problem is the type's address, not its existence
Here is where it goes wrong, and it is a one-word problem: public.
DeadlineRequestedIntegrationEvent lives in Sales.Shared, the contract project. Every module that references Sales.Shared — Backoffice, Inquiries, Postsale, and transitively anything referencing those — can see it, subscribe to it, and depend on its shape. It is a published promise. And its only subscriber is the assembly that published it.
The consequences are precise:
- Every field on it is frozen for the wrong reason. Renaming
SourcetoRequestedByis a breaking change to a public contract that no external party consumes. Nobody will actually break, but nobody can know that without running the same matrix I just ran. - It advertises capability that does not exist. A developer building a ninth module reads
Sales.Shared, sees six integration events, and reasonably concludes Sales publishes six things worth reacting to. Four of them are internal plumbing. There is nothing in the type, the namespace or the folder to say so — only theEventHandlers/Internalconvention on the consuming side, which is in a different project. - It makes the estate look more event-driven than it is. Thirteen event types across an eight-module system suggests a busy choreography. The real cross-module event traffic is four types.
For Sales this is untidy. For Postsale it directly contradicts its own ADR. ADR 04's recommendation was, translated, “maximum limitation of the public API (including the data model)” — the emphasis is in the original. Postsale honours the first half beautifully: it is the only module with no I<Module>Api at all, so nothing outside can command it. And then it publishes three integration events into Postsale.Shared, all three consumed only by Postsale, and one of them is this:
public record ReservationChangeAcceptedIntegrationEvent(Guid ReservationId,
Guid ReservationChangeRequestId,
ChangeTravelDto ChangeTravel,
NewTotalCostDto CostAfterChange,
List<PaymentDeadlineChangeDto> PaymentDeadlineChanges,
PassengerNamesDeadlineChangeDto PassengerNamesDeadlineChange) : IEvent;
Postsale.Shared/IntegrationEvents/ReservationChangeAcceptedIntegrationEvent.cs:6-11. The full internal shape of an accepted change — the new itinerary, the new total and refundable cost, every shifted payment deadline — published to a channel nobody outside Postsale listens on. The module with the strictest encapsulation mandate in the repository publishes its richest payload to an empty room.
The record with nobody at either end
And then there is PaymentCanceled. A repository-wide grep for the identifier returns exactly one hit:
public record PaymentCanceled(Guid PaymentId) : IEvent;
Finance.Shared/Events/PaymentCanceled.cs:6. The declaration. No publisher, no handler, no test, no mention in any ADR or in the README. Its sibling PaymentCompleted is one of the two real broadcast channels in the estate; PaymentCanceled is the shape of a thought.
I am inclined to be generous about this one. A payments context that can complete a payment can obviously cancel one, and declaring the event next to its twin is the natural way to record that the case exists. To be fair to the authors, an unbuilt cancellation path in a teaching repository with a fake gateway that auto-pays after five seconds is not a defect — there is no way to cancel anything.
But it is published, and that is the recurring theme. A note in an issue tracker costs nothing to delete. A public record in a contract assembly is a promise, and this one has been made to nobody about something the system cannot do.
The rule I would write down
The estate already has a naming convention that gets this right on the consumer side. Extend it to the producer side and the whole problem disappears:
An event type belongs in
<Module>.Sharedonly if a handler outside<Module>subscribes to it. Everything else is an internal event and lives in the module's own assembly,internal.
Applied to GroupFlights that moves seven of thirteen types out of the contract projects and shrinks the published surface by more than half. Sales' contract goes from six event types to two — ContractGenerationRequested for Backoffice and ReservationChangesApplied for Postsale — and those two are exactly the ones its ADR-declared relationships predict. Postsale's contract goes to zero events, matching its zero-API surface and finally making ADR 04 true as written.
Nothing about the runtime changes. The dispatcher resolves handlers by reflection over IEventHandler<T> and does not care whether T is public. The internal continuation pattern survives intact. All that changes is that the contract project starts telling the truth, and the answer to “what does this module promise the rest of the system?” becomes readable without running a join across fourteen handler classes.
A contract project is not a place to put events. It is a place to put events other people consume. Everything else is your own workflow wearing a public modifier.
One of these published types does not stop at a promise. Next, the contract that became someone else's migration.