One Database, Eleven String Literals
Four physical Mongo databases collapse into one when Trill is rebuilt as a monolith - and the only thing keeping the modules apart afterwards is a private const string copied into eleven files.
Part 2 ended on a boast the code earns: no module can reach another module's code, because the compiler will not let it. Data is a different story. Every module in the Trill monolith talks to the same IMongoDatabase instance, and the only thing that stops Analytics from opening the Users collection is that nobody has typed the right string.
Code isolation is enforced by the compiler; data isolation is enforced by a naming convention repeated in eleven separate files. That asymmetry is the most consequential structural difference between the two builds, and it is entirely invisible unless you go looking for it.
What the distributed build had
In the microservices estate each service names its own physical database in its own appsettings.json, and the names are not subtle:
| Service | mongo.database |
|---|---|
Trill.Services.Ads |
trill-ads-service |
Trill.Services.Analytics |
trill-analytics-service |
Trill.Services.Stories |
trill-stories-service |
Trill.Services.Users |
trill-users-service |
Timeline has no mongo section at all — it is the one service with no document database, projecting everything into Redis instead. Four physical Mongo databases, one Redis, five storage boundaries you could enforce with credentials if you cared to.
That is the real isolation guarantee of the distributed build, and it is worth being precise about why it is strong. It is not strong because the schemas differ. It is strong because a service holds a connection string that does not name any other service's database. Reaching across is not a matter of restraint; it is a matter of not having the address.
What the monolith has
The whole application connects to one database (appsettings.json:58-61):
"mongo": {
"connectionString": "mongodb://localhost:27017",
"database": "trill",
"disableTransactions": true
}
Partitioning happens one level down, in the collection name. Mongo has no schemas, so the authors invented one out of a prefix: ads-module.ads, stories-module.stories, stories-module.ratings, users-module.users, users-module.followers, users-module.refreshTokens, analytics-module.stories, analytics-module.tags, analytics-module.users. Read as a list it looks like a schema. It is a string.
Here is the whole mechanism, from Trill.Modules.Ads.Core/Persistence/AdRepository.cs:11-16:
private const string Schema = "ads-module";
// ...
_collection = database.GetCollection<Ad>($"{Schema}.ads");
databaseis the sharedIMongoDatabase, injected straight from the container. There is no per-module database abstraction, no scoping wrapper, nothing that could refuse a request for another module's collection.Schemaisprivate const, which means it cannot be shared. Every file that needs the prefix declares its own copy.- The name is a lie in a useful way. Calling it
Schemadocuments the intent — this is a namespace, treat it as one — while the type system knows nothing about it.
Counting the copies
Grep the repository for that declaration and you get eleven files:
| File | Line | Prefix |
|---|---|---|
Ads.Core/Persistence/AdRepository.cs |
11 | ads-module |
Ads.Core/Queries/Handlers/BrowseAdsHandler.cs |
14 | ads-module |
Ads.Core/Queries/Handlers/GetAdHandler.cs |
12 | ads-module |
Analytics.Core/Mongo/DatabaseProvider.cs |
9 | analytics-module |
Stories.Infrastructure/Extensions.cs |
21 | stories-module |
Stories.Infrastructure/Mongo/Extensions.cs |
13 | stories-module |
Stories.Infrastructure/Mongo/Queries/Handlers/BrowseStoriesHandler.cs |
17 | stories-module |
Stories.Infrastructure/Mongo/Queries/Handlers/GetStoryHandler.cs |
15 | stories-module |
Stories.Infrastructure/Mongo/Repositories/StoryRatingMongoRepository.cs |
12 | stories-module |
Users.Core/Extensions.cs |
24 | users-module |
Users.Core/Handlers/Queries/BrowseUsersHandler.cs |
17 | users-module |
Eleven declarations of the same convention, none of them checked against each other. A typo in any one of them creates a new empty collection at runtime rather than failing at startup, because GetCollection<T> on a name that does not exist is a perfectly legal Mongo operation that returns a handle to a collection Mongo will create when you first write to it. The failure mode is not an exception; it is silence and an empty result set.
There are two further copies in the integration tests, hard-coded rather than derived (StoriesModuleWebApiTests.cs:69,90), and a thirteenth expression of the same convention inside the framework itself, where the inbox and outbox build their collection names from the module name at runtime (MongoInbox.cs:53, MongoOutbox.cs:86):
var collection = _database.GetCollection<InboxMessage>($"{module}-module.{_collectionName}");
That last one is the interesting case, because it shows the fix was available. The framework already knows every module's name — IModule.Name and IModule.Path are on the interface, ModuleInfoProvider holds the list, and GetModuleName() derives it from a type's namespace. A GetModuleCollection<T>(string name) helper on the shared kernel would have replaced all eleven constants with one derivation, and made a mistyped prefix a compile error instead of an empty collection. The abstraction that would have closed the gap already exists two projects away; nothing wires it to the query handlers.
Analytics gets it nearly right
One module does better, and the delta is instructive. Trill.Modules.Analytics.Core/Mongo/DatabaseProvider.cs declares the prefix once and exposes three typed properties:
private const string Schema = "analytics-module";
public IMongoCollection<Story> Stories => _database.GetCollection<Story>($"{Schema}.stories");
public IMongoCollection<Tag> Tags => _database.GetCollection<Tag>($"{Schema}.tags");
public IMongoCollection<User> Users => _database.GetCollection<User>($"{Schema}.users");
Every Analytics handler injects IDatabaseProvider and asks for .Tags. Not one of them types a collection name. That is a five-minute abstraction, it exists in this repository, it works, and the other five modules do not use it — Ads declares the same constant three times and Stories five. The pattern was found and never propagated, which is exactly the kind of unevenness that the compiler cannot catch and a linter would not either.
The transaction that the single database makes possible and the wiring makes unreachable
The obvious payoff of consolidating four databases into one is that a cross-module write could finally be atomic. Mongo 4 supports multi-document transactions, the shared kernel has IMongoSessionFactory, and UnitOfWorkCommandHandlerDecorator wraps every command handler in a session. On paper this is the killer argument for the monolith.
In practice it is unreachable twice over.
First, transactions are switched off. mongo.disableTransactions is true in appsettings.json:61 and in both test configurations, so the decorator takes its non-transactional branch (UnitOfWorkCommandHandlerDecorator.cs:36-40) in every environment that exists. The transactional path is dead code in the shipped configuration.
Second — and this would still bite if the flag were flipped — the dispatchers create a fresh DI scope per message. CommandDispatcher.cs:25 and EventDispatcher.cs:29 both open _serviceFactory.CreateScope() before resolving the handler. A command and the events it publishes therefore resolve different scoped services, which means different Mongo sessions, which means they were never going to share a transaction regardless of the flag.
The single database makes an atomic cross-module write possible; the composition makes it impossible. That is not a bug — it is a design that deliberately keeps modules from sharing a unit of work, which is the correct choice if you intend to extract them later. But it does mean the “one database gives you transactions” argument is not supported by this repository, and it is worth knowing that before you make it in a meeting.
Reading the two columns honestly
| Microservices | Modular monolith | |
|---|---|---|
| Storage boundary | Four connection strings | One connection string |
| Enforced by | Credentials and topology | A private const string, eleven copies |
| Cost of a rename | Nine deployments | One edit, eleven places to find |
| Cross-module read | Impossible without a new client | One line of code, no error |
| Cross-module transaction | Impossible | Possible in theory, unreachable in practice |
The conference sample took a different route — one physical database partitioned by real Postgres schemas, one per module, which that series covered — and Postgres schemas at least have grants behind them. Mongo collection prefixes have nothing behind them at all.
If I were to change one thing about this repository, it would be this: hoist the prefix into the shared kernel and give modules a typed handle to their own slice of the database, so that data isolation is derived from the same IModule.Name the routing table already trusts. The mechanism costs twenty lines and turns eleven silent conventions into one enforced one.
Next, the thing the monolith had to build in order to have a shared kernel at all: a framework instead of twenty-four packages.