ADR-0018 — A Mongoose model is the entity identity, and ObjectId converts at the adapter boundary
Status: accepted
Context
Two things every ORM adapter must supply collide with MongoDB in ways the first two adapters did not have to face.
Entity identity. createCrud(entity, …) (ADR-0011) needs a ClassRef<Entity> — a real runtime constructor — as an entity's identity: it is the key metadataFor/adapterFor cache by, and the key the whole KavoInstance map holds its per-entity registry under. @kavo/typeorm gets one for free from @Entity() classes; @kavo/prisma cannot, because Prisma generates only erased types, so ADR-0017 makes callers declare an empty marker class per model plus an explicit entities registry. Reaching for that same construction here would be the natural move — a third adapter, a third copy of the marker-class tax.
The primary key. Core's EntityId is string | number and FieldKind is a deliberately coarse six-member union (ADR-0011). MongoDB's _id is a 12-byte ObjectId, which is neither. Widening core to admit it would put an ORM-specific type into the package that is supposed to know no ORM (ADR-0001, ADR-0005), and would oblige every other adapter and every consumer to handle a case only MongoDB can produce.
Decision
A Mongoose model is the ClassRef. mongoose.model(...) returns a constructor, so it satisfies ClassRef<T> = abstract new (...args: never[]) => T directly. @kavo/mongoose therefore declares no marker classes and takes no entities list: createInfrastructure(connection) reads connection.models, which is the name→model registry Prisma has to ask the caller to rebuild by hand. Anything satisfying { models } is accepted — mongoose, mongoose.connection, or a createConnection() handle.
Two edges follow from that:
- Names come from
modelName, neverentity.name. A Mongoose model's functionnameis the useless string"model".buildEntityMetadatareadsmodelName;entity.nameis never consulted. - A non-model entity fails loudly. No type-level check can tell a Mongoose model from any other class, so
asModelguards at runtime and raises aConfigurationExceptionnaming the entity.
ObjectId converts at the adapter boundary, and core does not change.EntityId stays string | number; FieldKind gains no objectId member; _id is reported as kind: "string".
- Outbound: every
ObjectIdleaving the adapter becomes its hex string. The conversion recurses, because populated relations carry their own_ids and a to-manyrefarray is an array of them. - Inbound: nothing to do. Mongoose casts query and write values against the schema, so a hex string handed to
{ _id: id }becomes anObjectIdwithout this package ever constructing one — which is why@kavo/mongooseneeds nobsonimport. - Malformed: an id that is not a valid
ObjectIdraises a MongooseCastError, mapped toNotFoundException. A malformed id names a document that cannot exist, so it is answered exactly like a well-formed id that isn't there — which is also what@kavo/typeormdoes for a non-matching integer id, and avoids making the endpoint an oracle for the key format. ACastErroron any other path is a coercion failure and maps toKAVO_QUERY_INVALID_VALUE(400), per doc 06.
Consequences
- Setting up
@kavo/mongoosecosts nothing beyond the models an app has already declared — no marker classes, no entity list, nothing to keep in sync with the schema. This is the payoff for Mongoose keeping real runtime constructors where Prisma does not. - The identity claim is a type claim, so it is pinned by a compile-only test (
tests/types/model-as-class-ref.test-d.ts). If a future Mongoose release drops the construct signature fromModel, the build breaks — which is the correct alarm, because this ADR's premise would be gone. - Entities are keyed on
_id, and that name reaches the wire: responses carry_id, notid. Mongoose'sidvirtual is not used — virtuals are absent fromschema.pathsand are not applied to theleanreads this adapter issues, so honouring it would mean inventing a field the metadata seam cannot see. EntityMetadata.softDeleteFieldis alwaysnullfrom this adapter — Mongoose declares no@DeleteDateColumnequivalent — so soft delete is always explicitsoftDelete.fieldconfiguration here, never auto-detected. Same position as@kavo/prisma(ADR-0017).- Because the id is a string on the wire, an entity's id sorts and compares lexicographically wherever core treats it as an ordinary value. That is already true of any string-keyed entity under the other adapters.