A pragmatic service framework for structured and reactive data handling using Mongoose.
Fun · deer · ing — the Dutch word for foundation
- Description
- Installation
- Getting started
- Core concepts
- Querying
- Creating, updating and deleting
- Hooks
- Population
- Type casting
- Security: trusted query options
- Using fundering with NestJS
- Documentation and wiki
- License
Fundering is a thin service layer on top of Mongoose. It gives you a single place to keep the rules that should apply to every read and write of a model — authorization, censoring, validation, type casting, side effects — and exposes a small, composable set of CRUD methods that respect those rules by default.
The library does not replace Mongoose; it builds on top of the schemas and models you already have. Where Mongoose ships primitives (queries, middleware, virtuals), fundering ships a service abstraction with opinionated defaults:
CrudService<T>— an abstract class with create / find / count / upsert / merge / replace / delete methods that compile to a single MongoDB aggregation per call.- Reactive hooks —
onAuthorization,onCensor,preSave,postSave,preDelete,postDelete,postCount— declared as interface methods on your service class. - Cross-service awareness — services register themselves in a shared map, so authorization and censoring also apply when a related model is reached through a populated virtual.
- Dynamic population — populate by string or object, with deep nesting and per-level matching, sorting and limits.
- Type-cast conditions — query payloads coming from HTTP boundaries are cast against your Mongoose schema, so string
_idvalues becomeObjectIds,"true"becomestrue, and so on.
npm install fundering mongooseFundering declares mongoose (>= 8.9.5) as a peer dependency, so the version your application uses is the version fundering will use.
Extend CrudService and pass your Mongoose model to the constructor. The service registers itself for cross-service authorization and casting and immediately exposes the full CRUD surface.
import { CrudService } from 'fundering';
import { model } from 'mongoose';
import { IUser } from './user.interface';
import { userSchema } from './user.schema';
export class UsersService extends CrudService<IUser> {
constructor() {
super(model('User', userSchema));
}
}
const users = new UsersService();
const all = await users.find({});
const one = await users.findById('507f1f77bcf86cd799439011');The abstract class your services extend. Construction registers the service against the model's name in a static serviceMap and installs document hooks (save, deleteOne) on the model's prototype. Each model is wired exactly once, so creating multiple CrudService instances against the same model is safe.
The single options object passed to every read and write method. Its shape is:
| Field | Type | Purpose |
|---|---|---|
match |
Conditions<ModelType> |
Extra $match conditions appended via $and — can only further restrict results. |
sort |
string[] |
Sort fields. Prefix with - for descending (e.g. ['-createdAt', 'name']). |
skip |
number |
Documents to skip. |
limit |
number |
Maximum documents to return. |
select |
string[] |
Field projection, supports dot notation. |
populate |
(string | IPopulateOptions)[] |
Virtuals to populate; see Population. |
addFields |
Record<string, any> |
Extra $addFields stage available to subsequent stages. |
distinct |
string | string[] |
SQL-style distinct on one or more fields. |
random |
boolean |
Use $sample instead of $sort. Overrules sort. |
pipelines |
Record<string, any>[] |
Trusted-only. Extra aggregation stages appended after the main pipeline. |
disableAuthorization |
boolean |
Trusted-only. Skips the onAuthorization hook for the current call. |
session |
ClientSession |
Mongoose transaction session. |
maxTimeMS |
number |
Per-query timeout in milliseconds (default 10000). |
The two fields marked trusted-only are gated by the TRUSTED_QUERY_OPTIONS symbol; spreading raw HTTP input into options can never enable them. See Security: trusted query options.
The [key: string]: any index signature lets you carry context (e.g. an authenticated user) on the same options object that hooks receive.
Each CrudService adds itself to CrudService.serviceMap keyed by the Mongoose model name. Fundering uses this map to walk schema virtuals: when you query, populate or sort by a referenced model, the related service's onAuthorization and onCensor hooks are applied automatically.
The find methods compile the conditions and IQueryOptions into a single aggregation pipeline. The pipeline is, in order:
$matchforonAuthorization($expr, unlessdisableAuthorizationis honored).$set+$unsetforonCensor(static and conditional unsets).- Recursive
$lookupstages for any virtuals referenced inmatch,sortoraddFields. - The user's
$addFields. $matchfor the merged conditions (conditions+options.match).$sortor$sample(random).$skipand$limit.$projectto remove temporary virtual lookups.$projectforselect.- Recursive
$lookupstages forpopulate. - Any trusted user-supplied
pipelines.
service.find(conditions, options?); // Document<ModelType>[]
service.findOne(conditions, options?); // Document<ModelType> | null
service.findById(id, options?); // Document<ModelType> | null
service.findByIds(ids, options?); // Document<ModelType>[]
service.count(conditions, options?); // number
service.query<R>(conditions, options?); // R[] -- raw aggregation cursors, no hydrationfind* results are hydrated as Mongoose documents, with related virtuals (looked up through populate) hydrated against their owning service. query returns the raw aggregation output and is the right tool when you only need projected scalars.
Because populated relations participate in the same pipeline, you can match and sort across them:
class UsersService extends CrudService<IUser> {
constructor() { super(model('User', userSchema)); }
getPopulatedGroups() {
// populate the user's group virtual
return this.find({}, { populate: ['group'] });
}
getByGroupName(name: string) {
// match on a populated relation's field, sorted by the relation's createdAt
return this.find(
{ 'group.name': name },
{ sort: ['-group.createdAt'] }
);
}
getFiveUniqueNames(options?: IQueryOptions<IUser>) {
// safe to spread caller-supplied options -- match/addFields/populate
// can only further restrict, never bypass auth
return this.find({}, {
...options,
distinct: 'firstName',
random: true,
limit: 5,
});
}
}count runs the same pipeline as find (so authorization and censoring still apply) but appends $count and clamps the result to a maximum scan of 10,000 documents. The postCount hook fires with the resulting number.
| Method | Behaviour |
|---|---|
create(payload, options?) |
Inserts one document. Triggers preSave / postSave. |
createMany(payloads, options?) |
Inserts many in a transaction. Rolls back on failure. |
upsertModel(payload, options?) |
Updates by _id / id, inserts otherwise. |
upsertModels(payloads, options?) |
Bulk upsert in a transaction. |
upsert(conditions, payload, options?) |
Replace by conditions; create if no match. |
replaceModel(payload, options?) |
Replace by _id / id. Throws if no match. |
replaceModels(payloads, options?) |
Bulk replace in a transaction. |
replace(conditions, payload, options?, mergeCallback?) |
Replace all matches. Optional mergeCallback to customise per-document merging. |
mergeModel(payload, options?) |
Deep-merge by _id / id. Throws if no match. |
mergeModels(payloads, options?) |
Bulk merge in a transaction. |
merge(conditions, payload, options?) |
Deep-merge all matches. |
deleteById(id, options?) |
Delete by id. Triggers preDelete / postDelete. |
delete(conditions, options?) |
Delete all matches sequentially so hooks run per-document. |
Bulk methods that mutate (createMany, upsertModels, replaceModels, mergeModels) start a transaction when one isn't already provided in options.session. Any thrown error aborts the transaction and bubbles up. This means MongoDB must be running as a replica set (or via Atlas) for bulk methods to work.
deepMerge skips __proto__, constructor and prototype keys to prevent prototype pollution from untrusted payloads.
The options object passed in is dereferenced (cloned at the top level) and stored on the document's $locals.options before save / delete. Hook implementations therefore receive the same options reference the caller passed, including any custom fields you added (like { user }). This is the canonical way to thread request context through to authorization and middleware.
Implement the matching interface on your service class. The presence of the method is enough — fundering picks it up via getHook / callHook. Hooks may be sync or async.
class UsersService extends CrudService<IUser> implements IOnAuthorization<IUser> {
async onAuthorization(options: IQueryOptions<IUser>): Promise<Expression> {
// restrict every find to the caller's own document
return { $eq: ['$_id', options.user?._id] };
}
}The expression is appended as { $match: { $expr: ... } } to every read pipeline, including reads triggered by populating a virtual that points to this service. Returning {} (or omitting the implementation) means "no extra restrictions". The IQueryOptions argument lets you build context-sensitive rules without globals.
Returns the field paths to remove from results, evaluated server-side via $unset. Censored fields are also removed from populated relations.
class UsersService extends CrudService<IUser> implements IOnCensor<IUser> {
onCensor(options: IQueryOptions<IUser>) {
const isAdmin = options.user?.role === 'admin';
return [
'passwordHash', // always censored
['email', { $ne: ['$_id', options.user?._id] }], // censored unless self
...(isAdmin ? [] : ['internalNotes']),
];
}
}The $unset runs before addFields and match, so the user can't materialise censored values back via either field.
class UsersService extends CrudService<IUser> implements IPreSave<IUser> {
async preSave(payload: Document<IUser>, options: IQueryOptions<IUser>) {
if (payload.isModified('password')) {
payload.password = await encrypt(payload.password);
}
}
}postSave additionally receives a prevState parameter — the document as it was before the save — for diffing, audit logs and the like. Fundering only refetches the previous state when a postSave hook is actually defined, so there's no cost when you don't use it.
class UsersService extends CrudService<IUser> implements IPreDelete<IUser> {
async preDelete(existing: Document<IUser>, options: IQueryOptions<IUser>) {
if (existing.isProtected) {
throw new Error('Cannot delete protected user');
}
}
}Fires after each count call with the resulting number; useful for analytics or cache priming.
Populate using either string paths or fully specified objects:
// shorthand: populate two virtuals at the root level
service.find({}, { populate: ['group', 'avatar'] });
// deep population via dot notation
service.find({}, { populate: ['group.owner'] });
// object form: per-level match, sort, limit, select, projection, recursion
service.find({}, {
populate: [{
path: 'comments',
sort: ['-createdAt'],
limit: 10,
populate: [{ path: 'author', select: ['name'] }],
}],
});Population only resolves virtuals declared on the schema, and the related service's onAuthorization + onCensor are applied at every level.
Conditions passed to find, findOne, count, etc. are cast against the Mongoose schema. This is what makes it safe to forward query strings from an HTTP layer:
string→ObjectIdforobjectidschema fields where the value is a valid hex.string→number,boolean,Datefor the matching primitive types.- Operator-aware:
$existscasts to boolean,$sizeto number,$in/$nin/$allcast each element. - Recurses through
$and,$or,$not,$nor.
A request like { _id: { $in: ["507f...", "507e..."] } } arriving as JSON will be cast to real ObjectId instances before the aggregation runs.
Two fields on IQueryOptions can change the security posture of a query:
disableAuthorization— skip theonAuthorizationhook for this call.pipelines— append arbitrary aggregation stages after the authorization$match, which can therefore override it.
Both fields are gated by the TRUSTED_QUERY_OPTIONS symbol. The library only honors them when the options object carries this symbol as a property. The symbol cannot be expressed in JSON or URL-encoded form, so spreading req.body or req.query directly into options can never enable either field — the values are silently ignored.
import { TRUSTED_QUERY_OPTIONS } from 'fundering';
// internal admin tooling: explicitly opt in
service.find(conditions, {
[TRUSTED_QUERY_OPTIONS]: true,
disableAuthorization: true,
});
service.find(conditions, {
[TRUSTED_QUERY_OPTIONS]: true,
pipelines: [{ $group: { _id: '$tenantId', total: { $sum: 1 } } }],
});Library-internal call sites that legitimately need to bypass the gate (such as the post-create refetch in create, and the count pipeline in count) attach the marker to the options they construct — never to the caller-supplied options.
The other "extension" fields — match, addFields, populate — are designed to be safe to spread from untrusted input:
matchis appended via$and, so it can only further restrict results.addFieldsruns after the censor$unset, so it cannot resurrect censored fields.populateonly resolves schema-declared virtuals and the populated service'sonAuthorizationandonCensorstill apply.
Pair fundering with nest-utilities for a CRUD-style controller layer that maps HTTP query strings to IQueryOptions and exposes consistent endpoints with minimal boilerplate.
Deeper guides live in the project wiki:
Issues and feature requests: https://github.com/MartinDrost/fundering/issues.
ISC.
Made by Martin Drost — Buy me a ☕