Boot Loudly, Fail Quietly
One sentence describes every failure convention in Inflow's shared framework - startup misconfiguration throws, runtime message failure is logged and dropped. The retrospective on fourteen parts of source-reading, including what to steal, what to avoid, and where this design should not go.
Fourteen parts in, the shared framework has a personality, and it can be stated in six words.
Boot is fail-fast. Delivery is fail-silent.
AddSecurity throws if the encryption key is not exactly thirty-two characters — for a subsystem nothing in the repository injects. AddAuth throws if the signing key is missing. ContractRegistry.Validate throws if a consumer's required property has vanished upstream, and the process does not start. Registration-time misconfiguration is treated as a catastrophe, correctly.
Then AsyncDispatcherJob catches every exception from every handler, logs it, and moves on. OutboxProcessor catches, logs, and retries the identical poisoned batch one second later, forever. EfOutbox.PublishUnsentAsync hits an unresolvable type and continues. No retry counter, no dead-letter table, no backoff, no metric — and no ActivitySource, no OpenTelemetry package and no health endpoint anywhere in the repository to notice any of it.
A system that validates its configuration harder than its messages has pointed its rigour at the cheap failure. A wrong key length is found in five seconds by the person who typed it. A dropped event is found in a quarter by a customer whose wallet never received their bonus.
What I would steal
Four things, and I mean steal — I have used two of them since.
Local contracts. Each module declares its own copy of every event it consumes, with only the properties it wants; a JSON round trip performs the projection; a Contract<T> plus a boot-time verifier type-checks it against the producer's loaded type. Consumer-driven contract testing with no broker, no schema registry, no CI handshake and no version-skew problem, because the verified types and the running types are the same assemblies in the same process. The best idea in the repository, in roughly 280 lines.
The [Decorator] marker. Nine lines of attribute plus .WithoutAttribute<DecoratorAttribute>() on every assembly scan is what allows a scanning container and a decorator chain to coexist. Without it, a decorator registers as a handler and wraps itself. I now write this into every project that uses Scrutor.
Message context as a side table. IMessage stays an empty marker; correlation id, trace id, message id and identity live in a separate store keyed on the message, so domain records stay free of infrastructure fields. It is an inverted Envelope Wrapper, with the two caveats part 8 found: do not key on records unless you want value equality deciding what “the same message” means, and do not give it an expiry shorter than your slowest delivery path.
The module-name registries. Nineteen lines keying on a module name derived from a namespace, with one expression serving both registration and resolution. It works precisely because the derived fact and the source fact are the same fact.
What I would not
Routing on a bare simple type name with no boot-time census. Subscription by spelling is a lovely developer experience and a rename is a silent unsubscribe. The mechanism is fine; shipping it without a startup warning listing message types that have no counterpart in any other module is not.
Conventions derived by string surgery with no assertion. type.Namespace.Split(".")[2] decides delivery, persistence, transactions and log labels, and it fails into a valid-looking value rather than an exception. Six lines at boot would convert the estate's quietest failure into its loudest.
Work between registration and Build(). Twenty-two throwaway service providers, two of which mutate shared state and survive only because three singletons happen to be instance-registered.
An outbox whose broker opens its own scope. Part 11 is the whole argument. If you take one line out of this series, take that one: the outbox pattern's only invariant is that the row and the state change commit together, and a CreateScope() in the write path is the shortest way to break it.
Unbounded channels. Channel.CreateUnbounded means the only backpressure in the system is the OOM killer.
Where the estate genuinely gets it right
I want to be specific here, with the same precision I have used for the defects, because a critique is only credible if it can say what is good.
The module boundary is real, and it cost nothing to make real. Fourteen module project files, zero references between modules. No analyser, no architecture test, no policy engine — just the discipline not to add a line.
The framework is complete rather than fragmentary. Roughly 5,000 lines implement DI composition, CQRS dispatch, in-process pub/sub with type translation, an outbox and inbox, a contract verifier, JWT auth, structured logging with three sinks, exception-to-response mapping, pagination, encryption and module loading — and every idea is visible in one file. That is rare, and it is why the repository rewards reading rather than skimming.
The README is mostly accurate. I checked every claim that touches this series. “There's no reference between the modules at all” — true, verified project file by project file. Integration “based on local contracts” — true, and implemented exactly as described. The Bootstrapper “loading configurations, running DB migrations, exposing public APIs” — all three true. The one I would qualify is the shared split: “the former does contain public abstractions and the latter their implementation” is true of the code, but Inflow.Shared.Abstractions takes a FrameworkReference on Microsoft.AspNetCore.App and puts IApplicationBuilder on IModule's face, so there is no pure-domain tier and Amount, Currency and IApplicationBuilder share an assembly. A fuller audit across the whole README, which the companion series The Repo Is the Lesson takes up, lands at eleven of fifteen checkable claims true — a better record than most shipped products manage.
Dead code here is mostly pedagogy, not rot. About 900 lines never execute, and they are not one kind of thing. MessagePackModuleSerializer sits fully implemented with its registration commented out one line under the live one and its package reference retained; the outbox subsystem sits behind a flag that would activate it; AddTransactionalDecorators() is complete and uncalled. Against that: AppInitializer, superseded and unregistered, and the orphaned brace pair in EfInbox's finally. A linter cannot tell these apart — MessagePackModuleSerializer and AppInitializer are byte-for-byte equally unreferenced. The discriminator is intent evidence: a commented-out registration beside a live one, a config path that would reach it, a retained dependency, an implementation with no half-finished edges. Code that exists to be read rather than run is a legitimate artefact class in a teaching repository, and deserves to be named rather than scored.
When not to use this design
Not “when not to build a modular monolith” — that argument is elsewhere and mostly settled in the monolith's favour for small teams. Specifically, when not to use this framework's shape:
When you need delivery to survive a restart. As shipped, this is commit-then-publish into a volatile queue. Enabling the outbox does not fix it, for the reasons in part 11, and would not fix it even after the one-line repair while SentAt is stamped on enqueue.
When you will run more than one instance. OutboxProcessor selects WHERE SentAt IS NULL with no FOR UPDATE SKIP LOCKED and no owner column. Two instances publish everything twice.
When correlation has to cross your front door. UseCorrelationId mints a fresh Guid and reads no inbound header; there is no traceparent handling and no ActivitySource. This system cannot join a distributed trace it did not start.
When ordering matters per aggregate, or when third parties can add assemblies. The channel gives global process ordering with a single reader — too strong to scale and, once the outbox is involved, replaced by no ordering at all. And every message type in every loaded assembly gets a broadcast registration, with no [Message] attribute meaning “accept from anywhere”.
What I would do differently
If I were building this framework for production rather than for a course, in rough order of how much each buys:
- Delete
OutboxBroker'sCreateScope()and resolve the outbox from the ambient scope, so the row and the aggregate share a transaction. - Add an ordering key and
FOR UPDATE SKIP LOCKEDto the outbox read, plus anAttemptscolumn and a dead-letter destination after N failures. A poison pill must be able to leave the queue. - Populate the type registries after
Build()via a hosted service, deleting eightBuildServiceProvidercalls and the invariant they depend on. - Assign, do not null-coalesce.
_contextAccessor.Context = envelope.MessageContext.Context;— and better, pass the envelope onward instead of re-fetching from a cache. - Assert the conventions at boot. Namespace-derived module names cross-checked against declared names; message type names with no counterpart logged as warnings; handlers registered after the decoration pass rejected.
- Store a stable message type name, not
AssemblyQualifiedName. A format indicator bound to your assembly version is bound to your build number. - Bound the channel, drain it on shutdown, and ingest inbound correlation headers. All three are small; none exists.
- Test the framework layer.
Inflow.Shared.Testsis marked<IsTestProject>false</IsTestProject>and contains five helpers and a fake. There is not one[Fact]covering the broker, the dispatchers, the outbox, the contract registry or the value objects. Every defect in this series exists because nothing asserted otherwise — in a repository whose author demonstrably knew how to test, since the Wallets module ships unit, integration and end-to-end suites.
The last word
Reading master end to end changed how I think about one thing in particular: the shape of the gap between what a team implements first and what it implements last. Inflow's EIP ledger is not random. Every construction and routing pattern is present, several of them implemented well — Messaging Gateway, Message Bus, Publish-Subscribe Channel, Message Translator, Content Filter, Envelope Wrapper, Content-Based Router. Every reliability pattern is either disabled, mis-scoped, or absent — Guaranteed Delivery, Idempotent Receiver, Dead Letter Channel, Invalid Message Channel, Resequencer, Message History.
That is the order everybody builds in, because routing is what makes the demo work and reliability is what makes the demo survive. Inflow is honest about being the demo, and its author put the reliability half in the box, wired, switched off, and visible — which is a more useful teaching artefact than either omitting it or shipping it half-tested as enabled.
Credit where it is owed: Inflow is MIT-licensed, © 2021 DevMentors, written by Piotr Gankiewicz, and is the companion repository for their Building Modular Monolith course. Everything in these fifteen parts is a reading of code somebody wrote for free so that other people could learn from it, and every criticism here was only possible because the code is small enough, complete enough and clear enough to be criticised precisely. Most repositories are not.
Two companion series continue from here. Five Modules, One Database Each reads the domain modules the framework is carrying, and One Module Leaves the Process follows the microservices branch, where the transport-shaped seams this series kept noticing finally get a transport. If you are about to build a shared kernel for a modular monolith, read this framework first — then write down which half you are going to implement second, and put a date on it.