Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/five-modules-that-cannot-see-each-other && read --section="top" 0%
Architecture

Five Modules That Cannot See Each Other

Every modular monolith claims its modules are decoupled. Inflow proves it the only way that counts - by having no project reference between any two of them - and then has to build a whole mini-framework to make that survivable. Part 1 of a source-read through the shared layer.

By Kumar Chandrachooda 17 Jan 2026 7 min read
Five sealed boxes, one bus, and no line drawn between any two boxes

Every modular monolith I have been shown had one project reference that ruined it. Usually it is called Shared.Contracts or Common.Events, it holds the record types that two modules pass between them, and the day someone adds a property to one of those records is the day both modules redeploy together. The boundary was drawn in the folder structure and erased in the .csproj.

Inflow — the MIT-licensed sample application by Piotr Gankiewicz (spetz) and DevMentors, © 2021, the companion repository for their Building Modular Monolith course — takes the other option. I opened all fourteen module .csproj files on the master branch and grepped every ProjectReference in them. There is not one reference from any module to any other module. Not a contracts project, not a shared events assembly, not a “just this one type” exception. Five modules — Customers, Payments, Wallets, Users and a Saga — that literally cannot see each other's types at compile time.

To be clear up front: I did not write Inflow. I am a source-reader working through someone else's teaching repository. Where the implementation is the story I will quote it, attributed and short; everything else is fresh example code written for the article. And because the repository has several long-lived branches that disagree with each other, I will say every time: this series reads master, the three-commit squashed history whose most recent commit is 39e96da, dated 2022-07-23.

The reference graph, in full

Here is the entire compile-time dependency picture for the modules, taken from the project files:

Customers.Api  -> Customers.Core   -> Shared.Infrastructure
Payments.Api   -> Payments.Core    -> Shared.Infrastructure, Payments.Shared
Users.Api      -> Users.Core       -> Shared.Infrastructure
Wallets.Api    -> Wallets.Infrastructure -> Wallets.Application -> Wallets.Core
Saga.Api       -> Shared.Infrastructure

Read the right-hand column. Every arrow terminates either inside its own module or at Inflow.Shared.Infrastructure / Inflow.Shared.Abstractions. Nothing crosses. The Bootstrapper — the ASP.NET Core host — references all five module API projects, and it is the only project in the solution that does.

That is the whole boundary mechanism, and it is worth pausing on how cheap it is. There is no ArchUnit-style test asserting the rule, no Roslyn analyser, no NDepend policy. There is just the absence of a line in a file, enforced by the fact that adding one would be a visible diff in a pull request. The strongest boundary in this repository is the one nobody wrote any code to enforce.

What that costs you immediately

Compile-time isolation is easy to declare and expensive to live with, because the modules still have to integrate. Customers needs to know when a user signs up. Wallets needs to know when a customer is verified. Payments needs to know when a customer gets locked. None of them can name the other's type.

Inflow's answer is a mini-framework of about 168 hand-written C# files — 53 in Inflow.Shared.Abstractions, 115 in Inflow.Shared.Infrastructure — and roughly 5,000 lines. That framework is the subject of this series. It does five things:

  1. Discovery. Modules are found by scanning the output directory for assemblies whose filename contains Inflow.Modules., then reflecting for implementations of IModule. No module is registered by hand anywhere.
  2. Routing without types. A message is delivered by matching the simple CLR type name of the published object against the simple type names of receiver types in other modules. CustomerVerified in Customers finds CustomerVerified in Wallets because they are spelled the same, not because either references the other.
  3. Translation. Every delivery is a JSON round trip — serialise the sender's instance, deserialise it into the receiver's own type. A receiver that declares fewer properties simply gets fewer properties.
  4. Verification. A module may declare a Contract<T> for a type it consumes, and the framework checks at application start that the upstream type still has the properties the contract requires. If it does not, the process refuses to boot.
  5. Cross-cutting plumbing. Dispatchers for commands, queries, events and domain events; a logging decorator chain; an outbox and inbox; auth; error mapping; pagination.

Points 2, 3 and 4 together are the interesting part, and they have a name in the repository's own README: local contracts. Each module owns its private copy of every event it consumes, shaped to its own needs, and the framework projects between them.

Local contracts, in one screen

Here is the shape, written fresh for this article rather than copied out of the repo. Customers publishes an event:

namespace Inflow.Modules.Customers.Core.Events;

internal record CustomerCompleted(
    Guid CustomerId, string Name, string FullName, string Nationality) : IEvent;

Payments, which cannot reference Customers, declares its own:

namespace Inflow.Modules.Payments.Core.Deposits.Events.External;

internal record CustomerCompleted(
    Guid CustomerId, string FullName, string Nationality) : IEvent;

Two types. Same simple name, different namespaces, different shapes — Payments does not want Name and therefore does not declare it. Both are internal, so neither is even visible outside its own assembly. When Customers publishes, the framework finds the Payments type by name, serialises the sender's record to JSON, deserialises it into the Payments record, and Name is quietly dropped on the floor because there is nowhere for it to land.

That dropped property is not a bug. It is the Content Filter pattern implemented by omission, and it is the single best idea in this codebase. The consumer's type declaration is its subscription and is its projection, and there is no schema registry, no broker, no .proto file and no versioning ceremony anywhere in sight.

The bill for all of it

None of this is free, and the honest accounting is what makes the repository worth fifteen articles rather than one.

Routing on a bare type name means a rename is a silent unsubscribe. The JSON round trip runs once per receiver per message, encoding to UTF-8 bytes and back on a bus that never leaves the process. The verification step in point 4 is entirely opt-in — of the eighteen consumer-side copies I counted across the estate, exactly four carry a contract. The outbox is fully implemented and shipped switched off. And one line in the outbox's own broker means that switching it on would not give you the guarantee the pattern exists to provide.

To be fair — and I will keep being fair, because this is a free teaching repository with no CI pipeline and no deployment — most of those are the correct trade for a course. The point of a demonstration estate is that you can see every idea in one file. Inflow succeeds at that better than any comparable repository I have read. It is also four years cold, and reading cold code is exactly how you learn what a design costs once nobody is left to defend it.

Where the series goes

  1. Five modules that cannot see each other — this post.
  2. The bootstrapper is seventy-six lines — how a host that knows nothing about its modules starts them.
  3. A namespace segment is a routing key — the one string four subsystems depend on.
  4. Your type name is the wire contract — subscription by spelling.
  5. Local contracts, verified at boot — consumer-driven contracts with no broker.
  6. Four of eighteen copies are checked — the census of what the verifier actually covers.
  7. The round trip that copies every message — the translator, and the wire format on an in-process bus.
  8. The bus that never leaves the process — the broker, the channel and the background drain.
  9. Nine lines stop a stack overflow — a marker attribute holding up the decorator chain.
  10. One assignment freezes every trace — how ??= turns a correlation chain into a constant.
  11. The outbox that commits somewhere else — one CreateScope() and the pattern it defeats.
  12. An inbox that is really a ledger — naming a component after the pattern you meant to build.
  13. Two flags, four systems, one that stalls — configuration as a product space.
  14. The container you build to read a setting — twenty-two throwaway service providers, two of them load-bearing.
  15. Boot loudly, fail quietly — the retrospective.

Two companion series read the same repository from other angles: Five Modules, One Database Each takes the domain modules themselves, and One Module Leaves the Process follows the microservices branch where one of them is extracted. A third, The Repo Is the Lesson, reads the repository as an artefact rather than a design.

If you have ever argued that your modules are decoupled while pointing at a folder tree, start here. The next part opens the file where all of this begins, which turns out to be seventy-six lines long.