Node / Express Guide

Repositories

The repository pattern — data access with MongoDB native driver and Redis caching.

Repositories are the only layer that touches MongoDB and Redis. Every collection has a dedicated repository built on a generic useRepo base.

TL;DRuseRepo<T>(resource) provides generic CRUD with auto-caching. useAtlas is a static class (useAtlas.getDb()). paginate(items, page, limit, total) returns { items, pages, pageRange }. Each write invalidates the cache group automatically.

Base Repository — useRepo

src/repositories/base.repo.ts provides generic CRUD operations with built-in Redis cache management:

// src/repositories/base.repo.ts (simplified)
import {
  buildCacheKey,
  ConflictError,
  InternalServerError,
  NotFoundError,
  paginate,
  toObjectId,
  useAtlas,
  useCache,
} from "@codisolutions23/node-utils";
import type { ClientSession, Document, Filter, ObjectId } from "mongodb";

export function useRepo<T extends Document>(resource: string) {
  const { delCache, delCacheGroup, getCache, setCache } = useCache();

  function getCollection() {
    const db = useAtlas.getDb(); // static class — not useAtlas().getDb()
    if (!db) throw new InternalServerError("Database is not connected.");
    return db.collection<T>(resource);
  }

  async function add(
    doc: T,
    session?: ClientSession,
    customResource?: string,
    additionalCacheGroups?: string[], // invalidate related collections
  ) {
    const result = await getCollection().insertOne(doc, { session });
    await delCacheGroup(resource);
    if (additionalCacheGroups) {
      await Promise.all(additionalCacheGroups.map((g) => delCacheGroup(g)));
    }
    return result.insertedId; // returns ObjectId, not the doc
  }

  async function getAll(
    pipeline: object[] = [],
    page?: number,
    limit?: number,
    cacheOptions?: Record<string, any>,
  ) {
    const cacheKey = buildCacheKey(`${resource}:all`, {
      page,
      limit,
      ...cacheOptions,
    });
    const cached = await getCache(cacheKey);
    if (cached !== null) return cached;

    if (page !== undefined && limit !== undefined) {
      // Single $facet query — data + count in one round trip
      const [facetResult] = await getCollection()
        .aggregate([
          ...pipeline,
          {
            $facet: {
              data: [
                { $skip: Math.max((page - 1) * limit, 0) },
                { $limit: limit },
              ],
              count: [{ $count: "total" }],
            },
          },
        ])
        .toArray();

      const items = facetResult.data || [];
      const total = facetResult.count[0]?.total ?? 0;
      const result = paginate(items, page, limit, total);
      await setCache(cacheKey, result, CACHE_SHORT_TTL, resource);
      return result;
    }

    // No pagination — return all items
    const items = await getCollection().aggregate(pipeline).toArray();
    const result = { items };
    await setCache(cacheKey, result, CACHE_SHORT_TTL, resource);
    return result;
  }

  async function getById(
    _id: string | ObjectId,
    pipeline: object[] = [],
    throwOnEmpty = true,
  ) {
    const cacheKey = buildCacheKey(`${resource}:id`, { id: _id });
    const cached = await getCache(cacheKey);
    if (cached !== null) return cached;

    const objectId = toObjectId(_id);
    const result = await getCollection()
      .aggregate([{ $match: { _id: objectId } }, ...pipeline])
      .toArray();

    if (result.length === 0 && throwOnEmpty) {
      throw new NotFoundError(`${resource} not found.`);
    }

    const item = result[0] ?? null;
    if (item) await setCache(cacheKey, item, CACHE_SHORT_TTL, resource);
    return item;
  }

  async function update(
    _id: string | ObjectId,
    update: Partial<T>,
    session?: ClientSession,
    customResource?: string,
    additionalCacheGroups?: string[],
  ) {
    const objectId = toObjectId(_id);
    const res = await getCollection().updateOne(
      { _id: objectId } as Filter<T>,
      { $set: { ...update, updatedAt: new Date() } },
      { session },
    );
    await delCacheGroup(resource);
    if (additionalCacheGroups) {
      await Promise.all(additionalCacheGroups.map((g) => delCacheGroup(g)));
    }
    return res.modifiedCount; // returns count, not the doc
  }

  async function softDelete(_id: string | ObjectId, session?: ClientSession) {
    // Sets status='deleted' and deletedAt via deleteMany('soft')
  }

  return {
    createIndex,
    createIndexes,
    add,
    addMany,
    getAll,
    getAllDirect,
    getGroupedAll,
    getById,
    getField,
    getFields,
    getByPipeline,
    exists,
    update,
    updateByField,
    updateByFilter,
    updateMany,
    softDelete,
    softDeleteByField,
    softDeleteMany,
    hardDelete,
    hardDeleteByField,
    hardDeleteMany,
    deleteMany,
  };
}
useAtlas is a static class — always useAtlas.getDb(), never useAtlas().getDb().

Key Return Values

MethodReturns
add()ObjectId (insertedId)
addMany()Record<number, ObjectId> (insertedIds)
getAll(){ items, pages, pageRange } (paginated) or { items } (unpaginated)
getById()Document or null (throws NotFoundError by default)
update()number (modifiedCount)
softDelete()number (modifiedCount)

paginate Signature

paginate(items, page, limit, total);
// → { items: T[], pages: number, pageRange: '1-10 of 42' }

useCache — Four Methods

const { getCache, setCache, delCache, delCacheGroup } = useCache();

await setCache(key, value, ttlSeconds, groupName); // groupName enables delCacheGroup
await getCache(key); // returns cached value or null
await delCache(key); // delete single key
await delCacheGroup(groupName); // delete all keys in the group

Duplicate Key Handling

The base repo has a built-in handleDuplicateKey helper that catches MongoDB error code 11000 and throws a descriptive ConflictError:

// Automatically called in add(), addMany(), update(), updateByField(), etc.
// → ConflictError: "A user with this email (john@example.com) already exists."

Additional Cache Group Invalidation

When a write in one collection should also bust caches of related collections:

// e.g., updating a branch also invalidates organization caches
await base.update(id, data, session, "branches", ["organizations"]);

Specific Repository

Each collection wraps the base repo with typed, domain-specific query methods:

// src/repositories/resource.repo.ts
import { useRepo } from "./base.repo";
import { buildCacheKey } from "@codisolutions23/node-utils";
import { CollectionName } from "../enums/db-collections.enum";
import type { IResource, TResourceCreate } from "../models/resource.model";
import { Resource } from "../models/resource.model";
import { ObjectId } from "mongodb";
import type { ClientSession } from "mongodb";

export function useResourceRepo() {
  const base = useRepo<IResource>(CollectionName.RESOURCES);

  function createResource(value: TResourceCreate, session?: ClientSession) {
    return base.add(new Resource(value) as any, session);
  }

  function getByName(name: string, organizationId: string) {
    return base.getField(
      "name",
      name,
      [
        {
          $match: {
            organizationId: new ObjectId(organizationId),
            deletedAt: null,
          },
        },
      ],
      false,
    ); // throwOnEmpty = false
  }

  function getResources({
    organizationId,
    page = 1,
    limit = 10,
  }: {
    organizationId: string;
    page?: number;
    limit?: number;
  }) {
    const pipeline = [
      {
        $match: {
          organizationId: new ObjectId(organizationId),
          deletedAt: null,
        },
      },
      { $sort: { createdAt: -1 } },
    ];
    return base.getAll(pipeline, page, limit, { organizationId });
  }

  function updateResource(
    id: string,
    patch: Partial<IResource>,
    session?: ClientSession,
  ) {
    return base.update(id, patch, session);
  }

  function deleteResource(id: string) {
    return base.softDelete(id);
  }

  return {
    createResource,
    getByName,
    getResources,
    updateResource,
    deleteResource,
  };
}
Collection names come from a db-collections.enum file (not collection.enum). Cache keys are auto-built in getAll from the resource name + cacheOptions object.

Rules

  • Never query MongoDB outside a repository. No useAtlas.getDb() calls in controllers or services.
  • Always use softDelete (sets deletedAt + status: 'deleted') — never hard-delete unless absolutely required.
  • Cache is automaticgetAll, getById, getField, getFields, and getByPipeline all auto-cache with buildCacheKey. Pass cacheOptions for additional key parameters.
  • Invalidate on writeadd, update, softDelete, and all write methods call delCacheGroup(resource) automatically.
  • Aggregate pipelines for complex queries — the base repo uses $facet for paginated queries (single round trip).
  • additionalCacheGroups — pass related collection names to bust cross-collection caches on writes.