When you work on growing Node.js backends with MongoDB, every collection—Tags, Notes, Users, etc.—ends up needing the same basic CRUD logic.
Yet wiring up filters, pagination, projection, sorting, sessions, and population for each model is boilerplate city.
In this post, we’ll explore a functional approach to creating a single, generic Base Repository factory in TypeScript that:
- Enforces strong typing on filters, projections, and sorts
- Supports pagination, filtering, sorting, population, and sessions
- Is extension-friendly—add cursor pagination, full-text search, or aggregation down the road
- Keeps your code DRY and your team happy
The Problem: Boilerplate Explosion
A typical getAll
method for a TagDocument
might look like this:
async getAll(userId: ID, session?: ClientSession): Promise<TagDocument[]> {
return unwrap(
await mongo.fire(() =>
tagModel.find({ user: userId }, null, { session })
)
);
}
But once you need to:
- Add pagination (
.skip()
/.limit()
) - Add sorting (
.sort()
) - Support projection (
.find(filter, projection)
) - Attach populate()
- Thread through a ClientSession
…you’ll end up duplicating this logic across every repository. Worse, your method signatures balloon with extra parameters and overloads.
The Solution: createBaseRepository
Instead of repeating yourself, we build a factory:
import type {
ClientSession,
FilterQuery,
HydratedDocument,
Model,
PopulateOptions,
ProjectionType,
SortOrder,
} from "mongoose";
import { mongo } from "./mongo.config";
import { type CommandResult, unwrap } from "./global";
// 1) Pagination & sorting types
export interface PaginationOptions {
page?: number;
pageSize?: number;
}
export type SortBy<T> =
| Partial<Record<Extract<keyof T, string>, SortOrder>>
| [Extract<keyof T, string>, SortOrder][];
// 2) Fully-typed GetAll options
export interface GetAllOptions<T, Doc extends HydratedDocument<T>> {
filter?: FilterQuery<Doc>;
projection?: ProjectionType<Doc>;
pagination?: PaginationOptions;
sort?: SortBy<T>;
session?: ClientSession;
populate?: PopulateOptions | PopulateOptions[];
}
// 3) The factory function
export const createBaseRepository = <T, Doc extends HydratedDocument<T>>(
model: Model<T, {}, Doc>
) => ({
async getAll<F extends FilterQuery<Doc>>(
opts: GetAllOptions<T, Doc>
): Promise<Doc[]> {
const {
filter = {} as F,
pagination: { page = 1, pageSize = 10 } = {},
projection,
populate,
sort,
session,
} = opts;
let query = model.find(filter, projection ?? null, { session });
// Pagination
query = query.skip((page - 1) * pageSize).limit(pageSize);
// Sorting
if (sort) {
if (Array.isArray(sort)) {
query = query.sort(sort);
} else {
query = query.sort(sort as Record<string, SortOrder>);
}
}
// Population
if (populate) {
query = query.populate(populate);
}
// Execute & unwrap
const res = (await mongo.fire(() => query)) as CommandResult<Doc[]>;
return unwrap(res);
},
});
How It Works
- Generic Types
-
T
is your raw schema interface (e.g.Note { title: "string; … }
)." -
Doc extends HydratedDocument<T>
is the actual Mongoose document type.
GetAllOptions
-
filter
: apply any Mongoose‐style filter. -
projection
: include/exclude fields. -
pagination
: page number & page size. -
sort
: strongly-typed sort by any schema field. -
session
: thread aClientSession
for transactions. -
populate
: standard Mongoose populates.
- Functionality
- We build a single
model.find()
query, then chain.skip()
,.limit()
,.sort()
, and.populate()
. - We
await mongo.fire(() => query)
(your wrapper for connection management) andunwrap()
the result.
Extending the Base Repo in Your Interfaces
If you’re using interfaces for your repositories, you can easily extend the base repo’s shape:
export interface INotesRepository
extends ReturnType<typeof createBaseRepository<Note, NoteDocument>> {
// add any note-specific methods here
create(
title: "string,"
content: string,
user: string,
session?: ClientSession
): Promise<NoteDocument>;
}
Then implement it:
export const notesRepository: INotesRepository = {
...createBaseRepository<Note, NoteDocument>(noteModel),
async create(title, content, user, session) {
const doc = new noteModel({ title, content, user });
const res = (await mongo.fire(() => doc.save({ session }))) as CommandResult<NoteDocument>;
return unwrap(res);
},
};
Future‑Proofing: Aggregations & Text Search
Since all your query configuration lives in GetAllOptions, you can add new capabilities later without altering your repo signatures across models. For example:
export interface GetAllOptions<T, Doc extends HydratedDocument<T>> {
filter?: FilterQuery<Doc>;
projection?: ProjectionType<Doc>;
pagination?: PaginationOptions;
sort?: SortBy<T>;
session?: ClientSession;
populate?: PopulateOptions | PopulateOptions[];
// new feature flags:
search?: string;
useCursor?: boolean;
aggPipeline?: PipelineStage[];
}
Inside your getAll implementation, you can then branch on opts.search, build a text‑search filter, or run an aggregation if aggPipeline is provided—all in the same method. Your service layer stays consistent, and your developers get one place to learn and extend.
Benefits
- DRY & Maintainable: all shared logic in one place.
- Fully Typed: TypeScript catches invalid filters, projections, or sorts.
-
Future-Proof: add support for cursor pagination, full-text search, or aggregation by extending
GetAllOptions
and the factory once. - Consistent API: every repository method shares the same signature style.
Conclusion
By using a generic, functional createBaseRepository
, you gain a lean, consistent, and future-ready foundation for all your Mongoose models. No more copy/paste CRUD; just build once, extend everywhere—and keep your codebase clean, fast, and scalable.
If you have questions drop a comment below! 💡
Let’s connect!!: 🤝