Repository Pattern
Overview
The Repository Pattern in Wrkbelt provides a consistent abstraction over data access operations, separating business logic from database interactions. Our implementation uses generics for type safety and provides standardized CRUD operations while ensuring proper entity normalization and transaction management.
Architecture and Design
Core Components
The repository pattern implementation consists of several key components:
- Base Repository Interface: Defines the contract that all repositories must implement
- Base Repository Implementation: Provides standard functionality for all repositories
- Entity-Specific Repositories: Extend the base repository for domain-specific operations
- MongoDB/Mongoose Integration: Handles the underlying database operations
Type Safety with Generics
Our repository implementation leverages TypeScript generics to provide type safety between MongoDB documents and domain entities:
export interface IBaseRepository<
TEntity extends BaseEntity,
TDocument extends TEntity & MongooseDocument
> {
// Repository methods...
}
@Injectable()
export abstract class BaseRepository<
TEntity extends BaseEntity,
TDocument extends HydratedDocument<TEntity>
> implements IBaseRepository<TEntity, TDocument> {
// Implementation...
}
This approach ensures that:
- Every repository is strongly typed to its specific entity and document types
- Compiler checks prevent type mismatches between documents and entities
- Intellisense provides appropriate type hints during development
Base Repository Implementation
The BaseRepository class in libs/api/utils-api/src/lib/repository/base-repository.common.ts provides a foundation for all entity-specific repositories:
@Injectable()
export abstract class BaseRepository<
TEntity extends BaseEntity,
TDocument extends HydratedDocument<TEntity>
> implements IBaseRepository<TEntity, TDocument> {
constructor(
protected readonly model: Model<TDocument>,
protected readonly connection: Connection
) {}
// Transaction flow, normalization methods, and CRUD operations...
}
This foundational class eliminates the need to reimplement common repository functionality across different domain entities. By extending this class, entity-specific repositories automatically inherit standardized CRUD operations, entity normalization logic, and transaction management capabilities.
Key Features
The BaseRepository provides several key features that are automatically inherited by all repository implementations:
1. Entity Normalization
The repository transforms MongoDB documents into domain entities:
normalizeResult<
T extends BaseEntity = TEntity,
P extends BaseDocument = TDocument
>(doc: P | null | undefined): T | null {
if (!doc) return null;
try {
return this.transformToEntity<T, P>(doc);
} catch (error) {
throw new DocumentNormalizationError(
'Failed to normalize document',
error
);
}
}
normalizeMany<
T extends BaseEntity = TEntity,
P extends BaseDocument = TDocument
>(docs: Array<P | null>): T[] {
const results = docs.map((d) => {
try {
return this.normalizeResult<T, P>(d);
} catch {
return null;
}
});
return results.filter((result) => isNonNullEntity(result)) as T[];
}
This normalization process:
- Removes Mongoose metadata and methods
- Converts MongoDB documents to plain JavaScript objects
- Handles null/undefined values gracefully
- Provides error wrapping for debugging
2. Transaction Management
Provides a unified approach to database transactions:
async withTransaction<T>(
work: (session: ClientSession) => Promise<T>
): Promise<T> {
const session = await this.connection.startSession();
session.startTransaction();
try {
const result = await work(session);
await session.commitTransaction();
return result;
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
}
This pattern:
- Simplifies transaction handling with a clean API
- Ensures proper transaction lifecycle (commit/abort/cleanup)
- Provides a consistent pattern across the codebase
- Reduces boilerplate in service implementations
3. Standard CRUD Operations
Exposes standardized CRUD operations that are automatically bound to the Mongoose model:
// These methods are directly available on any repository that extends BaseRepository
find: Model<TDocument>['find'] = this.model.find.bind(this.model);
findOne: Model<TDocument>['findOne'] = this.model.findOne.bind(this.model);
findById: Model<TDocument>['findById'] = this.model.findById.bind(this.model);
create: Model<TDocument>['create'] = this.model.create.bind(this.model);
findByIdAndUpdate: Model<TDocument>['findByIdAndUpdate'] = this.model.findByIdAndUpdate.bind(this.model);
findOneAndUpdate: Model<TDocument>['findOneAndUpdate'] = this.model.findOneAndUpdate.bind(this.model);
updateMany: Model<TDocument>['updateMany'] = this.model.updateMany.bind(this.model);
deleteOne: Model<TDocument>['deleteOne'] = this.model.deleteOne.bind(this.model);
These operations:
- Provide a consistent interface across repositories
- Delegate to Mongoose model methods for implementation
- Preserve method signatures and typing from Mongoose
- Allow for consistent typing across the data access layer
- Eliminate the need to reimplement basic database operations
Implementing Custom Repositories
To create a repository for a specific entity:
- Define your entity and document types
- Extend BaseRepository
- Implement entity-specific methods
- Register the repository in the appropriate module
Example:
// 1. Define the repository interface
export interface IPermissionRepository {
findByName(permissionName: AllPermissions): Promise<PermissionDocument | null>;
deleteByName(permissionName: AllPermissions): Promise<boolean>;
}
// 2. Implement the repository
@Injectable()
export class PermissionRepository
extends BaseRepository<Permission, PermissionDocument>
implements IPermissionRepository {
constructor(
@InjectModel(PermissionSchema.name)
permissionModel: Model<PermissionDocument>,
@InjectConnection() connection: Connection
) {
super(permissionModel, connection);
}
// 3. Implement custom methods
async findByName(
permissionName: AllPermissions
): Promise<PermissionDocument | null> {
const query: FilterQuery<PermissionDocument> = { name: permissionName };
return this.findOne(query);
}
async deleteByName(permissionName: AllPermissions): Promise<boolean> {
const permission = await this.deleteOne({
name: permissionName,
});
return permission.deletedCount > 0;
}
}
// 4. Register in module
@Module({
imports: [
MongooseModule.forFeature([
{ name: PermissionSchema.name, schema: PermissionSchema },
]),
],
providers: [PermissionRepository],
exports: [PermissionRepository],
})
export class PermissionModule {}
Best Practices
1. Repository Interface Design
- Define Clear Interface Contracts: Create interfaces for repositories that extend
IBaseRepository - Keep Methods Domain-Focused: Methods should represent domain operations, not database operations
- Use Descriptive Method Names: Name methods based on their domain purpose (e.g.,
findActiveUsersnotfindByStatusEquals) - Leverage Type Safety: Utilize the generic type parameters
<TEntity, TDocument>to ensure type safety
2. Entity Normalization
- Always Normalize Before Returning: Use
normalizeResultornormalizeManybefore returning data to services - Handle Nulls Consistently: Normalize null/undefined values consistently
- Custom Transformations: Implement entity-specific transformations when needed by overriding the
transformToEntitymethod - Return Plain Objects: Ensure that entities returned to services are plain JavaScript objects, not Mongoose documents
3. Transaction Management
- Use Transactions for Multi-Document Operations: Any operation that modifies multiple documents should use transactions
- Keep Transaction Scope Narrow: Only include the necessary operations in the transaction
- Handle Errors Properly: Ensure errors are properly caught and transactions aborted
4. Query Design
- Lean Queries: Use
.lean()for read-only operations when appropriate - Composition Over Complexity: Break complex queries into smaller, more manageable ones
- Handle Performance: Be mindful of index usage and query optimization
Advanced Patterns
Specialized Repository Types
For specific use cases, consider creating specialized repository base classes:
// Example: ReadOnlyRepository for repositories that don't need write operations
export abstract class ReadOnlyRepository<
TEntity extends BaseEntity,
TDocument extends HydratedDocument<TEntity>
> extends BaseRepository<TEntity, TDocument> {
// Override/hide write methods to prevent usage
create = undefined;
findByIdAndUpdate = undefined;
// ...other write operations
}
Repository Composition
For complex domain operations, consider composing repositories:
@Injectable()
export class UserPermissionService {
constructor(
private readonly userRepository: UserRepository,
private readonly permissionRepository: PermissionRepository,
private readonly roleRepository: RoleRepository
) {}
// Implement complex operations that span multiple repositories
}
Troubleshooting Common Issues
Transaction Errors
- Missing Session Parameter: Ensure session is passed to all operations within a transaction
- Nested Transactions: Avoid nested transactions; use a single transaction scope
- Connection Issues: Check MongoDB connection settings and replica set configuration
Normalization Errors
- Circular References: Handle circular references in entity relationships
- Missing Schema Fields: Ensure schema and entity types are in sync
- Performance Issues: Use projection to limit fields for better performance
Related Patterns
- Shared Type System: Understanding the type system used throughout repositories
- Resource Module Pattern: How repositories fit into the overall module structure
Recommended Usage Pattern
The following pattern shows the recommended way to implement and use repositories in your application:
-
Define an entity-specific interface that extends IBaseRepository:
export interface IUserRepository extends IBaseRepository<User, UserDocument> {
findByEmail(email: string): Promise<UserDocument | null>;
// Add domain-specific methods here
} -
Create a repository implementation that extends BaseRepository:
@Injectable()
export class UserRepository
extends BaseRepository<User, UserDocument>
implements IUserRepository {
constructor(
@InjectModel(User.name) userModel: Model<UserDocument>,
@InjectConnection() connection: Connection
) {
super(userModel, connection);
}
async findByEmail(email: string): Promise<UserDocument | null> {
return this.findOne({ email });
}
} -
Register the repository in its module:
@Module({
imports: [
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])
],
providers: [UserRepository],
exports: [UserRepository]
})
export class UserModule {} -
Use the repository in services:
@Injectable()
export class UserService {
constructor(private readonly userRepository: UserRepository) {}
async findUserByEmail(email: string): Promise<User | null> {
const user = await this.userRepository.findByEmail(email);
return this.userRepository.normalizeResult(user);
}
async createUser(userData: CreateUserDto): Promise<User> {
return this.userRepository.create(userData);
}
}