Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-context-map-the-compiler-drew && read --section="top" 0%
Architecture

The Context Map the Compiler Drew

A repeatable twenty-minute method for deriving a real context map from the project graph and the event subscriptions - then diffing it against the one on the wall.

By Kumar Chandrachooda 17 Nov 2025 8 min read
Two overlaid graphs, one drawn and one derived, with the mismatched edges highlighted

A context map on a wall is a hypothesis. The compiler holds the only version of it that cannot lie, and it will hand you that version for free — it is already computing it every time you build. All you have to do is ask.

Part 3 read ADR 03's declared map: four Open Host Service relationships, one Partnership, one Supplier-Customer, three Conformists and two anti-corruption layers. This part derives the map the code actually implements and diffs the two. The technique matters more than the verdict, so I will give you the method first and the findings second, and you can run it on your own estate this afternoon.

The method, in three passes

Pass one: the structural edges. Every ProjectReference between two modules is a compile-time dependency, and compile-time dependencies are directional by definition. Extract them all and throw away the intra-module ones.

Get-ChildItem -Recurse -Filter *.csproj |
  ForEach-Object {
    $from = $_.BaseName
    Select-Xml -Path $_.FullName -XPath '//ProjectReference/@Include' |
      ForEach-Object { [pscustomobject]@{ From = $from; To = [IO.Path]::GetFileNameWithoutExtension($_.Node.Value) } }
  } |
  Where-Object { $_.From.Split('.')[1] -ne $_.To.Split('.')[1] }

That one pipeline is the whole structural half. It emits From/To pairs across module boundaries and nothing else. Feed it to a DOT file if you want a picture; I usually just sort it and read it.

Pass two: the message edges. Compile-time references are only half a context map, because a publish-subscribe channel creates a runtime dependency that the project graph shows backwards — a subscriber references the publisher's contract assembly, so the arrow in the build points the opposite way to the arrow in the data flow. Two greps close the gap: one for every class implementing the handler interface, one for every publish call site.

Select-String -Path (Get-ChildItem -Recurse -Filter *.cs) -Pattern 'IEventHandler<(\w+)>'
Select-String -Path (Get-ChildItem -Recurse -Filter *.cs) -Pattern 'Publish(Async|Multiple)'

Join those two result sets on the event type and you have the publish/consume matrix. In GroupFlights that is fourteen handler classes across five modules and sixteen files containing a publish call.

Pass three: the direction of initiative. For each edge, ask ADR 03's own question — who is the active party? A synchronous call through a module API means the caller is active. An event subscription means the subscriber is active. An edge with initiative in both directions is a partnership whether you drew one or not.

Now overlay. Every edge on the drawn map should appear in the derived one with the same direction and the same pattern; every edge in the derived map should appear on the drawing. The mismatches are your findings.

Finding one: the Conformist that is a cycle

The drawn map has a single red CONFORMS line from Backoffice to Sales. Pass one returns two edges:

GroupFlights.Backoffice.Core  ->  GroupFlights.Sales.Shared
GroupFlights.Sales.Domain     ->  GroupFlights.Backoffice.Shared

Backoffice references Sales' contract assembly because Backoffice.Core/EventHandlers/ContractGenerationRequestedEventHandler.cs subscribes to Sales' ContractGenerationRequestedIntegrationEvent. That is the Conformist edge, and it is exactly as drawn. But Sales references Backoffice's contract assembly too, because Sales.Domain/Reservations/DomainServices/ReservationConfirmationDomainService.cs:38 calls IBackofficeApi.GetAvailableCashiersUserIds. Sales asks Backoffice for its configuration: cashier buffer hours, ticket fees, offer validity windows, the available cashier list.

At module granularity that is a cycle. Backoffice is simultaneously Sales' event conformist and Sales' configuration supplier, and a single unlabelled red line hides both halves. Nobody violated anything — no reference reaches past a <Module>.Shared project, which is part 6's subject and the estate's genuine achievement. The map is simply one label short. Two initiatives in opposite directions across the same boundary is the definition of what the map calls Partnership everywhere else.

The second half of that finding is where it lives. Sales.Domain — the innermost layer of a Clean Architecture module — is the project holding the reference. A domain service performs I/O against another bounded context. I will present that as a question rather than a verdict, because there is a defensible reading: the cashier list is a domain input to confirming a reservation, and pushing it up to the application layer would mean the domain service takes a pre-fetched list and loses the ability to state its own requirement. The undefended part is that the dependency is on a foreign context's interface rather than a Sales-owned port, and the price is visible in Postsale's mirror image — Postsale.UnitTests/Changes/ReservationChangeRequestTests.cs cannot construct its aggregate without Substitute.For<ISalesApi>(). You cannot unit-test one bounded context's domain without stubbing another's.

Finding two: the Partnership that is one-way RPC

The map draws Sales ↔ Inquiries in green, PARTNERSHIP, the only such edge in the estate. The derived map:

GroupFlights.Inquiries.Core  ->  GroupFlights.Sales.Shared
GroupFlights.Inquiries.Core  ->  GroupFlights.WorkloadManagement.Shared

One edge, one direction. InquiryService.cs:63 calls _salesApi.CreateOfferDraft(request, cancellationToken) and that is the entirety of the relationship. Sales does not reference Inquiries at all. Pass two is more damning than pass one: Inquiries publishes zero events and subscribes to zero events. It is a pure upstream caller.

A partnership is a two-way commitment. What the code implements is Customer-Supplier with Inquiries as the customer and a synchronous RPC as the only channel. To be fair to the drawing, the organisational claim may still be true — the same two people may well design both models together, and Evans' patterns describe team relationships, not call graphs. But if the design intent was mutual evolution, nothing in the code holds anyone to it, and the label is the only place that intent exists.

There is a second-order finding lurking in that one call site, and it is part 12's territory: AcceptInquiry writes to the sales schema through the module API and then writes to the inquiries schema through its own repository, in that order, with no transaction anywhere. The remote write happens first. If the local write fails, the offer draft exists and the inquiry does not know it was accepted.

Finding three: Conformist where the map says Open Host

Sales.Application references TimeManagement.Shared, Finance.Shared and WorkloadManagement.Shared. Structurally that matches the drawn OHS + PL edges — Sales is the active party, calling the published API of each supporting context, exactly as ADR 03 requires.

Look at what the adaptation costs, though. DeadlineRequestedIntegrationEventHandler in Sales maps Sales' own DeadlineRequestedIntegrationEvent onto TimeManagement's SetUpDeadlineDto with essentially zero translation loss, because the two shapes were designed to match. The ADR permits this: adapt to the module's contract, “optionally closing the complexity of the remapping on our side”. There is no complexity to close, so there is no anti-corruption layer.

And then the leak. TimeManagement's published DTO is:

public record SetUpDeadlineDto(
    DeadlineId Id,
    CommunicationChannel CommunicationChannel,
    DeadlineParticipantDto[] Participants,
    Message Message,
    DateTime DeadlineDateUtc);

SetUpDeadlineDto.cs:6-11, GroupFlights at commit a19b337. CommunicationChannel and Message are not TimeManagement types. They belong to Communication, a third bounded context. So when Sales conforms to TimeManagement's published language, it transitively conforms to Communication's as well — and the derived project graph shows the transitive edge plainly: TimeManagement.Shared -> Communication.Shared. Three of the seven contract projects reference another contract project. That is a published language chain, and it is not on the map. Part 9 follows Message all the way to where it ends up: a column in a schema its owning module has never heard of.

Finding four: an edge on the map with no code at all

The map's Sales ↔ Workload Management edge is drawn OHS + PL like the other three. The derived structural edge exists — Sales.Application -> WorkloadManagement.Shared — and it is dead. The decorator that would call CanAccessWorkload short-circuits on a local var enabled = false; at WorkloadManagementEnforcementCommandHandlerDecorator.cs:30, and everything below it is unreachable. Meanwhile Inquiries enforces the identical rule for real. That contradiction gets part 14 to itself.

The method, not the verdict

Here is the derived map beside the drawn one, which is the artefact worth producing:

Edge Drawn Derived Verdict
Sales ↔ Time Management OHS + PL Conformist, no ACL, transitive PL from Communication shape holds, chain undocumented
Sales ↔ Finance OHS + PL Conformist, one call holds
Sales ↔ Communication OHS + PL Shared kernel in practice — Message lives in Sales' aggregates stronger coupling than drawn
Sales ↔ Workload Management OHS + PL edge exists, code disabled dead
Sales ↔ Inquiries Partnership one-way RPC, Inquiries active not a partnership
Sales ↔ Post-Sales Supplier / Customer exactly as documented textbook
Backoffice → Sales Conformist cycle one label short
Finance → Payment Gate ACL real port and adapter, contract copied textbook
Communication → Email Service ACL one LogInformation call not built

Four of nine edges match. That is a better hit rate than any estate I have personally shipped, and the misses are informative rather than embarrassing — a chain nobody noticed, a label that describes an intention rather than a mechanism, a feature switched off.

Run this against your own repository before you argue about the boxes. The derived map takes twenty minutes, needs no meetings, and it settles questions that otherwise get settled by whoever is most senior in the room. Then put both maps in the same document, because the drawn one is still worth having — it records what you meant, and the diff is a to-do list.

Three of the boxes on this map produced no edges at all, because no code was ever written for them. Next, three boxes that were never built.