Skip to main content

Command Palette

Search for a command to run...

Repository Pattern: Generic Anti-Pattern

Published
•10 min read
T

Welcome to TopperBlog! šŸ‘‹

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

šŸŽÆ What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

šŸ’¼ Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

šŸ“š 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Why Generic Repository Became Popular (And Why That Reasoning No Longer Holds)

The generic repository pattern emerged during an era when ORMs were immature, testing frameworks lacked sophistication, and switching database technologies was considered a realistic concern. The pattern promised three main benefits: database abstraction, testability, and a consistent interface across entities.

In 2025, these justifications have eroded. Modern ORMs are stable, feature-rich, and unlikely to be replaced. Entity Framework Core has been production-ready for years, with Microsoft's long-term support commitment. Prisma has become the de facto standard for Node.js applications. The "we might switch databases" argument rarely materializes—organizations that do migrate typically rewrite significant portions of their application logic anyway.

Testing has evolved dramatically. In-memory database providers, containerized test databases with Testcontainers, and sophisticated mocking libraries make testing data access code straightforward without generic abstractions. GitHub Actions and similar CI/CD platforms can spin up real PostgreSQL or SQL Server instances in seconds, enabling integration tests against actual database engines.

The consistency argument also falls apart under scrutiny. Different entities have fundamentally different data access patterns. A User entity might need complex filtering and pagination, while an AuditLog entity requires append-only operations with time-series optimizations. Forcing both through identical IRepository<T> interfaces obscures these differences and prevents optimization.

The Technical Problems with Generic Repository Pattern

Generic repositories create several concrete technical problems that impact modern application development.

Abstraction Leakage: The most fundamental issue is that generic repositories leak abstractions constantly. Consider this common generic repository interface:

interface IRepository<T> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  create(entity: T): Promise<T>;
  update(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

This interface immediately breaks down with real-world requirements. How do you express:

  • Eager loading related entities (Include in EF Core, include in Prisma)?
  • Complex filtering with multiple conditions?
  • Pagination with cursor-based or offset-based strategies?
  • Sorting by multiple fields?
  • Projection to DTOs for performance?
  • Batch operations for efficiency?

Teams typically respond by adding methods like findBySpecification() or query(), which essentially bypass the generic interface and expose the underlying ORM anyway. You end up with the worst of both worlds: abstraction overhead plus direct ORM usage.

Performance Degradation: Generic repositories often force inefficient query patterns. The findAll() method is particularly problematic—it encourages loading entire tables into memory. In production systems with millions of records, this causes memory exhaustion and database load spikes.

Modern ORMs provide sophisticated query optimization features that generic repositories hide. Entity Framework Core 8 includes compiled queries, split queries for collections, and query filters. Prisma 5 offers query optimization hints and connection pooling configuration. When wrapped in generic repositories, developers lose access to these capabilities or must create increasingly complex workarounds.

False Sense of Testability: The testability argument for generic repositories is largely obsolete. Here's a modern approach using Prisma with Testcontainers:

import { PrismaClient } from '@prisma/client';
import { PostgreSqlContainer } from '@testcontainers/postgresql';

describe('UserService Integration Tests', () => {
  let container: PostgreSqlContainer;
  let prisma: PrismaClient;

  beforeAll(async () => {
    container = await new PostgreSqlContainer('postgres:16-alpine')
      .withDatabase('testdb')
      .start();

    prisma = new PrismaClient({
      datasources: {
        db: {
          url: container.getConnectionUri(),
        },
      },
    });

    await prisma.$executeRawUnsafe('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
    // Run migrations
  });

  afterAll(async () => {
    await prisma.$disconnect();
    await container.stop();
  });

  it('should create user with profile in transaction', async () => {
    const result = await prisma.$transaction(async (tx) => {
      const user = await tx.user.create({
        data: {
          email: 'test@example.com',
          profile: {
            create: {
              firstName: 'John',
              lastName: 'Doe',
            },
          },
        },
        include: {
          profile: true,
        },
      });
      return user;
    });

    expect(result.profile).toBeDefined();
    expect(result.profile?.firstName).toBe('John');
  });
});

This test runs against a real PostgreSQL instance in a container, validates actual database behavior including transactions and constraints, and completes in under 5 seconds on modern CI systems. No generic repository needed.

Modern Alternatives: Domain-Specific Repositories and Direct ORM Usage

The solution isn't to abandon repositories entirely—it's to use domain-specific repositories that expose exactly the operations your domain needs, or to use the ORM directly in application services when appropriate.

Domain-Specific Repositories: Create repositories tailored to specific aggregates with methods that express domain operations:

interface UserRepository {
  findByEmail(email: string): Promise<User | null>;
  findActiveUsersWithSubscriptions(
    pagination: CursorPagination
  ): Promise<PaginatedResult<User>>;
  createWithProfile(
    userData: CreateUserData,
    profileData: CreateProfileData
  ): Promise<User>;
  updateLastLoginTime(userId: string, timestamp: Date): Promise<void>;
  findUsersRequiringPasswordReset(): Promise<User[]>;
}

class PrismaUserRepository implements UserRepository {
  constructor(private prisma: PrismaClient) {}

  async findByEmail(email: string): Promise<User | null> {
    return this.prisma.user.findUnique({
      where: { email },
      include: {
        profile: true,
        subscriptions: {
          where: { status: 'ACTIVE' },
        },
      },
    });
  }

  async findActiveUsersWithSubscriptions(
    pagination: CursorPagination
  ): Promise<PaginatedResult<User>> {
    const users = await this.prisma.user.findMany({
      where: {
        status: 'ACTIVE',
        subscriptions: {
          some: {
            status: 'ACTIVE',
          },
        },
      },
      include: {
        subscriptions: true,
      },
      take: pagination.limit + 1,
      cursor: pagination.cursor ? { id: pagination.cursor } : undefined,
      orderBy: { createdAt: 'desc' },
    });

    const hasMore = users.length > pagination.limit;
    const items = hasMore ? users.slice(0, -1) : users;
    const nextCursor = hasMore ? items[items.length - 1].id : null;

    return {
      items,
      nextCursor,
      hasMore,
    };
  }

  async createWithProfile(
    userData: CreateUserData,
    profileData: CreateProfileData
  ): Promise<User> {
    return this.prisma.user.create({
      data: {
        ...userData,
        profile: {
          create: profileData,
        },
      },
      include: {
        profile: true,
      },
    });
  }

  async updateLastLoginTime(userId: string, timestamp: Date): Promise<void> {
    await this.prisma.user.update({
      where: { id: userId },
      data: { lastLoginAt: timestamp },
    });
  }

  async findUsersRequiringPasswordReset(): Promise<User[]> {
    const ninetyDaysAgo = new Date();
    ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);

    return this.prisma.user.findMany({
      where: {
        passwordChangedAt: {
          lt: ninetyDaysAgo,
        },
        passwordResetRequired: false,
      },
    });
  }
}

This repository expresses domain operations clearly, uses ORM features effectively, and remains testable. Each method has a clear purpose aligned with business requirements.

Direct ORM Usage in Application Services: For simpler domains or when using CQRS, consider using the ORM directly in application services:

class CreateOrderCommandHandler {
  constructor(
    private prisma: PrismaClient,
    private eventBus: EventBus
  ) {}

  async handle(command: CreateOrderCommand): Promise<OrderCreatedResult> {
    return this.prisma.$transaction(async (tx) => {
      // Validate inventory
      const product = await tx.product.findUnique({
        where: { id: command.productId },
        select: { id: true, stockQuantity: true, price: true },
      });

      if (!product || product.stockQuantity < command.quantity) {
        throw new InsufficientInventoryError();
      }

      // Create order
      const order = await tx.order.create({
        data: {
          userId: command.userId,
          status: 'PENDING',
          totalAmount: product.price * command.quantity,
          items: {
            create: {
              productId: command.productId,
              quantity: command.quantity,
              unitPrice: product.price,
            },
          },
        },
        include: {
          items: true,
        },
      });

      // Update inventory
      await tx.product.update({
        where: { id: command.productId },
        data: {
          stockQuantity: {
            decrement: command.quantity,
          },
        },
      });

      // Publish domain event
      await this.eventBus.publish(
        new OrderCreatedEvent({
          orderId: order.id,
          userId: order.userId,
          totalAmount: order.totalAmount,
        })
      );

      return {
        orderId: order.id,
        status: order.status,
      };
    });
  }
}

This approach eliminates unnecessary abstraction layers while maintaining clear separation of concerns. The command handler encapsulates business logic, uses transactions appropriately, and publishes domain events—all without a generic repository.

When Repository Pattern Still Makes Sense

Domain-specific repositories remain valuable in specific scenarios:

Complex Aggregates in DDD: When implementing Domain-Driven Design with complex aggregates, repositories serve as the boundary between domain and infrastructure layers. They ensure aggregate consistency and encapsulate persistence logic.

Multiple Data Sources: Applications that genuinely need to abstract over multiple data sources (SQL database, document store, external API) benefit from repositories that provide a unified interface while handling source-specific details internally.

Legacy System Integration: When integrating with legacy systems or gradually migrating from one data access technology to another, repositories can provide stability during the transition period.

Query Complexity Isolation: For domains with extremely complex query logic involving multiple joins, CTEs, or database-specific features, repositories can isolate this complexity from application services.

Common Pitfalls and Edge Cases

Over-Abstracting Simple Operations: Teams often create repositories for entities that only need basic CRUD operations. This adds ceremony without value. Use the ORM directly for simple cases.

Ignoring Transaction Boundaries: Generic repositories typically don't handle transactions well. Domain operations often span multiple entities and require transactional consistency. Ensure your approach supports proper transaction management:

// Anti-pattern: Separate repository calls without transaction
await userRepository.create(user);
await profileRepository.create(profile); // Might fail, leaving orphaned user

// Better: Transaction-aware approach
await prisma.$transaction(async (tx) => {
  const user = await tx.user.create({ data: userData });
  await tx.profile.create({ data: { ...profileData, userId: user.id } });
});

Specification Pattern Complexity: Some teams introduce the Specification pattern to handle complex queries through generic repositories. This often creates more complexity than it solves, with elaborate specification builders that are harder to understand than direct ORM queries.

Caching Complications: Generic repositories make caching difficult. Different queries need different caching strategies—some need cache invalidation on writes, others need time-based expiration, and some shouldn't be cached at all. Domain-specific repositories can implement appropriate caching per operation.

Missing Bulk Operations: Generic repositories rarely support efficient bulk operations. Modern databases and ORMs provide optimized bulk insert, update, and delete operations that generic interfaces obscure.

Best Practices for Data Access in 2025

Use Domain-Specific Repositories for Aggregates: Create repositories that align with domain aggregates and express domain operations clearly. Avoid generic CRUD interfaces.

Leverage ORM Features Directly: Don't hide powerful ORM features behind abstractions. Use query optimization, eager loading, projections, and database-specific features when needed.

Implement Proper Transaction Management: Ensure your data access approach supports transactions that span multiple operations. Use the ORM's transaction capabilities directly.

Test Against Real Databases: Use containerized databases in tests rather than mocking repositories. This validates actual database behavior and catches issues early.

Optimize for Read vs. Write Patterns: Consider CQRS for complex domains. Use different approaches for queries (potentially direct ORM or even raw SQL) versus commands (domain-specific repositories or command handlers).

Monitor Query Performance: Implement query logging and performance monitoring. Generic repositories often hide inefficient queries. Make query performance visible and measurable.

Document Data Access Patterns: Create clear guidelines for when to use repositories versus direct ORM access. Ensure team alignment on data access approaches.

FAQ

What is the generic repository anti-pattern? The generic repository anti-pattern refers to creating a single, generic interface (like IRepository<T>) for all entity data access operations. This abstraction typically adds complexity without meaningful benefits, hides ORM capabilities, and forces inefficient query patterns while providing minimal value in modern applications with mature ORMs.

Why is generic repository considered an anti-pattern in 2025? Modern ORMs like Entity Framework Core 8 and Prisma 5 are stable, feature-rich, and provide excellent testability through in-memory providers and containerized test databases. The original justifications for generic repositories—database abstraction, testability, and consistency—no longer hold. The pattern now creates more problems than it solves by hiding ORM features and forcing inefficient patterns.

What should I use instead of generic repository pattern? Use domain-specific repositories that express actual domain operations, or use the ORM directly in application services when appropriate. Domain-specific repositories align with business requirements, leverage ORM features effectively, and remain testable. For CQRS architectures, consider using the ORM directly in command handlers and query handlers.

How do you test data access code without generic repositories? Use containerized databases with tools like Testcontainers to run integration tests against real database instances. Modern CI/CD platforms can spin up PostgreSQL, MySQL, or SQL Server containers in seconds. This approach validates actual database behavior including transactions, constraints, and query performance without requiring generic abstractions.

When should you use repository pattern in modern applications? Use domain-specific repositories for complex aggregates in Domain-Driven Design, when abstracting over multiple genuinely different data sources, during legacy system migrations, or when isolating extremely complex query logic. Avoid repositories for simple CRUD operations where direct ORM usage is clearer and more efficient.

How does repository pattern affect performance in 2025? Generic repositories often degrade performance by hiding ORM optimization features like compiled queries, split queries, query hints, and efficient batch operations. They encourage inefficient patterns like loading entire tables with findAll() methods and prevent developers from using database-specific optimizations. Domain-specific repositories or direct ORM usage typically perform better.

What are the main problems with IRepository interface? The IRepository<T> interface cannot express common requirements like eager loading, complex filtering, pagination strategies, sorting, projections, or batch operations without abstraction leakage. Teams end up adding methods like findBySpecification() that bypass the generic interface, resulting in complexity without benefits. Different entities need fundamentally different data access patterns that generic interfaces cannot accommodate.

Conclusion

The generic repository pattern represents outdated thinking from an era of immature ORMs and limited testing capabilities. In 2025, this abstraction creates unnecessary complexity, hides powerful ORM features, and forces inefficient query patterns without delivering meaningful benefits.

Modern applications should use domain-specific repositories that express actual business operations or leverage ORMs directly in application services. This approach provides better performance, clearer code, and easier maintenance while remaining fully testable through containerized databases and modern testing frameworks.

Start by auditing your existing generic repositories. Identify which ones actually provide value versus which ones simply wrap ORM calls. Refactor toward domain-specific repositories for complex aggregates and direct ORM usage for simpler cases. Implement integration tests with containerized databases to validate behavior. Your codebase will become clearer, your queries more efficient, and your team more productive.