Skip to content
Kumar Chandrachooda
.NET

Persistence as a Registration: Mongo and Redis

What you get when a chassis reduces MongoDB to one builder call and a named collection - a generic repository, paging, camelCase conventions, a seeding hook - and the transactions and indexes it deliberately leaves on your desk.

By Kumar Chandrachooda 09 May 2026 6 min read
One registration, one collection, one repository

Every team that adopts MongoDB in .NET writes the same three hundred lines within a month: a client singleton, a database accessor, a generic repository with GetAsync/AddAsync/paging, and a BSON convention pack that stops C# PascalCase leaking into the documents. I have written that file. You have probably written that file. Convey — the open-source .NET microservices toolkit from DevMentors whose source I have been walking through in this series — wrote it once, put it behind a builder call, and that is the whole pitch of Convey.Persistence.MongoDB:

services.AddConvey()
    .AddMongo()
    .AddMongoRepository<Order, Guid>("orders")
    .Build();

Two lines, and a handler can constructor-inject IMongoRepository<Order, Guid> and start paging. This part is about what those two lines actually register, the conventions they impose, and — just as important — the things the package looked at and deliberately did not abstract.

What AddMongo wires up

AddMongo() binds the "mongo" section — four properties, in keeping with the toolkit's small-options habit:

"mongo": {
  "connectionString": "mongodb://localhost:27017",
  "database": "orders-service",
  "seed": false
}

(The fourth, setRandomDatabaseSuffix, we'll get to — it's a testing gem.) Registration itself is textbook driver hygiene: one MongoClient as a singleton, because the client owns the connection pool and must not be created per request, and IMongoDatabase as a cheap transient view over it. Then the interesting part: the package registers its initializer through the chassis rather than the pipeline:

builder.AddInitializer<IMongoDbInitializer>();

That is the IStartupInitializer mechanism from Part 2 doing real work. When UseConvey() runs at startup, the Mongo initializer executes alongside every other module's, and — if seed: true — calls an IMongoDbSeeder. The default seeder is honest about being a placeholder: it lists the database's collections, returns if any exist, and otherwise does… nothing. It is a hook, not a feature — you pass your own seeder type to AddMongo(seederType: typeof(OrdersSeeder)) and get a guaranteed-once, post-container, pre-traffic place to create indexes and reference data. The guarantee comes from a static Interlocked.Exchange guard in the initializer, which also means it is once per process, not once per container — a detail that matters the moment two hosts share an AppDomain in an integration test.

The convention pack you forgot you needed

The part of this package I have stolen for other codebases is not the repository — it is twelve lines of convention registration:

BsonSerializer.RegisterSerializer(typeof(decimal), new DecimalSerializer(BsonType.Decimal128));
ConventionRegistry.Register("convey", new ConventionPack
{
    new CamelCaseElementNameConvention(),
    new IgnoreExtraElementsConvention(true),
    new EnumRepresentationConvention(BsonType.String),
}, _ => true);

(Convey.Persistence.MongoDB/Extensions.cs, abridged)

Each line prevents a production incident I have personally met. decimal as Decimal128 — because the driver's default of storing decimals as strings breaks range queries and aggregation. camelCase element names — because otherwise your documents are forever coupled to C# property casing. IgnoreExtraElements — because without it, removing a property from a class makes every existing document fail to deserialize. Enums as strings — because integer enums turn your database into a guessing game the day someone reorders the enum. None of this is clever; all of it is the difference between a toolkit written by people who ran Mongo in production and one written from the driver docs.

And the testing gem: setRandomDatabaseSuffix: true appends a GUID to the database name at registration time. Every integration-test run gets a fresh database on the same server, no fixtures, no cleanup races. It is one if statement, and it is worth more than most testing libraries.

The repository: honest CRUD, honest paging

AddMongoRepository<Order, Guid>("orders") registers a transient IMongoRepository<Order, Guid> bound to the collection name you supply — there is no pluralising-name magic, which I count as a feature. The entity must implement IIdentifiable<Guid> (a single Id property, defined in the core package), and the surface is deliberately small: GetAsync, FindAsync, AddAsync, UpdateAsync, DeleteAsync, ExistsAsync, and one paging method, BrowseAsync, which returns the same PagedResult<T> the CQRS queries package uses — so a repository page slots straight into a paged query handler with no mapping layer.

The paging implementation is worth reading because it shows its costs plainly: it issues an AnyAsync (empty-check), then a CountAsync (for total pages), then the page query itself — up to three round trips per page. Dynamic ordering is a small Expression builder that turns the query's orderBy string into a property lambda. Fine for admin grids; on a hot path you will want to drop to repository.Collection — and the interface exposes the raw IMongoCollection<TEntity> for exactly that reason. An abstraction with a trapdoor is an abstraction you can keep.

Two behaviours deserve a warning label. UpdateAsync is ReplaceOneAsync — full-document replacement, no partial $set updates, so concurrent writers last-write-wins the whole document. And DeleteAsync(predicate) calls DeleteOneAsync: a predicate that matches fifty documents deletes one of them. Both are defensible defaults; neither is what the method name whispers.

What it deliberately doesn't do

The package ships an IMongoSessionFactory — three lines wrapping StartSessionAsync() — and then the repository never uses it. That is the transaction story in full: if you need multi-document atomicity, you start the session yourself, pass it through your own data access, and give up the tidy repository. No index management, no mapping layer, no unit-of-work either. I read this as a scope decision rather than laziness: transactions and indexes are where every generic Mongo abstraction I have seen either becomes a leaky ORM or silently does the wrong thing. Convey stops at the line where genericity stops being true. The cost is real — the outbox package (Part 12) had to build its own atomicity story — but the repository never lies to you about what a call does.

Redis: the thinnest package in the toolkit

Convey.Persistence.Redis is four files, and its entire runtime contribution is:

builder.Services
    .AddSingleton(options)
    .AddSingleton<IConnectionMultiplexer>(sp => ConnectionMultiplexer.Connect(options.ConnectionString))
    .AddTransient(sp => sp.GetRequiredService<IConnectionMultiplexer>().GetDatabase(options.Database))
    .AddStackExchangeRedisCache(o => { o.Configuration = options.ConnectionString; o.InstanceName = options.Instance; });

(Convey.Persistence.Redis/Extensions.cs)

A "redis" section (connection string, instance prefix, database number), the multiplexer as a singleton — the one thing people get wrong with StackExchange.Redis — the raw IDatabase for when you want Redis to be Redis, and IDistributedCache for when you want it to be a cache. There is no IRedisRepository, no serialization opinion, no key-naming convention. At first I read that as an unfinished package. I now think it is the most self-aware one: Redis usage is too varied — cache, lock, stream, counter — for a generic abstraction to earn its keep, and the multiplexer lifetime is the only universal mistake worth preventing. A chassis should abstract what is uniform and hand you the client where uniformity ends. Mongo and Redis, side by side, are the two halves of that judgement call.

For completeness, the toolkit also carries Convey.Persistence.OpenStack.OCS — a hand-rolled client for OpenStack object storage, with its own token-auth manager and request builder. It is the odd one out in the persistence family (clearly built for one deployment environment somebody had), and I mention it mostly as evidence that a chassis grows by accretion of real needs, not by roadmap symmetry.

Where this sits in the series

The pattern to take away: AddMongo() is not hiding Mongo from you. It is standardising the boring 90% — lifetimes, conventions, seeding, paging — and exposing Collection, sessions, and the multiplexer for the 10% where your service is actually different. Next part, the same philosophy applied to talking HTTP to other services: one client behind an interface, typed RestEase clients on top — and a retry policy whose enthusiasm you will want to read about before you trust it with a POST.