The Case Against Generic Repository Patterns in TypeScript
Stop using generic repository patterns in TypeScript. Discover how domain-focused repositories improve type safety and maintainability.
We've all seen (or written) this pattern. It seems elegant, reusable, and type-safe. But it might be causing more problems than it solves.
The Common Pattern
// Looks clean at first...
interface IRepository<T> {
findById(id: string): Promise<T>;
findAll(): Promise<T[]>;
create(entity: Omit<T, 'id'>): Promise<T>;
update(id: string, entity: Partial<T>): Promise<T>;
delete(id: string): Promise<void>;
}
class GenericRepository<T> implements IRepository<T> {
constructor(
private readonly collection: string,
private readonly db: Database
) {}
async findById(id: string): Promise<T> {
return this.db.findOne(this.collection, { id });
}
// ... other methods
}
// Usage
interface User {
id: string;
email: string;
password: string;
}
const userRepo = new GenericRepository<User>('users', db);
The Problems
1. Domain-Specific Logic Gets Scattered
// ❌ BAD: Business logic spreads everywhere
class UserService {
constructor(private readonly repo: IRepository<User>) {}
async createUser(data: CreateUserDTO): Promise<User> {
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await this.repo.create({
...data,
password: hashedPassword
});
await this.sendWelcomeEmail(user);
return user;
}
}
// ❌ BAD: Or duplicated in multiple places
class AuthService {
async validatePassword(email: string, password: string) {
const user = await this.repo.findOne({ email });
// Oops, password hashing logic again!
return bcrypt.compare(password, user.password);
}
}
2. Type Safety Illusions
// ❌ BAD: False sense of type safety
interface BaseEntity {
id: string;
createdAt: Date;
updatedAt: Date;
}
class GenericRepository<T extends BaseEntity> {
async update(id: string, data: Partial<T>): Promise<T> {
// What if T has complex nested objects?
// What if some fields shouldn't be updateable?
// What if updates need validation?
return this.db.update(this.collection, id, data);
}
}
Better Approach: Specific Repositories
1. Domain-Focused Repositories
// ✅ BETTER: Clear domain boundaries
class UserRepository {
constructor(private readonly db: Database) {}
async create(data: CreateUserDTO): Promise<User> {
const user = await this.db.insert('users', {
...data,
password: await this.hashPassword(data.password),
createdAt: new Date()
});
return this.mapToUser(user);
}
async findByEmail(email: string): Promise<User | null> {
const user = await this.db.findOne('users', { email });
return user ? this.mapToUser(user) : null;
}
private async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
private mapToUser(data: any): User {
return {
id: data.id,
email: data.email,
createdAt: new Date(data.createdAt)
};
}
}
2. Type-Safe Operations
// ✅ BETTER: Explicit types for operations
interface CreateUserDTO {
email: string;
password: string;
name: string;
}
interface UpdateUserDTO {
name?: string;
email?: string;
// Notice: no password field
}
class UserRepository {
async update(id: string, data: UpdateUserDTO): Promise<User> {
// Only allowed fields can be updated
const user = await this.db.update('users', id, {
...data,
updatedAt: new Date()
});
return this.mapToUser(user);
}
async updatePassword(id: string, newPassword: string): Promise<void> {
// Password updates are handled separately
await this.db.update('users', id, {
password: await this.hashPassword(newPassword),
updatedAt: new Date()
});
}
}
3. Domain-Specific Queries
// ✅ BETTER: Business-focused queries
class OrderRepository {
async findPendingByUser(userId: string): Promise<Order[]> {
const orders = await this.db.find('orders', {
userId,
status: 'pending',
createdAt: { $gt: subDays(new Date(), 30) }
});
return orders.map(this.mapToOrder);
}
async markAsShipped(
orderId: string,
trackingData: TrackingData
): Promise<Order> {
const order = await this.db.update('orders', orderId, {
status: 'shipped',
trackingNumber: trackingData.number,
shippedAt: new Date(),
carrier: trackingData.carrier
});
return this.mapToOrder(order);
}
}
When to Use Generic Repositories
There are valid cases for generic repositories:
// ✅ GOOD: Internal infrastructure patterns
class BaseRepository<T extends { id: string }> {
protected async executeWithRetry<R>(
operation: () => Promise<R>
): Promise<R> {
try {
return await operation();
} catch (error) {
if (this.shouldRetry(error)) {
return await operation();
}
throw error;
}
}
protected async logOperation(
operation: string,
data?: any
): Promise<void> {
await this.logger.log({
operation,
collection: this.collection,
timestamp: new Date(),
data
});
}
}
// Specific repositories can extend for infrastructure features
class UserRepository extends BaseRepository<User> {
async create(data: CreateUserDTO): Promise<User> {
return this.executeWithRetry(async () => {
const user = await this.db.insert('users', data);
await this.logOperation('create', { id: user.id });
return user;
});
}
}
Conclusion
Instead of generic repositories:
- Create specific repositories for each domain entity
- Define explicit DTOs for operations
- Keep business logic contained
- Use inheritance for infrastructure concerns only
- Let your domain guide your data access patterns
Remember: Your repository layer should speak the language of your domain, not your database.