From IBL ABP
ABP Framework + MongoDB integration: configure AbpMongoDbContext, register entity collections, add custom MongoDbRepository implementations, typed filters, indexes, embedded/reference documents, enum string setup, and explicit UpdateAsync behavior because MongoDB has no change tracking. Use when adding a Mongo-backed ABP entity or working with MongoDbContext, IMongoCollection, MongoDbRepository, IMongoDbContextProvider, AddMongoDbContext, GetQueryableAsync, GetCollectionAsync, indexes, compound indexes, tenant-aware uniqueness, or collection registration.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ibl-abp:abp-mongodbThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill covers the MongoDB-specific layer of an ABP project: the
This skill covers the MongoDB-specific layer of an ABP project: the
AbpMongoDbContext, repository patterns (generic + custom), indexes
(single, compound, tenant-aware unique), and the differences from EF Core
that bite teams new to the stack.
For framework-wide conventions (DI, base classes, async, etc.) see
abp-core. For the entity/AppService/DTO scaffold see abp-feature-dev.
For modular ownership decisions see abp-module-architecture.
If the solution has modules/, choose the Mongo context owned by the module
that owns the entity. Do not register a CRM entity in the host context just
because the host can see it.
Rules:
IMongoDbContextProvider<TModuleMongoDbContext>.Default; do not leave the name accidental.await _repo.UpdateAsync(entity) after mutating it.ABP lays the same Mongo code out differently per template. Locations and
namespaces are resolved centrally by resolve_artifact(ctx, kind, plural) in
abp-core/scripts/abp_context.py — run python <skills-root>/abp-core/scripts/abp_context.py --show-layout
in the solution to print the exact dir + namespace for every artifact. The
table below is the summary for the pieces this skill touches:
| Artifact | nolayers (single project / IBL360) | layered (DDD / IBLTermocasa) |
|---|---|---|
*MongoDbContext | {{PROJECT_ROOT}}/Data/, ns {{ROOT_NAMESPACE}}.Data | {{DATA_PROJECT}}/MongoDb/, ns {{ROOT_NAMESPACE}}.MongoDB |
| Custom repo interface | {{PROJECT_ROOT}}/Data/{Plural}/, ns {{ROOT_NAMESPACE}}.Data.{Plural} | {{DOMAIN_PROJECT}}/{Plural}/, ns {{ROOT_NAMESPACE}}.{Plural} |
| Custom repo implementation | {{PROJECT_ROOT}}/Data/{Plural}/, ns {{ROOT_NAMESPACE}}.Data.{Plural} | {{DATA_PROJECT}}/MongoDb/{Plural}/, ns {{ROOT_NAMESPACE}}.MongoDB |
| Index seed contributor | {{PROJECT_ROOT}}/Data/ | {{DOMAIN_PROJECT}}/{Plural}/ |
{{DATA_PROJECT}} is the *.MongoDB project in layered and collapses to
{{PROJECT_ROOT}} in nolayers, so the same placeholder works on both. The
examples below use the nolayers namespaces ({{ROOT_NAMESPACE}}MongoDbContext,
{{ROOT_NAMESPACE}}.Data.*); on layered the context namespace is
{{ROOT_NAMESPACE}}.MongoDB — real example
src/IBLTermocasa.MongoDB/MongoDb/IBLTermocasaMongoDbContext.cs with
namespace IBLTermocasa.MongoDB;.
[ConnectionStringName("Default")]
public class {{ROOT_NAMESPACE}}MongoDbContext : AbpMongoDbContext
{
public IMongoCollection<Book> Books => Collection<Book>();
public IMongoCollection<Author> Authors => Collection<Author>();
protected override void CreateModel(IMongoModelBuilder modelBuilder)
{
base.CreateModel(modelBuilder);
modelBuilder.Entity<Book>(b =>
{
b.CollectionName = "Books";
});
modelBuilder.Entity<Author>(b =>
{
b.CollectionName = "Authors";
});
}
}
The collection name in CreateModel is what actually lands in MongoDB — by
default ABP would use the entity class name, but specifying it explicitly
shields you from accidental renames.
When you add a new entity, three things must change in *MongoDbContext.cs:
using for the entity's namespace.IMongoCollection<TEntity> {Plural} => Collection<TEntity>(); property.modelBuilder.Entity<TEntity>(b => { b.CollectionName = "..."; });
block inside CreateModel.If the entity has a custom repository (see further down), one more thing
must change — in *Module.cs:
options.AddRepository<TEntity, MongoTEntityRepository>(); inside the
AddMongoDbContext<>() call.The bundled script does all of this idempotently, and auto-detects the template so it finds the right context file and computes the right namespace on both layouts:
# Interactive — prompts for missing values
python <skills-root>/abp-mongodb/scripts/register_entity_in_context.py
# Scripted — namespace flags are optional; omit them to use the
# template-correct default from resolve_artifact (Root.Entities.Customers
# on nolayers, Root.Customers on layered) and let the script find the context.
python <skills-root>/abp-mongodb/scripts/register_entity_in_context.py \
--entity Customer --plural Customers
# With custom repository registration — repo namespace also defaults correctly
# (Root.Data.Customers on nolayers, Root.MongoDB on layered), so you can drop it too.
python <skills-root>/abp-mongodb/scripts/register_entity_in_context.py \
--entity Customer --plural Customers \
--repository-name MongoCustomerRepository \
--register-repository
Pass --entity-namespace / --repository-namespace only to override the
resolver (e.g. a non-standard folder). On layered, the entity namespace
equals the context's own namespace for some aggregates, and the script
skips a redundant using in that case.
The script:
[skip] for any change already in place — safe to re-run.CreateModel if missing.*Module.cs automatically when --register-repository is
passed, and inserts the AddRepository<> line inside the
AddMongoDbContext<> call.{
"ConnectionStrings": {
"Default": "mongodb://localhost:27017/{{PROJECT_NAME}}Db"
}
}
The ConnectionStringName("Default") attribute on the DbContext class points
at this key. To shard contexts across databases (rare), give each one its own
[ConnectionStringName("…")] and a matching entry in appsettings.json.
[DependsOn(typeof(AbpMongoDbModule))]
public class {{ROOT_NAMESPACE}}MongoDbModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddMongoDbContext<{{ROOT_NAMESPACE}}MongoDbContext>(options =>
{
options.AddDefaultRepositories();
// Don't pass `includeAllEntities: true` — see references/repositories.md
options.AddRepository<Customer, MongoCustomerRepository>(); // custom impl
});
}
}
Default to the generic IRepository<T, TKey> for plain CRUD. Add a
custom repository when:
ApplyFilter(IQueryable<T>, GetXInput) method that all three callers use.FindByTaxIdAsync,
GetActiveByOwnerAsync).GetCollectionAsync() and Builders<T> inside the repository.The abp-feature-dev scaffolder generates a custom repository when the
filter spec has >3 entries, or when you pass --custom-repository yes. See
references/repositories.md for the full shape.
GetQueryableAsync() for LINQ; GetCollectionAsync() only for
driver-level bulk ops or operators LINQ can't translate.IMongoQueryable<T> after a Where(...) chain to keep
the Mongo provider — MongoDB.Driver.Linq.IMongoQueryable<T>.await _repo.UpdateAsync(entity) every time.ISoftDelete, IMultiTenant) work through the
repository — bypass via DataFilter.Disable<...>().See references/repositories.md for the full pattern: custom interfaces, direct collection access, indexes, embedded vs referenced documents.
The trap to avoid:
IMongoEntityModelBuilder<T>(thebyou receive insidemodelBuilder.Entity<T>(b => { ... })) does NOT exposeConfigureCollection,Indexes, or any way to declare indexes — that's a recurring hallucination. The builder only configures the collection name (b.CollectionName = "...") and a few naming hints. Indexes must be created against the liveIMongoCollection<T>through one of the patterns below.
Three patterns, in order of preference for production:
IDataSeedContributor (recommended)Runs once at app startup via migrate-database / data-seed pipeline. Idempotent
(CreateOneAsync is a no-op when the same index already exists). Centralizes
all indexes for one entity in one file.
The seed contributor is data_seed in the resolver: nolayers → {{PROJECT_ROOT}}/Data/;
layered → the Domain project, {{DOMAIN_PROJECT}}/{Plural}/. Either way it injects the
context via IMongoDbContextProvider<{{ROOT_NAMESPACE}}MongoDbContext> (the context type
is the same; only its namespace differs by template — .Data vs .MongoDB).
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MongoDB;
using Volo.Abp.Uow;
public class CustomerIndexInitializer : IDataSeedContributor, ITransientDependency
{
private readonly IMongoDbContextProvider<{{ROOT_NAMESPACE}}MongoDbContext> _dbContextProvider;
private readonly IUnitOfWorkManager _uowManager;
public CustomerIndexInitializer(
IMongoDbContextProvider<{{ROOT_NAMESPACE}}MongoDbContext> dbContextProvider,
IUnitOfWorkManager uowManager)
{
_dbContextProvider = dbContextProvider;
_uowManager = uowManager;
}
public async Task SeedAsync(DataSeedContext context)
{
using var uow = _uowManager.Begin(requiresNew: true);
var dbContext = await _dbContextProvider.GetDbContextAsync();
var collection = dbContext.Customers;
var keys = Builders<Customer>.IndexKeys;
// UNIQUE WHERE tax_id IS NOT NULL — scoped per tenant
await collection.Indexes.CreateOneAsync(new CreateIndexModel<Customer>(
keys.Ascending(c => c.TenantId).Ascending(c => c.TaxId),
new CreateIndexOptions<Customer>
{
Name = "ux_customers_tenant_taxid",
Unique = true,
PartialFilterExpression = new BsonDocument("TaxId", new BsonDocument("$type", "string"))
}));
await collection.Indexes.CreateOneAsync(new CreateIndexModel<Customer>(
keys.Ascending(c => c.Status),
new CreateIndexOptions { Name = "ix_customers_status" }));
await uow.CompleteAsync();
}
}
Why this beats alternatives:
migrate-database so indexes appear before the first user request,
not on the first query.Simplest. Acceptable for small projects or app-level indexes you're prototyping. Pays a tiny per-process overhead and ties index DDL to repository read code:
public override async Task<IQueryable<Book>> GetQueryableAsync()
{
var col = await GetCollectionAsync();
await col.Indexes.CreateOneAsync(new CreateIndexModel<Book>(
Builders<Book>.IndexKeys.Ascending(b => b.Name)));
return await base.GetQueryableAsync();
}
Use only when you can't get a IDataSeedContributor to run (no migrator
pipeline, or you need indexes to be checked on every host startup
regardless of whether seeds ran).
When porting a Postgres UNIQUE constraint to a multi-tenant Mongo collection,
a single-field unique index is wrong — it would prevent two tenants from
having the same TaxId. Use a compound (TenantId, Field) unique index:
await collection.Indexes.CreateOneAsync(new CreateIndexModel<Customer>(
Builders<Customer>.IndexKeys
.Ascending(c => c.TenantId)
.Ascending(c => c.TaxId),
new CreateIndexOptions<Customer>
{
Unique = true,
// MongoDB equivalent of WHERE TaxId IS NOT NULL.
// Filter.Ne(c => c.TaxId, null) sometimes won't translate to a valid
// partialFilterExpression; the BsonDocument form below is what the
// driver accepts unambiguously across versions.
PartialFilterExpression = new BsonDocument("TaxId", new BsonDocument("$type", "string")),
Name = "ux_customers_tenant_taxid"
}));
Notes:
PartialFilterExpression is the MongoDB equivalent of WHERE field IS NOT NULL
in Postgres — required for unique-when-not-null semantics.using var uow = _uowManager.Begin(requiresNew: true) block so the seed contributor doesn't
fight with the ambient UoW that migrate-database opens.The MongoDB C# driver serializes enums as integers by default. Two big
downsides for app-level enums (Status, Segment, Type):
status: 1 instead of status: "Active").1 now
means something else).Three ways to fix it, listed best-first:
Register EnumRepresentationConvention once and every enum on every
entity, present and future, is stored as a string. No per-property
attributes, no per-type registrations. Put this in *Module.PreConfigureServices
(must run before the first BsonClassMap is built):
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Conventions;
public override void PreConfigureServices(ServiceConfigurationContext context)
{
ConventionRegistry.Register(
"{{ROOT_NAMESPACE}}EnumAsString",
new ConventionPack { new EnumRepresentationConvention(BsonType.String) },
_ => true); // applies to all types
}
The third argument is a Func<Type, bool> — return true to opt every
type in, or scope it to a namespace if you want a more surgical roll-out:
ConventionRegistry.Register(
"{{ROOT_NAMESPACE}}EnumAsString",
new ConventionPack { new EnumRepresentationConvention(BsonType.String) },
t => t.FullName?.StartsWith("{{ROOT_NAMESPACE}}.Entities.") == true);
Conventions are applied lazily, when each BsonClassMap is built. As long
as registration happens in PreConfigureServices it's in place before any
collection is opened.
Use when you have a mixed model (some enums string, others int, e.g. internal numeric flags) and you don't want a convention to flip them all:
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
public class Customer : AuditedAggregateRoot<Guid>, IMultiTenant
{
[BsonRepresentation(BsonType.String)]
public CustomerSegment Segment { get; set; }
}
BsonSerializer.TryRegisterSerializer(typeof(MyEnum), new EnumSerializer<MyEnum>(BsonType.String))
works but you have to call it once per enum type — same maintenance burden
as the attribute, without the locality benefit. Prefer the convention or
the attribute.
The Bson representation controls storage, not the JSON API surface.
ASP.NET still serializes enums as integers by default — and Swashbuckle
(the OpenAPI generator ABP uses) reads from MVC's JsonOptions, so an
inconsistent setup gives you "status": "Active" on the wire but
type: integer, enum: [0,1,2] in /swagger/v1/swagger.json. Clients
generated from that spec (TypeScript, C#, OpenAPI Generator) will then
expect numbers and break at runtime.
To make storage + REST + OpenAPI agree, pair the convention with
JsonStringEnumConverter on both serializers:
// REST API surface (and OpenAPI: Swashbuckle picks this up automatically)
context.Services.Configure<Microsoft.AspNetCore.Mvc.JsonOptions>(o =>
o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// ABP-internal JSON: proxies, dynamic HTTP clients, integration events.
// Recommended for the same reason — without this, outbound JSON can still
// emit integers even though the REST surface emits strings.
context.Services.Configure<Volo.Abp.Json.SystemTextJson.AbpSystemTextJsonSerializerOptions>(o =>
o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
It's easy to forget one of the three layers (convention, MVC converter,
ABP converter) — and the failure mode is silent until a generated client
breaks. Use the bundled script to verify all three, and optionally patch
the *Module.cs in place:
# Report missing pieces, exit non-zero if any FAIL
python <skills-root>/abp-mongodb/scripts/verify_enum_string_setup.py
# Insert the missing snippets into *Module.cs and re-check
python <skills-root>/abp-mongodb/scripts/verify_enum_string_setup.py --fix
After applying the fix, re-run migrate-database so any seed-time
data that embedded enum values is rewritten with the new representation.
For pre-existing application data, the driver still reads old-format
documents (integers); subsequent writes use the new format. For a clean
cutover run a one-shot updateMany per collection mapping integers to
names — typical pattern:
db.Customers.find({ Status: { $type: "int" } }).forEach(doc => {
db.Customers.updateOne(
{ _id: doc._id },
{ $set: { Status: ["Prospect", "Active", "Churned"][doc.Status] } }
);
});
Make this a verification step on every feature that introduces an enum —
the skill abp-feature-dev calls this script automatically as part of
verify_feature.py, so a freshly scaffolded entity with enums will fail
verification until the convention + converters are in place.
Flipping representation does NOT back-fill existing documents. Pre-existing
records keep their old encoding until rewritten. For a clean cutover, run
a one-shot updateMany mapping the integer values to their string names,
or wait it out if the data churn is fast.
Localization keys must match the chosen representation. With string
storage, prefer name-based keys (Enum:CustomerStatus.Prospect) over
index-based (Enum:CustomerStatus.0). Index-based keys are brittle:
adding a member in the middle of the enum silently breaks all UI labels.
| Symptom | Likely cause |
|---|---|
| Saved entity not reflected in next read | Forgot UpdateAsync after mutation |
Where(...) query returns nothing despite data in DB | Multi-tenant filter (different TenantId) or soft-delete filter |
InvalidOperationException calling LINQ method | Operator not supported by Mongo LINQ — drop down to GetCollectionAsync() and Builders<T> |
| Repository injected but custom methods don't exist | You defined a custom interface but didn't register it — add AddRepository<T, TImpl>() (or pass --register-repository to the script) |
CollectionName mismatch | Some entities default to class name, some are configured — always set explicitly in CreateModel |
Compile error after Where(...) in a custom repo | LINQ provider returned IQueryable<T>; cast back to IMongoQueryable<T> for ToListAsync to work without ambiguity |
Whenever a Mongo-backed feature is modified or deleted, the changes touch multiple coordinates: collection registration in the DbContext, indexes, custom repositories, embedded references, and — most importantly — the data already in MongoDB. These are done by hand because the right answer depends on what else points at the collection.
| File | Change |
|---|---|
| Entity | Add property (no [BsonRepresentation] for enums — the convention handles it; see "Storing enums as strings") |
Get{Entities}Input.cs | Add nullable filter property |
{Entity}AppService.ApplyFilters | Add LINQ Where clause |
{Entity}IndexInitializer | Add index if the filter is high-cardinality / frequently used |
| Existing data | Field is missing on existing docs → LINQ null. The filter clause c.X.HasValue correctly skips them. Backfill if needed. |
CollectionNameThe driver name and the on-disk name diverge. Three options:
db.OldName.renameCollection("NewName"). Single-shot, downtime.[BsonElement] / b.CollectionName
to point at the old name. Cheaper.Always surface the data choice to the user — never run the rename silently. The reverse migration is not free.
Examples: int → long, string → Guid, or enum representation.
The driver reads whatever's in BSON and trusts the C# type. Mismatches
throw FormatException at deserialization time. Plan a migration:
// Example: backfill a missing field with a default
db.Customers.updateMany(
{ Country: { $exists: false } },
{ $set: { Country: "IT" } }
);
// Example: rename a field
db.Customers.updateMany({}, { $rename: { "LegalName": "RegisteredName" } });
// Example: integer enum → string enum (only needed if you previously
// stored as int and just now turned on EnumRepresentationConvention)
const statusNames = ["Prospect", "Active", "Churned"];
db.Customers.find({ Status: { $type: "int" } }).forEach(doc => {
db.Customers.updateOne(
{ _id: doc._id },
{ $set: { Status: statusNames[doc.Status] } }
);
});
When abp-feature-dev is removing a feature, the Mongo side is two
edits in *MongoDbContext.cs:
IMongoCollection<{Entity}> {Plural} propertymodelBuilder.Entity<{Entity}>(b => { ... }) block in
CreateModelThen delete (paths per template — --show-layout confirms them):
{{PROJECT_ROOT}}/Data/{Entity}IndexInitializer.cs,
layered {{DOMAIN_PROJECT}}/{Plural}/{Entity}IndexInitializer.cs (if it exists){{PROJECT_ROOT}}/Data/{Plural}/,
layered {{DATA_PROJECT}}/MongoDb/{Plural}/ (if any), plus its interface in
the Domain project on layeredAddRepository<{Entity}, ...> line in *Module.cs if the custom
repo was registeredThe MongoDB collection on disk stays. Surface to the user:
The Mongo collection `{Plural}` still contains N documents. To clean up:
db.{Plural}.drop()
This is destructive — only run when you're sure no other app reads it.
Don't run drop() automatically, even if Claude has direct Mongo access.
The user might have a backup workflow, an analytics pipeline, or another
service that still reads the collection.
After verify_enum_string_setup.py or any schema change, an old index
may become useless. Use db.{Plural}.dropIndex("name") — pass the
name, not the key spec. Indexes are inexpensive to recreate via
the IndexInitializer; the only cost is the first rebuild scan.
mongodb:* skills for that.mongodb:mongodb-schema-design.mongodb:mongodb-query-optimizer.This skill is specifically about the ABP integration layer: contexts, repositories, ABP-specific quirks.
npx claudepluginhub inno-bit-lab/ibl-agent-plugins --plugin ibl-abpGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.