Skip to main content

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:

  1. Base Repository Interface: Defines the contract that all repositories must implement
  2. Base Repository Implementation: Provides standard functionality for all repositories
  3. Entity-Specific Repositories: Extend the base repository for domain-specific operations
  4. 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:

  1. Define your entity and document types
  2. Extend BaseRepository
  3. Implement entity-specific methods
  4. 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., findActiveUsers not findByStatusEquals)
  • Leverage Type Safety: Utilize the generic type parameters <TEntity, TDocument> to ensure type safety

2. Entity Normalization

  • Always Normalize Before Returning: Use normalizeResult or normalizeMany before returning data to services
  • Handle Nulls Consistently: Normalize null/undefined values consistently
  • Custom Transformations: Implement entity-specific transformations when needed by overriding the transformToEntity method
  • 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

The following pattern shows the recommended way to implement and use repositories in your application:

  1. 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
    }
  2. 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 });
    }
    }
  3. Register the repository in its module:

    @Module({
    imports: [
    MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])
    ],
    providers: [UserRepository],
    exports: [UserRepository]
    })
    export class UserModule {}
  4. 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);
    }
    }

References