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.