Skip to content
Kumar Chandrachooda
.NET

One Index Across Ten Services

Pacco models aggregates-per-document honestly across ten Mongo databases, and then creates exactly one index in the whole estate - fire-and-forget, in a scope that has already been disposed - while natural-key scans sit on the message hot path.

By Kumar Chandrachooda 03 Feb 2026 6 min read
Ten databases, one lonely index card

Part 3 ended in the basement, at the .AddMongo() call every service makes (and Vehicles makes twice). This part follows it into the database, because the estate's persistence story splits cleanly into a half done well and a half not done at all — and the boundary between them is a single index.

Aggregate-per-document, done honestly

First the good half. Pacco is genuinely database-per-service: ten Mongo databases, each named for its owner (orders-service, deliveries-service, and so on), each reachable only through its service, no shared collections, no cross-database reads. And within each database, the document design respects the aggregate boundary. Orders' persisted shape:

// Pacco.Services.Orders.Infrastructure/Mongo/Documents/OrderDocument.cs
public class OrderDocument : IIdentifiable<Guid>
{
    public Guid Id { get; set; }
    public Guid CustomerId { get; set; }
    public Guid? VehicleId { get; set; }
    public OrderStatus Status { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? DeliveryDate { get; set; }
    public decimal TotalPrice { get; set; }
    public IEnumerable<Parcel> Parcels { get; set; }
    // ...
}

The parcels ride inside the order document. Deliveries embeds its registrations; Availability embeds its reservations; Customers embeds its completed-order ids. One aggregate, one document, one atomic write — no cross-document joins and no multi-document invariants pretending Mongo is relational. Each service also pays a “mapping tax” willingly: a document class, an entity class, and hand-written extension methods converting each way, so the domain model stays free of driver attributes. For a document store consumed by DDD-style aggregates, this is the textbook answer, executed cleanly ten times.

The read side is honest in a different way. Query handlers skip the repository and the domain entirely — Deliveries' GetDeliveryHandler injects the raw IMongoRepository<DeliveryDocument, Guid>, filters, and returns the DTO in four lines. Write path through aggregate and repository, read path straight off the document: lightweight CQRS on a single store, with none of the ceremony of separate read models. It works, and it has a side effect that matters for this article — the estate's query predicates end up scattered across repository classes and query handlers in ten repositories, with no single place that records what gets asked of each collection.

Registration is one line of the chain from part 2 — .AddMongoRepository<OrderDocument, Guid>("orders") — and that line is where the honest half ends. Convey's registration surface gives you a collection, a repository, and nothing that ever asks: how will this collection be queried?

The estate's only index

Grep all ten services for index creation and you get exactly one hit. It is in Identity, and it deserves quoting in full, because every line teaches something:

// Pacco.Services.Identity.Infrastructure/Mongo/Extensions.cs
public static IApplicationBuilder UseMongo(this IApplicationBuilder builder)
{
    using (var scope = builder.ApplicationServices.CreateScope())
    {
        var users = scope.ServiceProvider.GetService<IMongoRepository<UserDocument, Guid>>().Collection;
        var userBuilder = Builders<UserDocument>.IndexKeys;
        Task.Run(async () => await users.Indexes.CreateOneAsync(
            new CreateIndexModel<UserDocument>(userBuilder.Ascending(i => i.Email),
                new CreateIndexOptions
                {
                    Unique = true
                })));
    }

    return builder;
}

Read it slowly.

The intent is exactly right. A unique index on UserDocument.Email is the correct — the only — way to enforce “one account per email” in Mongo. An application-level check (“query first, insert if absent”) is a race; the index is the invariant. Whoever wrote this knew that.

The execution un-does the intent. Task.Run(...) launches the index creation and nobody awaits it: UseMongo returns immediately, and the using block disposes the scope that produced the repository before the task is guaranteed to have started. In practice the underlying Mongo client is a singleton, so the launched task usually completes fine against a live connection — which is the worst possible failure mode, because usually means the code works often enough that nobody notices it is a race. If the creation fails — Mongo not up yet, permissions, a duplicate email already present from an earlier run — the exception is thrown inside an unobserved task. No log, no crash, no index. The service starts green, and sign-up quietly loses its uniqueness guarantee.

And it is the only one. Not the only unique index — the only index statement of any kind across ten databases. The estate's single attempt at the discipline is also its flakiest line of startup code. There is one mitigating footnote: the query this index serves — sign-in's lookup by lowercased email — is thereby the only natural-key query in the estate with any chance of an index behind it.

The scans on the hot path

Every other natural-key query walks its collection. And these are not admin-screen queries; they sit on the message hot path. The clearest examples are in Orders:

// Pacco.Services.Orders.Infrastructure/Mongo/Repositories/OrderMongoRepository.cs
public async Task<Order> GetAsync(Guid vehicleId, DateTime deliveryDate)
{
    var order = await _repository.GetAsync(o => o.VehicleId == vehicleId &&
                                                o.DeliveryDate == deliveryDate.Date);
    return order?.AsEntity();
}

public async Task<Order> GetContainingParcelAsync(Guid parcelId)
{
    var order = await _repository.GetAsync(o => o.Parcels.Any(p => p.Id == parcelId));
    return order?.AsEntity();
}

The first is the estate's cross-service correlation join: when Availability announces ResourceReserved, Orders finds the order to approve by (VehicleId, DeliveryDate) — a compound natural key with no compound index, so every reservation event triggers a collection scan. The second is worse as a query shape: Parcels.Any(p => p.Id == parcelId) matches inside an embedded array, which Mongo answers by scanning orders and probing each parcel list — and it runs every time Parcels publishes a deletion. Mongo can index that (a multikey index on Parcels.Id); nothing asks it to.

Deliveries adds the race variant of the same gap. StartDeliveryHandler guards against duplicates by query:

var delivery = await _repository.GetForOrderAsync(command.OrderId);   // scan by OrderId
if (delivery is {} && delivery.Status != DeliveryStatus.Failed)
{
    throw new DeliveryAlreadyStartedException(command.OrderId);
}
delivery = Delivery.Create(command.DeliveryId, command.OrderId, DeliveryStatus.InProgress);
await _repository.AddAsync(delivery);

Get-then-insert, with no unique index on OrderId to make the invariant real. Two concurrent StartDelivery commands for one order — a redelivered message and a fresh one, say — both pass the guard and both insert. DeliveryAlreadyStartedException is best-effort theatre; the database happily stores two deliveries for one order. It is the exact mistake Identity's author avoided in intent and fumbled in execution: the estate knows uniqueness belongs in the database in one repo, and enforces it with an if-statement in another. The version fields that might have caught some of this sit unused on every aggregate — but optimistic concurrency is a story the domain series already owns, in optimistic concurrency that never says no.

Why samples never index

To be fair to the authors: at demo scale, none of this is observable. Every collection holds dozens of documents; every scan returns in microseconds; Mongo's automatic _id index covers the load-by-id calls that dominate the command handlers. An index-creation pass would add code to ten repositories (copied, presumably, per part 2's distribution model) to speed up queries no demo ever feels. I understand the omission.

But the omission teaches the wrong thing, because indexes are precisely the discipline you cannot retrofit with a grep. The queries are scattered across repository lambdas in ten repos; nothing in the codebase records which predicates exist, so nothing tells you which indexes are missing. A fresh index-audit means re-reading every repository class in every service — which is what I did, and the answer fits in one sentence: one unique index on email, created as a fire-and-forget race; everything else scans. The framework shares responsibility here. A registration surface that asked one more question — .AddMongoRepository<OrderDocument, Guid>("orders", indexes: …) — would have made the discipline declarable in the one file everyone already copies. It never asks, so nobody answers, and ten services ship with their query plans unexamined.

What I take from the estate, as a checklist for any document-store service:

  • Every repository lambda is an index requirement in disguise — when you write GetAsync(d => d.OrderId == id), you have just designed an index, whether or not you create it.
  • Uniqueness belongs to the database, not to an if-statement — a get-then-insert guard is a race with a friendly error message.
  • Startup index creation must be awaited and observed — an index that fails to build silently is strictly worse than one that crashes the service, because the crash at least tells you.
  • Write the index audit down — one file per service listing predicates and the indexes that serve them costs ten minutes and makes the gap grep-able forever.

Nothing in the estate would catch any of this, of course — no load test fails, because almost nothing runs. Which brings us to the strangest artefact in all thirteen repositories: the test pyramid CI never runs, where nine repositories test nothing and stay green, and the one real pyramid is the one Travis skips.