The Only Legal Edge Between Modules
Seven contract projects are the estate's entire cross-module surface - and one misplaced marker interface drags the dispatcher framework into two of them by copy-paste.
Nothing decays faster than a shared project without an admission policy. It starts as three DTOs everybody genuinely needs, acquires a helper class, acquires the helper's dependency, and eighteen months later “Common” is a 40,000-line assembly that every module references and nobody may change. The failure is never a bad decision; it is the absence of a decision about what may go in.
Part 6 showed that GroupFlights' cross-module boundary held perfectly across thirty-five projects. This part is about the thing on the other side of that boundary — the seven contract projects that constitute the estate's entire legal cross-module surface — and about the one thing that got in without an admission policy.
Seven projects, under four hundred lines
The whole published surface of an eight-module system fits in a single sitting. Here it is in full:
| Contract project | Public API | DTOs / value types | Integration events |
|---|---|---|---|
Sales.Shared |
ISalesApi — 3 operations |
14 records | 6 |
Postsale.Shared |
none | none | 3 |
Backoffice.Shared |
IBackofficeApi — 4 operations |
3 config DTOs | 1 |
TimeManagement.Shared |
ITimeManagementApi — 3 operations |
DeadlineId + 2 operation DTOs |
1 |
Finance.Shared |
IFinanceApi — 1 operation |
SetupPaymentDto |
2 |
Communication.Shared |
ICommunicationApi — 2 operations |
Message, CommunicationChannel |
0 |
WorkloadManagement.Shared |
IWorkloadManagementApi — 1 operation |
WorkloadAccessCheck |
0 |
Fifteen operations and thirteen event types is the entire vocabulary in which eight bounded contexts address each other. The single most valuable property of this design is that the surface is small enough to read. A reviewer asked “what can Finance do to the rest of the system?” opens one project, finds one interface with one method and two event records, and is done in ninety seconds.
Three conventions do the structural work, and only one of them is written down anywhere.
The interface lives in .Shared, the implementation lives in ModuleApi/. IBackofficeApi is declared in Backoffice.Shared/IBackofficeApi.cs and implemented in Backoffice.Core/ModuleApi/BackofficeApi.cs, internal. Six of eight modules have such a folder. Consumers get the interface through DI and cannot reach the class.
Postsale deliberately has no public API. It is the only module with an empty API surface, and that is ADR 04 being honoured: the recommendation was "maximum limitation of the public API (including the data model)", and nothing outside Postsale can command it. Part 10 scores that ADR properly, including the part where the data model leaks anyway.
Event handlers are foldered by provenance. Sales and Postsale both split EventHandlers/Internal from EventHandlers/External — internal meaning the event came from this module, external meaning another module published it. That is a small convention with a large payoff: the folder name tells a reviewer whether the handler is orchestrating our own workflow or reacting to somebody else's, which is the single most useful thing to know about an event handler. It also makes an uncomfortable fact countable, which is part 8's subject.
The shared kernel, split in two
Underneath the seven contract projects sit two genuinely shared assemblies, and the split between them is where the trouble starts.
Shared.Types is the shared kernel proper — the vocabulary every context agrees on. Money with a Currency and a mismatch guard on addition. Airport and IataAirportCode with a three-character invariant. Email with a format regex. UserId, CashierId, PhoneNumber. IClock and UtcClock, which is ADR 02's abstraction. A Specification<T> combinator library. A HumanPresentableException hierarchy with a category enum that the host maps to HTTP status codes. And two marker interfaces:
// Marker
public interface IEvent
{
}
Shared.Types/Events/IEvent.cs, and ICommand beside it in Commands/. Both empty, both in the types assembly. That is the right home for them: a published event contract needs to be markable without dragging in a dispatcher.
Shared.Plumbing is the mini-framework — command dispatcher, query dispatcher, event dispatcher, the AddSharedFramework() composition method, the EF/Postgres registration helpers, the user-context abstractions. It references Shared.Types and it is emphatically not a contract assembly.
And then, in Shared.Plumbing/Queries/IQuery.cs:
//Marker
public interface IQuery
{
}
public interface IQuery<T> : IQuery
{
}
ICommand and IEvent are in Types. IQuery<T> is in Plumbing. One marker interface on the wrong side of the line.
What one misplaced marker costs
Follow it. Sales.Shared publishes a query as part of its contract, because Postsale needs to fetch a reservation before proposing a change to it:
using GroupFlights.Shared.Plumbing.Queries;
namespace GroupFlights.Sales.Shared.Changes;
public record GetReservationForChangeQuery(Guid ReservationId) : IQuery<ReservationToChangeDto>;
Sales.Shared/Changes/GetReservationForChangeQuery.cs, GroupFlights at commit a19b337. That using is the whole problem. To compile one record in the contracts project, Sales.Shared.csproj must reference Shared.Plumbing — and Shared.Plumbing is the assembly containing the event dispatcher, the command dispatcher and the DI extension methods. The published contract surface of the core module now carries the internal framework as a transitive dependency.
Every consumer of Sales.Shared inherits it. Postsale.Domain — the innermost layer of a Clean Architecture module — references Sales.Shared, and therefore has the dispatcher framework on its compile-time closure. Backoffice.Core and Inquiries.Core likewise. Nothing breaks. The types are never used from those places. But the dependency graph now says something untrue about the system: that the contract is coupled to the transport.
Then it spreads by copy. Finance.Shared.csproj and TimeManagement.Shared.csproj also reference Shared.Plumbing, and neither needs it. The only trace of why is a dangling using at the top of three files:
using GroupFlights.Shared.Plumbing.Events;
using GroupFlights.Shared.Types.Events;
namespace GroupFlights.Finance.Shared.Events;
public record PaymentCompleted(Guid PaymentId) : IEvent;
Finance.Shared/Events/PaymentCompleted.cs, and the identical pattern in PaymentCanceled.cs and TimeManagement.Shared/Events/DeadlineOverdueIntegrationEvent.cs. The first using resolves nothing — IEvent comes from Shared.Types.Events, on the line below. Somebody created the file, the IDE offered a namespace, and the project reference was added to make the offered namespace resolve. Two contract projects carry an unnecessary framework dependency because of a using statement that does nothing.
Read it slowly, because the failure is not the file — the file is a two-line record and it is fine. The failure is that nothing in the process notices a contract project growing a framework reference. No test asserts it, no review checklist mentions it, and the compiler is perfectly happy: unnecessary references always compile.
The admission policy, written down
The fix for the specific case takes ten minutes: move IQuery and IQuery<T> to Shared.Types/Queries/, alongside ICommand and IEvent, leave the dispatcher and the handler interfaces in Plumbing, and delete three project references and three usings. Nothing else changes. The marker belongs with the other markers; the machinery belongs with the other machinery.
The fix for the class of problem is a rule, and this is the one I would put in an ADR:
A
<Module>.Sharedproject may referenceShared.Typesand other<Module>.Sharedprojects. Nothing else. Ever.
One sentence, and it is machine-checkable in about the same number of lines as the boundary test from part 6:
[Fact]
public void Contract_projects_depend_only_on_types_and_other_contracts()
{
var offenders = ProjectGraph.Edges()
.Where(e => e.From.EndsWith(".Shared"))
.Where(e => e.To != "GroupFlights.Shared.Types" && !e.To.EndsWith(".Shared"))
.ToList();
offenders.Should().BeEmpty("a published contract must not depend on infrastructure");
}
Fresh illustrative code; the estate has no such test. Run against GroupFlights it would fail three times on the first execution and never again.
The second clause of that rule — “and other <Module>.Shared projects” — is the permissive one, and it is permissive on purpose. Three contract projects reference another contract project here: Sales.Shared → Communication.Shared, TimeManagement.Shared → Communication.Shared, and Postsale.Shared → Sales.Shared and Communication.Shared. That is a published-language chain, and it is legitimate — a deadline event genuinely carries a message, and a message is Communication's word. But a chain is a coupling with a name, and one link of it ends somewhere nobody expected.
Next, the count that makes the whole surface look different: your contract project is not your contract.