Three Modules, One Process
DevMentors' ModularMonolith sample packs three conference-system modules, a mini-framework and one deliberately broken contract into eighty-four C# files - opening a series that reads all of it, defects included.
The argument between “start with a monolith” and “start with microservices” usually skips the option in the middle: one process, one deployment, one database — but real, compiler-enforced boundaries between the parts. That middle station has a name, the modular monolith, and the best way I know to understand it is not another conference talk but a small codebase that actually builds one, teaching mistakes and all.
ModularMonolith — the open-source sample by DevMentors, MIT-licensed, at github.com/devmentors/ModularMonolith — is that codebase. Its git history credits both DevMentors founders: Piotr Gankiewicz laid down the skeleton and the Conferences and Speakers modules between January and April 2021, and Dariusz Pawlukiewicz (GooRiOn) added the Tickets module and the entire messaging machinery, finishing in August 2021. Seven commits, eighty-four C# files, net5.0 throughout, no README anywhere.
To be clear up front: I did not write ModularMonolith. I am a source-reader, and this estate belongs to the same DevMentors family this corpus has already walked twice — DShop's nine microservices and the Convey chassis. The modular monolith is the third architectural station the same teachers built, sitting between the monolith and the swarm, and reading it as teaching code — including where the teaching code has real shipped bugs — is the whole point of this series.
The estate in one screen
The solution is a conference system: conferences, their hosts, speakers, tickets. ModularMonolith.sln arranges it as one executable and a set of module pairs:
src/
├── Bootstrapper/ModularMonolith.Bootstrapper # the only executable
├── Modules/
│ ├── Conferences/ ...Conferences.Api + ...Conferences.Core
│ ├── Speakers/ ...Speakers.Api + ...Speakers.Core
│ └── Tickets/ ...Tickets.Api + ...Tickets.Core
├── Shared/
│ ├── ModularMonolith.Shared.Abstractions # 7 files of pure contracts
│ └── ModularMonolith.Shared.Infrastructure # the 20-file mini-framework
tests/
└── ModularMonolith.Tests.EndToEnd # xunit scaffold, zero source files
Every module splits into .Api (controllers plus an Add{X}Module/Use{X}Module facade) and .Core (entities, services, data access, events). The Bootstrapper's Startup.ConfigureServices is four lines — one per module, one for shared infrastructure — and its .csproj references only the three .Api projects. Modules never reference each other. The whole architecture is legible from the solution explorer before you open a single file, which is more than most production systems can claim.
The shared projects split along a line worth stealing: Shared.Abstractions holds nothing but interfaces — IMessage, IEvent, IMessageBroker, IEventDispatcher, IEventHandler<T>, IModuleClient, CustomException — while Shared.Infrastructure holds the machinery that makes modules composable: a controller-discovery trick that lets controllers be internal, an error-envelope middleware, an event dispatcher, an in-memory message broker built on System.Threading.Channels, a module registry that translates events between modules by JSON round-trip, and Postgres helpers giving each module its own schema.
Time to first request
Onboarding is three artefacts, because there is no README. docker-compose.yaml starts exactly one container:
services:
postgres:
image: postgres
shm_size: '4gb'
environment:
- POSTGRES_HOST_AUTH_METHOD=trust
ports:
- 5432:5432
Trust auth, and appsettings.json carries an empty Postgres password — fine for a laptop sample, and I will file it properly in the closing ledger. There is no service for the app itself and no Dockerfile; you docker-compose up, press F5, and Kestrel answers on http://localhost:5000 with a root endpoint that writes the plain string Modular Monolith API.
The third artefact is the .rest files — the estate's only executable documentation. ModularMonolith.rest probes the root and two module home endpoints; Conferences.rest is a genuinely complete CRUD walkthrough for hosts and conferences; Speakers.rest contains a single request. Notice what is missing: the root collection has no tickets-module probe at all, and it turns out that is honest — Tickets has no controllers to probe. Those collections, and the places they fall silent, become evidence later in the series.
Seven commits, one curriculum
The git history reads like a course syllabus, one episode per commit:
| Commit | Date | Episode |
|---|---|---|
3c6b3bc, 9d79074 |
2021-01-13 | licence, then the skeleton — including a tests folder that never receives a test |
8e0963c |
2021-01-27 | Conferences CRUD on in-memory repositories, the error envelope |
ffd157d |
2021-02-17 | EF Core, Postgres, docker-compose |
92df10c |
2021-04-14 | the Speakers module |
b06fe7a |
2021-04-14 | Tickets stub, event abstractions, a dummy broker |
60840b2 |
2021-08-23 | the full messaging machinery — and a quiet contract break |
Two of those rows hide the estate's best honest-ledger material. Commit b06fe7a wired a message broker whose PublishAsync was throw new System.NotImplementedException() into a live endpoint, and it stayed that way for four months. And the final commit, the one that delivers the whole cross-module messaging payoff, also renamed a property in a copied event contract and silently severed the only integration the system has. I will state both plainly when their parts arrive, with the commit hashes to check my work — respectfully, because this is teaching code that teaches most where it slipped.
Why this station matters
The claim a modular monolith makes is precise: module isolation should be real at compile time and cheap at runtime. Here, isolation is enforced three ways at once — internal controllers that MVC is specially taught to discover, InternalsVisibleTo chains that open each module to the Bootstrapper and nobody else, and a database schema per module. Runtime isolation, meanwhile, is deliberately zero: one process, one failure domain, events crossing module borders through an in-memory channel instead of a wire. Every convention mirrors a future service boundary — the URL prefix per module, the schema per module, the copied event contracts — so that extraction is a deletion job, not a rewrite. Whether the code cashes that cheque is exactly what the series examines.
Where the series goes
- Three Modules, One Process — this post.
- The Compiler Guards the Module Border — internal controllers,
InternalsVisibleTo, and where the isolation leaks. - Add, Use, Repeat — The Module Recipe — the extension-method recipe and what a fourth module would cost.
- Startup Order Is Load-Bearing — assembly scanning, throwaway service providers, and a reorder that tells a story.
- An Event Bus in Seven Files —
Channel<IMessage>, a background pump, and the patterns they implement. - Serialize, Deserialize, Deliver — class names as topics and translation by JSON round-trip.
- One Rename Broke the Only Contract — the centrepiece: contracts-by-copy and the silent break.
- One Flag, Two Failure Modes — sync versus async dispatch and the delivery semantics nobody wrote down.
- The Database Splits Before the App Does — schema-per-module and the empty configurations.
- Two Repositories, One Winner — dead registrations and DI as a strategy selector.
- Exception Names Become Error Codes — the Humanizer-powered error envelope.
- Invariants Enforced by Comment — anemic entities and the question the code asks itself.
- Two Modules, Three Months of Drift — the convention fossil record and the honest ledger.
- The Road Out of the Monolith — the retrospective: which module could leave home tomorrow.
If your team is stuck arguing monolith versus microservices as if those were the only two answers, the next thirteen parts walk the bridge between them — through real code, with its real cracks. Next, the trick that makes the borders hold: the compiler guards the module border.