A database-agnostic repository layer that provides a unified interface for both MongoDB and PostgreSQL. Write your business logic once, switch databases without changing a single line of application core.
Without a unified repository interface, your application becomes tightly coupled to a specific database:
// ❌ PROBLEM: Business logic tied to MongoDB
@Injectable()
export class UserService {
constructor(@InjectModel('User') private userModel: Model<UserDocument>) {}
async findActiveUsers() {
// MongoDB-specific query syntax
return this.userModel.find({
status: 'active',
deletedAt: null
}).select('-password')
}
async updateUser(id: string, data: UpdateUserDTO) {
// MongoDB-specific update syntax
return this.userModel.updateOne(
{ _id: id },
{ $set: data }
)
}
}
// 😫 What if you need to migrate to PostgreSQL?
// - Rewrite ALL repository calls
// - Change ALL query syntax
// - Risk introducing bugs everywhere┌─────────────────────────────────────────────────────────────────────────────┐
│ REPOSITORY ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ │
│ │ IRepository<T> │ │
│ │ (adapter.ts) │ │
│ │ │ │
│ │ • create() │ │
│ │ • findById() │ │
│ │ • findOne() │ │
│ │ • findAll() │ │
│ │ • updateOne() │ │
│ │ • remove() │ │
│ │ • ...20+ methods │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ │ implements │ │
│ ▼ ▼ │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ MongoRepository<T> │ │ TypeORMRepository<T> │ │
│ │ (mongo/repository.ts) │ │ (postgres/repository) │ │
│ │ │ │ │ │
│ │ Translates to: │ │ Translates to: │ │
│ │ • $set, $in, $or │ │ • TypeORM syntax │ │
│ │ • .populate() │ │ • .leftJoinAndSelect() │ │
│ │ • _id handling │ │ • relations handling │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ MongoDB │ │ Postgres │ │
│ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// src/core/cat/repository/cat.ts - Abstract interface in core
export abstract class ICatRepository extends IRepository<CatEntity> {
abstract paginate(input: CatListInput): Promise<CatListOutput>
}
// src/core/cat/use-cases/cat-list.ts - Use case uses ONLY the interface
export class CatListUsecase {
constructor(private readonly catRepository: ICatRepository) {}
execute(input: CatListInput): Promise<CatListOutput> {
return this.catRepository.paginate(input) // Database-agnostic!
}
}
// src/core/cat/use-cases/cat-get-by-id.ts
export class CatGetByIdUsecase {
constructor(private readonly catRepository: ICatRepository) {}
execute(id: string): Promise<CatEntity | null> {
return this.catRepository.findById(id) // Inherited from IRepository
}
}The use cases never know which database is being used. They only depend on the abstract ICatRepository, which could be backed by MongoDB, PostgreSQL, or any other database.
The pattern involves 3 layers:
┌─────────────────────────────────────────────────────────────────────────────┐
│ REPOSITORY PATTERN │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. CORE LAYER (src/core/{entity}/repository/) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ abstract class ICatRepository extends IRepository<CatEntity> │ │
│ │ abstract paginate(input): Promise<CatListOutput> │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ extends │
│ ▼ │
│ 2. MODULE LAYER (src/modules/{entity}/repository.ts) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ class CatRepository │ │
│ │ extends MongoRepository<CatDocument> ◄── Database specific │ │
│ │ implements ICatRepository │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ injected via │
│ ▼ │
│ 3. MODULE DI (src/modules/{entity}/module.ts) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ { provide: ICatRepository, useFactory: ... } │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// src/core/cat/repository/cat.ts
import { IRepository } from '@/infra/repository'
import { CatEntity } from '../entity/cat'
import { CatListInput, CatListOutput } from '../use-cases/cat-list'
export abstract class ICatRepository extends IRepository<CatEntity> {
abstract paginate(input: CatListInput): Promise<CatListOutput>
}// src/modules/cat/repository.ts
@Injectable()
export class CatRepository extends MongoRepository<CatDocument> implements ICatRepository {
constructor(@InjectModel(Cat.name) readonly entity: MongoRepositoryModelSessionType<PaginateModel<CatDocument>>) {
super(entity)
}
@ValidateDatabaseSortAllowed<CatEntity>({ name: 'createdAt' }, { name: 'breed' })
@ConvertMongooseFilter<CatEntity>([
{ name: 'name', type: SearchTypeEnum.like },
{ name: 'breed', type: SearchTypeEnum.like },
{ name: 'age', type: SearchTypeEnum.equal, format: 'Number' }
])
async paginate({ limit, page, search, sort }: CatListInput): Promise<CatListOutput> {
const cats = await this.entity.paginate(search as FilterQuery<IEntity>, { page, limit, sort: sort as object })
return {
docs: cats.docs.map((u) => new CatEntity(u.toObject({ virtuals: true })).toObject()),
limit, page, total: cats.totalDocs
}
}
}// src/modules/cat/module.ts
{
provide: ICatRepository,
useFactory: async (connection: Connection) => {
type Model = mongoose.PaginateModel<CatDocument>
const repository: MongoRepositoryModelSessionType<PaginateModel<CatDocument>> =
connection.model<CatDocument, Model>(Cat.name, CatSchema as Schema)
repository.connection = connection
return new CatRepository(repository)
},
inject: [getConnectionToken(ConnectionName.CATS)]
}// src/modules/role/repository.ts
@Injectable()
export class RoleRepository extends TypeORMRepository<Model> implements IRoleRepository {
constructor(readonly repository: Repository<Model>) {
super(repository)
}
@ConvertTypeOrmFilter<RoleEntity>([{ name: 'name', type: SearchTypeEnum.like }])
@ValidateDatabaseSortAllowed<RoleEntity>({ name: 'name' }, { name: 'createdAt' })
async paginate(input: RoleListInput): Promise<RoleListOutput> {
const skip = PaginationUtils.calculateSkip(input)
const [docs, total] = await this.repository.findAndCount({
take: input.limit, skip,
order: input.sort as FindOptionsOrder<IEntity>,
where: input.search as FindOptionsWhere<unknown>
})
return { docs: docs.map((doc) => new RoleEntity(doc).toObject()), total, page: input.page, limit: input.limit }
}
}
type Model = RoleSchema & RoleEntity// src/modules/role/module.ts
{
provide: IRoleRepository,
useFactory: (repository: Repository<RoleSchema & RoleEntity>) => {
return new RoleRepository(repository)
},
inject: [getRepositoryToken(RoleSchema)]
}// src/core/cat/use-cases/cat-list.ts
export class CatListUsecase {
constructor(private readonly catRepository: ICatRepository) {}
execute(input: CatListInput): Promise<CatListOutput> {
return this.catRepository.paginate(input) // Works with Mongo or Postgres!
}
}To switch databases: Only change the module's repository implementation. Use cases, controllers, and all business logic remain untouched! 🎉
The abstract class that defines all available operations:
abstract class IRepository<T> {
// Create
abstract create(document: T, options?): Promise<CreatedModel>
abstract createOrUpdate(document: T, options?): Promise<CreatedOrUpdateModel>
abstract insertMany(documents: T[], options?): Promise<void>
// Read - Basic
abstract findById(id: string | number, options?): Promise<T | null>
abstract findOne(filter, options?): Promise<T | null>
abstract findAll(filter?, options?): Promise<T[]>
abstract find(filter, options?): Promise<T[]>
// Read - Advanced
abstract findIn(filter, options?): Promise<T[]>
abstract findOr(propertyList, value, options?): Promise<T[]>
abstract findByCommands(filterList, options?): Promise<T[]>
abstract findOneByCommands(filterList, options?): Promise<T | null>
// Read - Field Selection
abstract findOneWithExcludeFields(filter, excludeFields, options?): Promise<T | null>
abstract findAllWithExcludeFields(excludeFields, filter?, options?): Promise<T[]>
abstract findOneWithSelectFields(filter, includeFields, options?): Promise<T | null>
abstract findAllWithSelectFields(includeFields, filter?, options?): Promise<T[]>
// Read - Joins/Relations
abstract findOneWithJoin(filter, joins?): Promise<T | null>
abstract findAllWithJoin(filter?, joins?): Promise<T[]>
// Update
abstract updateOne(filter, updated, options?): Promise<UpdatedModel>
abstract updateMany(filter, updated, options?): Promise<UpdatedModel>
abstract findOneAndUpdate(filter, updated, options?): Promise<T | null>
// Delete
abstract remove(filter, options?): Promise<RemovedModel>
}Creates a single document and returns its ID.
const result = await this.userRepository.create({
id: IdGeneratorUtils.uuid(),
name: 'John Doe',
email: 'john@example.com'
})
// result: { id: 'abc-123', created: true }Creates if doesn't exist, updates if exists. Requires id field.
// First call - creates
const result1 = await this.userRepository.createOrUpdate({
id: 'user-123',
name: 'John',
status: 'active'
})
// result1: { id: 'user-123', created: true, updated: false }
// Second call - updates
const result2 = await this.userRepository.createOrUpdate({
id: 'user-123',
name: 'John Updated',
status: 'inactive'
})
// result2: { id: 'user-123', created: false, updated: true }Bulk insert for performance. Returns void.
await this.userRepository.insertMany([
{ id: '1', name: 'User 1', email: 'user1@example.com' },
{ id: '2', name: 'User 2', email: 'user2@example.com' },
{ id: '3', name: 'User 3', email: 'user3@example.com' }
])Find single document by ID.
const user = await this.userRepository.findById('user-123')
// user: UserEntity | nullFind single document matching filter.
const user = await this.userRepository.findOne({ email: 'john@example.com' })Find all documents matching filter.
const activeUsers = await this.userRepository.find({ status: 'active' })Find all documents, optionally filtered.
// All users
const allUsers = await this.userRepository.findAll()
// Filtered
const admins = await this.userRepository.findAll({ role: 'admin' })Find documents where field value is IN a list.
const users = await this.userRepository.findIn({
id: ['user-1', 'user-2', 'user-3']
})
// Equivalent SQL: WHERE id IN ('user-1', 'user-2', 'user-3')
// Equivalent Mongo: { _id: { $in: [...] } }Find documents where ANY of the properties matches the value.
const users = await this.userRepository.findOr(
['email', 'username', 'phone'],
'john@example.com'
)
// Finds user where email='john@example.com'
// OR username='john@example.com'
// OR phone='john@example.com'Advanced filtering with operators: equal, not_equal, contains, not_contains.
const users = await this.userRepository.findByCommands([
{ property: 'status', value: ['active', 'pending'], command: DatabaseOperationEnum.EQUAL },
{ property: 'name', value: ['admin', 'test'], command: DatabaseOperationEnum.NOT_CONTAINS }
])
// Finds users where:
// - status IN ('active', 'pending')
// - name NOT LIKE '%admin%' AND name NOT LIKE '%test%'Same as findByCommands but returns single result.
const user = await this.userRepository.findOneByCommands([
{ property: 'email', value: ['john@'], command: DatabaseOperationEnum.CONTAINS }
])Find one document, excluding specific fields from result.
// Get user without sensitive fields
const user = await this.userRepository.findOneWithExcludeFields(
{ id: 'user-123' },
['password', 'refreshToken', 'resetPasswordToken']
)
// user.password = undefined (not included in result)Find all documents, excluding specific fields.
const users = await this.userRepository.findAllWithExcludeFields(
['password', 'internalNotes'],
{ status: 'active' }
)Find one document, selecting ONLY specific fields.
// Get only id and email
const user = await this.userRepository.findOneWithSelectFields(
{ id: 'user-123' },
['id', 'email', 'name']
)
// user = { id: 'user-123', email: '...', name: '...' }To ensure consistent and validated pagination across all repositories, every repository implementation must provide an applyPagination method. This method is responsible for handling pagination logic in a database-agnostic way, returning a standardized output regardless of the underlying database.
Usage:
- Implement
applyPaginationin every repository (MongoDB, PostgreSQL, etc.), following the interface contract and usingasync. - Always use the appropriate decorators (e.g.,
@ConvertMongooseFilter,@ConvertTypeOrmFilter,@ValidateDatabaseSortAllowed) on your pagination methods to ensure filters and sorting are correctly handled for each database. - In the output, always map each document to a new instance of the domain entity (e.g.,
new CatEntity(doc).toObject()). This guarantees validation and standardization of the returned data, regardless of the database.
Example (MongoDB):
@ValidateDatabaseSortAllowed<CatEntity>({ name: 'createdAt' }, { name: 'breed' })
@ConvertMongooseFilter<CatEntity>([
{ name: 'name', type: SearchTypeEnum.like },
{ name: 'breed', type: SearchTypeEnum.like },
{ name: 'age', type: SearchTypeEnum.equal, format: 'Number' }
])
async paginate(input: CatListInput): Promise<CatListOutput> {
const cats = await this.applyPagination(input)
return { ...cats, docs: cats.docs.map((doc) => new CatEntity(doc).toObject()) }
}Example (PostgreSQL/TypeORM):
@ConvertTypeOrmFilter<RoleEntity>([{ name: 'name', type: SearchTypeEnum.like }])
@ValidateDatabaseSortAllowed<RoleEntity>({ name: 'name' }, { name: 'createdAt' })
async paginate(input: RoleListInput): Promise<RoleListOutput> {
const docs = await this.applyPagination(input)
return { ...docs, docs: docs.docs.map((doc) => new RoleEntity(doc).toObject()) }
}To simplify existence checks and uniqueness validation, the repository interface includes two methods:
Checks if any record exists matching the filter.
Returns only true or false, never throws.
Example:
const exists = await userRepository.exists({ email: 'test@email.com' })
if (exists) {
// Handle existence
}Checks if any other record exists matching the filter, ignoring the given id.
Useful for uniqueness validation on update (e.g., avoid duplicate emails).
Example:
const exists = await userRepository.existsOnUpdate({ email: 'test@email.com' }, userId)
if (exists) {
// Handle conflict
}Implementation Notes:
- Both methods are available for MongoDB and PostgreSQL repositories.
- Always return a boolean, never throw errors.
existsOnUpdateignores the current record by id, ideal for update scenarios.
Find all documents, selecting ONLY specific fields.
// Lightweight list for dropdown
const users = await this.userRepository.findAllWithSelectFields(
['id', 'name'],
{ status: 'active' }
)Find one document with related entities populated.
// Define which relations to load
const user = await this.userRepository.findOneWithJoin(
{ id: 'user-123' },
{ roles: true, permissions: true }
)
// user.roles = [RoleEntity, RoleEntity, ...]
// user.permissions = [PermissionEntity, ...]Find all documents with relations.
const users = await this.userRepository.findAllWithJoin(
{ status: 'active' },
{ roles: true }
)Update single document matching filter.
const result = await this.userRepository.updateOne(
{ id: 'user-123' },
{ status: 'inactive', updatedAt: new Date() }
)
// result: { matchedCount: 1, modifiedCount: 1, acknowledged: true, ... }Update all documents matching filter.
const result = await this.userRepository.updateMany(
{ status: 'pending', createdAt: { $lt: thirtyDaysAgo } },
{ status: 'expired' }
)
// result: { matchedCount: 150, modifiedCount: 150, ... }Update and return the updated document.
const user = await this.userRepository.findOneAndUpdate(
{ id: 'user-123' },
{ lastLoginAt: new Date() }
)
// user = updated UserEntity (with new lastLoginAt)Delete documents matching filter.
const result = await this.userRepository.remove({ id: 'user-123' })
// result: { deletedCount: 1, deleted: true }| Type | Fields | Description |
|---|---|---|
CreatedModel |
id, created |
Result of create operation |
CreatedOrUpdateModel |
id, created, updated |
Result of upsert operation |
UpdatedModel |
matchedCount, modifiedCount, acknowledged, upsertedId, upsertedCount |
Result of update operation |
RemovedModel |
deletedCount, deleted |
Result of delete operation |
For advanced filtering with findByCommands and findOneByCommands:
type DatabaseOperationCommand<T> = {
property: keyof T // Field to filter
value: unknown[] // Values to match
command: DatabaseOperationEnum // Operation type
}
enum DatabaseOperationEnum {
EQUAL = 'equal' // IN (exact match)
NOT_EQUAL = 'not_equal' // NOT IN
CONTAINS = 'contains' // LIKE %value%
NOT_CONTAINS = 'not_contains' // NOT LIKE %value%
}Executes multiple operations in a single transaction. The callback receives the transaction context (session for MongoDB, manager for TypeORM).
await this.userRepository.runInTransaction(async (session) => {
await this.userRepository.create({
id: IdGeneratorUtils.uuid(),
name: 'John Doe',
email: 'john@example.com'
}, { session });
await this.userRepository.updateOne(
{ id: 'user-123' },
{ status: 'active' },
{ session }
);
// ...other operations
});await this.userRepository.runInTransaction(async (manager) => {
await manager.save(UserEntity, {
id: IdGeneratorUtils.uuid(),
name: 'John Doe',
email: 'john@example.com'
});
await manager.update(UserEntity, { id: 'user-123' }, { status: 'active' });
// ...other operations
});Both implementations automatically filter out soft-deleted records:
// Your code
const users = await this.userRepository.findAll({ status: 'active' })
// MongoDB translation
db.users.find({ status: 'active', deletedAt: null })
// PostgreSQL translation
SELECT * FROM users WHERE status = 'active' AND deleted_at IS NULLMongoDB uses _id, but your entities use id. The repository handles this automatically:
// Your code
const user = await this.userRepository.findOne({ id: 'user-123' })
// MongoDB translation (automatic)
db.users.findOne({ _id: 'user-123' })The MongoRepository uses decorators for automatic filter conversion:
@ConvertMongoFilterToBaseRepository()
async find(filter, options): Promise<T[]> {
// Filter is automatically converted from { id: '123' } to { _id: '123' }
// deletedAt: null is automatically added
}- @ConvertMongoFilterToBaseRepository — Auto filter conversion
- @ConvertTypeORMFilterToBaseRepository — TypeORM filter conversion
- Entity — Base entity interface
- Pagination — Pagination utilities