From Prisma
Implement the MongoDB persistence layer for a hexagonal architecture TypeScript project. Use this skill whenever the user needs to create or modify a MongoDB repository implementation, MongoDB document type, or entity mapper for MongoDB. Trigger when the user says things like "create a MongoDB repo for Foo", "add a mapper for Bar", "I need to persist Baz to MongoDB", "write the document type for Foo", "create the MongoDB repository and mapper", "wire up Foo to MongoDB", or whenever the project uses MongoDB and the storage layer for an entity is missing or incomplete. Also trigger when the user modifies an entity and the MongoDB persistence files need to stay in sync, or when they reference MongoDB-specific repo/mapper/document files by name. For the generic repository interface and mapper pattern (DB-agnostic), use the persistence skill instead.
How this skill is triggered — by the user, by Claude, or both
Slash command
/Prisma:mongodb-persistenceThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
**Installation:** If not already installed, add the required packages:
Installation: If not already installed, add the required packages:
pnpm add @efesto-cloud/entity (for IEntityMapper interface)pnpm add @efesto-cloud/maybe (for nullable results)pnpm add @efesto-cloud/mongodb-database-context (for transaction support)Helps you build the persistence layer — document type, repository interface, implementation, and mapper — for a hexagonal architecture TypeScript/MongoDB project following the ports-and-adapters pattern.
Scope: document type, repository interface, repository implementation, mapper. Population (the system for eager-loading related entities via aggregation pipelines) is a separate concern with its own skill — this skill only declares the hook for it where needed, without implementing the internal machinery.
Assumes the entity class and DTO interface already exist and are correct. Read them before writing any persistence code.
src/repo/, src/repo/impl/, src/mapper/, src/db/Documents/. Match the existing code style: import order, mapper shape, whether they use Foo.create() or new Foo() in mappers.references/simple-repo-example.ts — no population, uses .find()/.findOne() directlyreferences/aggregate-repo-example.ts — optional population, aggregation pipeline, streaming, bulk savereferences/di-wiring-example.md — the exact 4 files to update for DI registrationFor a new entity Foo stored in collection foo:
| File | Location |
|---|---|
| Document type | src/db/Documents/FooDocument.ts |
| Repository interface | src/repo/IFooRepo.ts |
| Repository implementation | src/repo/impl/FooRepoImpl.ts |
| Mapper | src/mapper/FooMapper.ts |
Plus 4 existing files to update for DI registration (see DI Wiring).
The document type is the MongoDB-level view of the entity. It's a pure TypeScript type alias — not a class, not a schema.
// src/db/Documents/FooDocument.ts
import { ObjectId } from "mongodb";
import IFoo from "~/dto/IFoo.js";
import BarDocument from "./BarDocument.js";
type FooDocument = Overwrite<IFoo, {
_id: ObjectId; // DTO has string → document has ObjectId
deleted_at: Date | null; // DTO has string|null → document has JS Date|null
owner_id: ObjectId | null; // FK: DTO string → document ObjectId
bar?: BarDocument | null; // optional — only present after aggregation $lookup
items?: ItemDocument[]; // optional array — only after aggregation
}>;
export default FooDocument;
Rules:
Overwrite<IDTO, {...}> and override only what differs from the DTO.Omit<IFoo, "computed_field"> before overriding: Overwrite<Omit<IFoo, "labels">, {...}>._id is always ObjectId.DateTime in entity/DTO becomes Date in document; FK fields (*_id) become ObjectId.?) — they're absent on raw stored documents, present only when an aggregation pipeline joined them.type XDocument = ADocument | BDocument.// src/repo/IFooRepo.ts
import Maybe from "@efesto-cloud/maybe";
import { ObjectId } from "mongodb";
import Foo from "~/entity/Foo.js";
// Export the search query type alongside the interface
export type SearchFoo = {
name?: string;
include_deleted?: boolean;
};
interface IFooRepo {
search(query: SearchFoo): Promise<Foo[]>; // empty array when nothing matches
get(id: ObjectId): Promise<Maybe<Foo>>; // Maybe.none() when not found
save(entity: Foo): Promise<void>;
}
export default IFooRepo;
Return type guide:
| Scenario | Return type |
|---|---|
| Nullable single result | Promise<Maybe<T>> |
| Multiple results | Promise<T[]> — never Maybe; empty array is fine |
| Write | Promise<void> |
| Count | Promise<number> |
| Large result set | Readable (stream) |
When population is needed — declare an options parameter and a namespace with an Options type. The population skill handles everything else; the interface just exposes the hook:
import type { Populate } from '@efesto-cloud/population';
import type { FooShape } from './shape/FooShape.js';
interface IFooRepo {
search(query: SearchFoo, options?: IFooRepo.Options): Promise<Foo[]>;
get(id: ObjectId, options?: IFooRepo.Options): Promise<Maybe<Foo>>;
save(entity: Foo): Promise<void>;
}
namespace IFooRepo {
export type Options = {
populate?: Populate<FooShape>; // Populate + FooShape come from the population system
};
}
// src/repo/impl/FooRepoImpl.ts
import Maybe from "@efesto-cloud/maybe";
import { inject, injectable } from "inversify";
import { Collection, Filter, ObjectId } from "mongodb";
import type IDatabaseContext from "~/db/Context/IDatabaseContext.js";
import FooDocument from "~/db/Documents/FooDocument.js";
import Symbols from "~/di/Symbols.js";
import Foo from "~/entity/Foo.js";
import FooMapper from "~/mapper/FooMapper.js";
import IFooRepo, { SearchFoo } from "../IFooRepo.js";
@injectable()
export default class FooRepoImpl implements IFooRepo {
constructor(
@inject(Symbols.Collections.foo) private readonly coll: Collection<FooDocument>,
@inject(Symbols.DatabaseContext) private readonly db: IDatabaseContext,
) {}
async search(query: SearchFoo): Promise<Foo[]> {
const filter: Filter<FooDocument> = {};
if (query.name) filter.name = new RegExp(`^${query.name}`, "i");
if (!query.include_deleted) filter.deleted_at = null;
const docs = await this.coll
.find(filter, { session: this.db.session }) // session is required — wires into transactions
.sort({ name: 1 })
.toArray();
return docs.map(FooMapper.from);
}
async get(id: ObjectId): Promise<Maybe<Foo>> {
const doc = await this.coll.findOne({ _id: id }, { session: this.db.session });
return Maybe.maybe(doc).map(FooMapper.from);
}
async save(entity: Foo): Promise<void> {
const raw = FooMapper.to(entity);
await this.coll.updateOne(
{ _id: raw._id },
{ $set: raw },
{ upsert: true, session: this.db.session },
);
}
}
The session rule — every MongoDB driver call must include { session: this.db.session }. Without it, the call silently runs outside any active transaction. IDatabaseContext exposes session as undefined when there's no transaction; the MongoDB driver ignores undefined gracefully, so it's always safe to pass.
Simple vs. aggregate reads:
.find()/.findOne() for straightforward queries with no population.XQueryBuilder. The query builder is part of the population system — see references/aggregate-repo-example.ts for the pattern.The mapper is a plain object (not a class) that transforms between entity and document.
// src/mapper/FooMapper.ts
import { IEntityMapper } from "@efesto-cloud/entity";
import { DateTime } from "luxon";
import FooDocument from "~/db/Documents/FooDocument.js";
import Foo from "~/entity/Foo.js";
const FooMapper: IEntityMapper<Foo, FooDocument> = {
/**
* from: document → entity (read path)
* Convert MongoDB types to domain types. Patch in populated sub-documents after construction.
*/
from: (doc: FooDocument): Foo => {
const entity = new Foo({
name: doc.name,
owner_id: doc.owner_id,
deleted_at: doc.deleted_at
? DateTime.fromJSDate(doc.deleted_at) as DateTime<true>
: null,
bar: null, // default; overwritten below if the pipeline populated it
}, doc._id);
if (doc.bar) entity.props.bar = BarMapper.from(doc.bar);
if (doc.items) entity.props.items = doc.items.map(ItemMapper.from);
return entity;
},
/**
* to: entity → document (write path)
* Only include fields the collection actually stores. Populated join results are never written back.
*/
to: (domain: Foo): FooDocument => ({
_id: domain._id,
name: domain.props.name,
owner_id: domain.props.owner_id,
deleted_at: domain.deleted_at?.toJSDate() ?? null,
}),
};
export default FooMapper;
from vs. to asymmetry — from is read-time and may encounter in-place populated sub-documents from an aggregation; construct the entity, then patch them in. to is write-time; serialize only own stored scalar fields and FK ObjectIds — never populated relations.
new Foo() vs. Foo.create() — in mappers, the direct constructor is appropriate because you have complete, already-validated stored state and don't need create()'s defaults. Use Foo.create() in use cases where you're working with partial user input.
Two mapper styles in the wild — some projects use namespace FooMapper { export function from(...) }. Match what already exists in the project.
Touch these 4 files when adding a new collection. See references/di-wiring-example.md for exact code snippets.
src/enum/CollectionNameEnum.ts — add foo = "foo"src/db/ICollectionsDocument.ts — add foo: FooDocumentsrc/di/Symbols.ts — add FooRepo: Symbol.for("FooRepo") in the Repo sectionsrc/di/container.ts — add container.bind(Symbols.Repo.FooRepo).to(FooRepoImpl).inRequestScope()Symbols.Collections is auto-generated from CollectionNameEnum — you do not need to touch it manually.
Entity has deleted_at: DateTime<true> | null. Records stay in the collection; filtered omitting deleted by default.
// In save():
if (entity.isDeleted()) {
await this.coll.updateOne(
{ _id: raw._id },
{ $set: { deleted_at: entity.deleted_at!.toJSDate() } },
{ session: this.db.session },
);
} else {
await this.coll.updateOne({ _id: raw._id }, { $set: raw }, { upsert: true, session: this.db.session });
}
// In search() filter:
if (!query.include_deleted) filter.deleted_at = null;
if (entity.isDeleted()) {
await this.coll.deleteOne({ _id: raw._id }, { session: this.db.session });
} else {
await this.coll.updateOne({ _id: raw._id }, { $set: raw }, { upsert: true, session: this.db.session });
}
saveManyimport prepareBulkOps from "~/db/prepareBulkOps.js";
async saveMany(entities: Foo[]): Promise<void> {
const ops = prepareBulkOps(entities, FooMapper);
if (!ops.length) return;
await this.coll.bulkWrite(ops, { session: this.db.session });
}
When a parent's save() must also persist child entities in their own collection:
constructor(
@inject(Symbols.Collections.foo) private readonly coll: Collection<FooDocument>,
@inject(Symbols.Repo.BarRepo) private readonly barRepo: IBarRepo, // inject child repo
@inject(Symbols.DatabaseContext) private readonly db: IDatabaseContext,
) {}
async save(entity: Foo): Promise<void> {
const raw = FooMapper.to(entity);
await this.coll.updateOne({ _id: raw._id }, { $set: raw }, { upsert: true, session: this.db.session });
await this.barRepo.saveMany(entity.bars); // delegate to child repo — same transaction session
}
When one MongoDB collection stores multiple entity variants:
// Document: discriminated union
type FooDocument = FooADocument | FooBDocument; // each has `type: "A" | "B"` discriminator
// Mapper: dispatch on doc.type
from: (doc: FooDocument): Foo => {
if (doc.type === "A") return FooAMapper.from(doc as FooADocument);
if (doc.type === "B") return FooBMapper.from(doc as FooBDocument);
throw new Error(`Unknown Foo type: ${(doc as any).type}`);
}
Use a Readable with cursor iteration rather than .toArray() to avoid loading the full collection into memory. See references/aggregate-repo-example.ts for the complete streaming implementation pattern.
FooDocument.ts created with Overwrite<IFoo, {_id: ObjectId; ...}>IFooRepo.ts created with interface + exported search typeFooRepoImpl.ts created — @injectable(), every call passes { session: this.db.session }FooMapper.ts created — from() handles optional populated fields; to() only own stored scalarsCollectionNameEnum updatedICollectionsDocument updatedSymbols.Repo.FooRepo addedcontainer.bind(...).inRequestScope() addedFooDocument + FooMapper.from() + FooMapper.to() + interface if the signature changesCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub efesto-cloud/lib --plugin Prisma