Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/one-database-eleven-string-literals && read --section="top" 0%
Architecture

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.

By Kumar Chandrachooda 31 Dec 2025 6 min read
One cylinder divided by dotted lines instead of walls

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");
  • database is the shared IMongoDatabase, 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.
  • Schema is private 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 Schema documents 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.