Skip to content
SP StackPractices
intermediate By Mathias Paulenko

Repository Pattern with TypeScript Generics

Implement a type-safe repository pattern in TypeScript that decouples data access logic from domain services using generics and interfaces

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Repository Pattern with TypeScript Generics

The Repository pattern mediates between the domain and data mapping layers. It acts like an in-memory collection of domain objects, abstracting away persistence details so your services remain focused on business logic.

When to Use This

  • You want to swap database technologies without touching business logic
  • Unit tests must run without a real database
  • Multiple domain services share similar query patterns

Problem

Direct database queries scattered across services make testing impossible, migrations risky, and query optimization a hunt across the codebase.

Solution

// repositories/Repository.ts
interface Repository<T, ID> {
  findById(id: ID): Promise<T | null>;
  findAll(filter?: Partial<T>): Promise<T[]>;
  create(entity: Omit<T, 'id'>): Promise<T>;
  update(id: ID, entity: Partial<T>): Promise<T | null>;
  delete(id: ID): Promise<boolean>;
}

// repositories/MongooseRepository.ts
import { Model, Types } from 'mongoose';

class MongooseRepository<T extends { id: string }> implements Repository<T, string> {
  constructor(private model: Model<any>) {}

  async findById(id: string): Promise<T | null> {
    const doc = await this.model.findById(id).lean();
    return doc ? this.toEntity(doc) : null;
  }

  async findAll(filter: Record<string, any> = {}): Promise<T[]> {
    const docs = await this.model.find(filter).lean();
    return docs.map(this.toEntity);
  }

  async create(data: Omit<T, 'id'>): Promise<T> {
    const doc = await this.model.create(data);
    return this.toEntity(doc.toObject());
  }

  async update(id: string, data: Partial<T>): Promise<T | null> {
    const doc = await this.model.findByIdAndUpdate(id, data, { new: true }).lean();
    return doc ? this.toEntity(doc) : null;
  }

  async delete(id: string): Promise<boolean> {
    const result = await this.model.findByIdAndDelete(id);
    return !!result;
  }

  private toEntity(doc: any): T {
    const { _id, __v, ...rest } = doc;
    return { id: _id.toString(), ...rest } as T;
  }
}

// domain/User.ts
interface User {
  id: string;
  email: string;
  name: string;
  role: string;
}

// services/UserService.ts
class UserService {
  constructor(private userRepo: Repository<User, string>) {}

  async promoteToAdmin(userId: string) {
    const user = await this.userRepo.findById(userId);
    if (!user) throw new Error('User not found');
    return this.userRepo.update(userId, { role: 'admin' });
  }
}

Usage

const userRepo = new MongooseRepository<User>(UserModel);
const userService = new UserService(userRepo);

Variations

  • In-Memory Repository: For unit testing with a Map-backed implementation
  • Specification Pattern: Compose query filters as reusable specification objects
  • Unit of Work: Batch multiple repository operations into a single transaction

Best Practices

  • Return domain entities, not database documents, from repository methods
  • Keep repositories focused on persistence; business rules belong in services
  • Inject the repository interface, not the concrete implementation

Common Mistakes

  • Leaking ORM queries into service methods
  • Returning raw database documents instead of mapped entities
  • Putting transaction management inside the repository instead of the service layer

FAQ

Q: Is Repository pattern overkill for small projects? A: For simple CRUD apps, active record is fine. Use repositories when you need testability, multiple data sources, or complex query logic.

Q: How does this compare to the Active Record pattern? A: Active Record mixes data access and domain logic. Repository separates them, making the domain layer independent from persistence.