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;DR —
useRepo<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
| Method | Returns |
|---|---|
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(setsdeletedAt+status: 'deleted') — never hard-delete unless absolutely required. - Cache is automatic —
getAll,getById,getField,getFields, andgetByPipelineall auto-cache withbuildCacheKey. PasscacheOptionsfor additional key parameters. - Invalidate on write —
add,update,softDelete, and all write methods calldelCacheGroup(resource)automatically. - Aggregate pipelines for complex queries — the base repo uses
$facetfor paginated queries (single round trip). additionalCacheGroups— pass related collection names to bust cross-collection caches on writes.
