Building a Scalable Base Repository with TypeScript & Mongoose 🔥
Ali nazari

Ali nazari @silentwatcher_95

About: Just a tech

Location:
Earth 🌍
Joined:
Jul 30, 2022

Building a Scalable Base Repository with TypeScript & Mongoose 🔥

Publish Date: May 27
9 0

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.

sad cry

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 })
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
  },
});
Enter fullscreen mode Exit fullscreen mode

How It Works

  1. Generic Types
  • T is your raw schema interface (e.g. Note { title: "string; … })."
  • Doc extends HydratedDocument<T> is the actual Mongoose document type.
  1. 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 a ClientSession for transactions.
  • populate: standard Mongoose populates.
  1. 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) and unwrap() 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>;
}
Enter fullscreen mode Exit fullscreen mode

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);
  },
};
Enter fullscreen mode Exit fullscreen mode

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[];
}
Enter fullscreen mode Exit fullscreen mode

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!!: 🤝

LinkedIn
GitHub

Comments 0 total

    Add comment