Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/a-gentlemans-agreement-across-thirty-five-projects && read --section="top" 0%
Architecture

A Gentleman's Agreement Across Thirty-Five Projects

ADR 01 chose a verbal ban on illegal project references over an automated architecture test - and the ban held perfectly, in an estate whose database boundary has no enforcement at all.

By Kumar Chandrachooda 19 Nov 2025 7 min read
A handshake standing in for the boundary a compiler was never asked to guard

Every modular monolith eventually faces the same question, usually about eighteen months in, usually in a pull request comment: “why can't module A just reference module B's repository directly? It's the same process.” The technical answer is that it can, trivially, in about four seconds. That is what makes the question dangerous. A boundary that costs nothing to cross needs something other than good intentions holding it.

Part 5 closed on the promises the estate did not keep. This one is about the promise it did.

The decision, and the option it declined

01-zaleznosci-pomiedzy-modulami.md — “Dependencies between modules” — is the first real ADR in the repository, reported by Michał Wilczyński and approved by Dariusz Pawlukiewicz. Its context section states the risk in one sentence, translated:

Without imposing any rules there is a technical possibility of creating references between modules that bypass their public API. This may lead to basing logic in module A on the implementation details of module B which — if they were not published in the API — should be able to change independently of their consumers.

It then considers two questions separately, which is the part I want to draw attention to, because most teams conflate them.

The first question is how modules communicate. Option 1: shared contracts — <Module>.Shared projects holding an interface representing the public API. Option 2: local contracts, where modules address each other over something HTTP-shaped, with serialisation for transport. The decision is Option 1, with the reason given as "small complexity (compared with enterprise-class systems)".

The second question is what stops somebody bypassing that public API. Three options are listed:

  1. A verbal prohibition on creating such references.
  2. An architecture test that uses reflection to detect whether the phenomenon occurs.
  3. Decomposing the solution into smaller per-module solutions, joined into one deployment artefact at build time.

The decision is Option 1 again, and the reasoning is explicit: because of the small number of developers building the system, the rules of collaboration should be maintainable without any additional solutions. It then names its own upgrade trigger — as the system grows and new developers or teams join, moving to Option 2 is recommended.

That is a competent decision record. It names the risk, enumerates real alternatives, picks the cheapest one that fits the current team size, and states the condition under which the choice expires. Most ADRs I have read do the first and the fourth badly and skip the third entirely.

The verdict: it held

So did the gentleman's agreement survive contact with thirty-five projects and 439 source files? Yes. Completely.

Extract every cross-module ProjectReference — the pipeline from part 4 does it in one pass — and every single one terminates at a <Module>.Shared project or at Shared.Types / Shared.Plumbing. Zero references from any module to another module's .Core, .Application, .Domain or .Infrastructure. Not one shortcut, in an estate where taking one would have compiled.

That is worth sitting with, because the sceptical reading is available and I want to dispose of it. Three commits, one squashed Init, a repository written to be read: of course it is clean. The rejoinder is that internal consistency of this kind is not free even when you are writing a demo. Sales alone has six projects; Postsale has six more. The cheapest way to make ReservationConfirmationDomainService get its cashier list would have been a reference to Backoffice.Core, and the author went and defined IBackofficeApi in Backoffice.Shared, implemented it in Backoffice.Core/ModuleApi/, and registered it — three files instead of one, for a value the module hardcodes anyway.

The convention that makes it hold is visible in the folder structure and never stated as a rule: the interface lives in <Module>.Shared, the implementation lives in <Module>.Core/ModuleApi/ or <Module>.Application/ModuleApi/. Six of eight modules have such a folder. Once that convention exists, the illegal shortcut is not just discouraged, it is awkward — you would have to import a namespace that no sibling module imports, in a file whose neighbours all do it the other way. Convention plus visible precedent is a surprisingly strong substitute for a test at small scale, and it is what ADR 01 is actually banking on.

The estate does leave one lever unpulled. ADR 01's ### Dodatkowa rekomendacja recommends internal on every type where possible, with [assembly: InternalsVisibleTo(...)] only for upward visibility inside a module. There is exactly one InternalsVisibleTo in all thirty-five projects, at Inquiries.Core/InternalsVisibleToThisModule.cs:3, and the public/internal ratios vary sharply by module — Sales declares 151 public types to 35 internal, while WorkloadManagement declares 9 public to 11 internal. The recommendation was followed where somebody was thinking about it and not followed elsewhere, which is what recommendations without enforcement always produce. It does not matter much here, because the .Shared boundary is doing the real work, but it is the same failure mode one layer down.

The same estate, the same boundary, no enforcement at all

Now the other half, and it is the one I would raise in a review.

The module boundary exists twice in this system. Once in the assembly graph, where it is perfectly maintained. And once in the database, where seven modules own seven Postgres schemas:

sales · postsale · inquiries · time-management · finance · workloads · backoffice

Each is declared with a single line in its own DbContextmodelBuilder.HasDefaultSchema("sales") at SalesDbContext.cs:33, and so on for all seven. No cross-schema foreign keys exist. No cross-schema join exists. The separation is real in the model.

And then every one of those contexts is registered through the same shared helper:

public static IServiceCollection AddPostgres<T>(this IServiceCollection services, IConfiguration configuration)
    where T : DbContext
{
    var connectionString = configuration.GetConnectionString("Postgres");
    services.AddDbContext<T>(x => x.UseNpgsql(connectionString));

    return services;
}

Shared.Plumbing/Database/Extensions.cs:35-41, GroupFlights at commit a19b337. One connection string, one Postgres role, for all seven schemas. In appsettings.json that role is postgres — the cluster superuser — and the compose file sets POSTGRES_HOST_AUTH_METHOD=trust, so there is no password at all. Appropriate for a teaching repository, and I would not raise it there; the point is what it means for the boundary rather than for security.

It means that the schema separation is enforced by the C# compiler and nothing else. If SalesDbContext gained a DbSet<Deadline> mapped to time-management."Deadlines", it would compile, it would run, and it would work perfectly until the day TimeManagement changed a column. The database — the one component in the system that could enforce the boundary with a GRANT — has been told to enforce nothing.

The contrast with ADR 01 is what makes this worth an article rather than a bullet. The team wrote a decision record about the assembly boundary, weighed three enforcement options, chose one deliberately and named the condition for upgrading. The database boundary got no ADR at all, and consequently got no considered choice — it inherited a default. Discipline that is real at one layer and absent at another is the normal shape of these systems, and the reason is almost always this: somebody thought hard about the layer they wrote a document for.

What I would actually do

Two things, in this order.

Give the database boundary the same three options. A role per module with GRANT USAGE ON SCHEMA sales TO app_sales; and nothing else, one connection string per module in configuration, and the module's AddPostgres<T> reading its own key. It is perhaps forty lines of SQL and one changed method signature, and it converts a convention into an error at first query. If that is too much for the current team — which is exactly the argument ADR 01 makes for the assembly layer — then write that down, so the next person knows it was a choice.

Pull the architecture test forward. ADR 01 defers Option 2 until more developers join, and there is a real cost argument for deferral: the test is a maintenance burden of its own, and reflection-based fitness functions rot when the project structure changes. But the version that catches this specific risk is not sophisticated. It reads the .csproj files and asserts a naming rule.

[Fact]
public void No_module_references_another_modules_internals()
{
    var illegal = ProjectGraph.CrossModuleReferences()
        .Where(edge => !edge.To.EndsWith(".Shared")
                    && !edge.To.StartsWith("GroupFlights.Shared."))
        .ToList();

    illegal.Should().BeEmpty(
        "modules may only depend on another module's published contract project");
}

Fresh illustrative code, not the estate's — GroupFlights has no such test. Thirty lines including the graph reader, one assertion, and it turns “we agreed not to” into “the build says no”. A rule you can state in one sentence is a rule you can assert in one test, and the moment that becomes true, deferring the test costs more than writing it.

To be fair to the ADR: it was right about its own estate. Zero violations in 439 files is not luck, and the money the team saved by not building a fitness function bought the four ADRs that make this repository worth reading in the first place. The finding is not that they were wrong to defer. It is that the other boundary never got the same conversation.

Next, the thing the agreement protects — the only legal edge between modules, what a <Module>.Shared project is allowed to contain, and the framework dependency that leaked into two of them by copy-paste.