Elad Sertshuk
4 min C#LiteDB.NET

LiteDB will let you put [BsonId] on the wrong property

It doesn't warn you. It builds a phantom unique index instead, and the second insert dies on a key you can't see.

I keep a few small ASP.NET Core packages that all store to a local LiteDB file — the debug dashboard, a feature-flag panel, a couple of others. They all have the same shape: some entity with a natural string key, and a UI that reads and writes it by that key.

For a feature flag, the natural key is the flag's name. So the obvious model is the obvious model:

public class FeatureFlag
{
    [BsonId]
    public string Name { get; set; } = "";
    public bool Enabled { get; set; }
}

This compiles. The first insert works. The second one throws duplicate key ... value is null, on a collection where you have inserted exactly one document and no two names are the same.

What's actually happening

[BsonId] tells LiteDB "this property is the document id." LiteDB honours that: it serialises Name's value into the _id field. What it does not do is remove Name from its list of ordinary members. So it also registers a unique index literally named Name — and that index reads the Name field of the stored document, which no longer exists, because the value went to _id.

Every document therefore indexes as null. The first null is fine. The second is a duplicate. The error message is technically accurate and completely useless, because the key it's complaining about isn't one you wrote.

The second half of the trap is on the read side. FindById(name) queries _id, which does hold the name, so that part looks like it works. But an Upsert that goes through the collection's own id resolution can miss, and then you're inserting again, and the phantom index rejects it again. You end up staring at an upsert that never updates.

The part that cost me the most time

Not the diagnosis. The verification.

Once I'd fixed the model I still got exceptions, now a InvalidCastException reading a string _id into an ObjectId. The fix was correct; the database file on disk was not. It still had documents in the old shape, and changing an id scheme is a breaking migration, not a code change. On Windows this is worse than it sounds, because deleting the file races whatever process still has a handle on it, so "I deleted it and it still fails" is a thing that happens to you for a while.

What broke the loop was pointing the sample app at a brand new filename. Not deleting the old file. Not clearing it. A name that had never existed. If the code is right, a virgin database proves it in one run, and anything else you were fighting was state.

I now reach for that earlier than I used to. When a fix "doesn't work," the question isn't only "is the code right" — it's "is there anything on disk that remembers the old code."

What I do instead

Give LiteDB the id it wants, and keep your natural key as an ordinary indexed field:

public class FeatureFlag
{
    [BsonId, JsonIgnore]
    public ObjectId Id { get; set; } = ObjectId.NewObjectId();

    public string Name { get; set; } = "";
    public bool Enabled { get; set; }
}

// once, at startup
_col.EnsureIndex(x => x.Name, unique: true);

// then query by predicate, never by id
var flag = _col.FindOne(x => x.Name == name);
_col.Upsert(flag);
_col.DeleteMany(x => x.Name == name);

Three things earn their place here.

ObjectId is LiteDB's own id type, so nothing about the mapping is surprising. The unique: true index on Name is a real constraint on a real field, so when it fires it names something you recognise. And querying by predicate rather than by id means the code says what it means — you are looking up a flag by its name, not by whatever the storage layer decided a name is.

[JsonIgnore] is there because System.Text.Json will happily serialise the ObjectId into your API responses otherwise, and an internal storage id has no business in a public payload.

The general shape of it

An attribute that accepts a value it can't actually honour is worse than one that throws. LiteDB had every opportunity to say "you can't put the id on a member I'm also going to index" and instead it built both, half-wired, and let the mistake surface three layers away as a null-key violation.

There's not much you can do about a library making that choice. What you can do is stop treating "the storage layer's id" and "the thing my domain calls a key" as the same field. They almost never are, and every time I've collapsed them to save a property I've paid for it later.

The fixed version is running in AspNetFlags, one of the packages in my ASP.NET suite. The model and queries above are in Flags.cs.