Skip to content

Repository Pattern

Abstracting data persistence through domain-focused interfaces

Related Concepts: Clean Architecture | Dependency Inversion | Use Cases

What is a Repository?

A repository is an object that provides a collection-like interface for accessing domain objects while abstracting away the specific storage implementation. As Martin Fowler defines it:

"Mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects."

The key insight: repositories operate on domain entities, not database models or DTOs.

Key Principles

1. Domain-Centric Interface

Repositories work with domain entities and speak the language of the domain:

typescript
// ✅ Repository operates on domain entity
interface OrganizationRepository {
  save(organization: Organization): Promise<Organization>;
  findById(id: OrganizationId): Promise<Organization | null>;
  findBySlug(slug: string): Promise<Organization | null>;
}

// ❌ Not database models or DTOs
interface WrongRepository {
  save(data: OrganizationDTO): Promise<DatabaseRecord>;  // Wrong!
}

2. Collection-Like Semantics

Repositories act like in-memory collections of domain objects:

typescript
interface UserRepository {
  add(user: User): Promise<void>;
  remove(user: User): Promise<void>;
  findById(id: UserId): Promise<User | null>;
  findByEmail(email: Email): Promise<User | null>;
  findActiveUsers(): Promise<User[]>;
}

3. Abstraction of Persistence

The repository hides how and where data is stored:

typescript
// Domain layer defines the interface
interface OrderRepository {
  save(order: Order): Promise<Order>;
  findById(id: OrderId): Promise<Order | null>;
}

// Infrastructure layer implements it
class PostgresOrderRepository implements OrderRepository {
  async save(order: Order): Promise<Order> {
    // Convert domain entity to database format
    const dbRecord = this.toDatabaseFormat(order);
    await this.db.query('INSERT INTO orders...', dbRecord);
    return order;
  }
}

When to Use Repositories

Create Repositories For:

  • Aggregate Roots - Entities that control access to related objects
  • Entities that need direct access - Core domain objects that use cases retrieve directly
  • Collections of domain objects - When you need to work with groups of entities

Don't Create Repositories For:

  • Value Objects - These should be persisted as part of their parent entity
  • Every database table - Repositories are for domain access, not database mapping
  • Read-only queries - Consider query services or read models for complex queries

Repository as a Port

In Clean Architecture, repositories are outbound ports - interfaces defined by the domain or use case layer, implemented by the infrastructure layer:

typescript
// Domain layer (inner circle) defines what it needs
namespace Domain {
  export interface OrganizationRepository {
    save(organization: Organization): Promise<Organization>;
    findById(id: string): Promise<Organization | null>;
  }
}

// Use case depends on the interface
class CreateOrganizationUseCase {
  constructor(
    private repository: Domain.OrganizationRepository
  ) {}
  
  async execute(request: CreateOrgRequest): Promise<Organization> {
    const organization = new Organization(request);
    return await this.repository.save(organization);
  }
}

// Infrastructure layer (outer circle) implements it
class SqlOrganizationRepository implements Domain.OrganizationRepository {
  async save(organization: Organization): Promise<Organization> {
    // SQL-specific implementation
  }
}

This follows the Dependency Rule: the inner layers don't know about the outer layers.

Common Repository Methods

Typical repository methods focus on domain operations:

typescript
interface Repository<T, ID> {
  // Basic operations
  save(entity: T): Promise<T>;
  findById(id: ID): Promise<T | null>;
  findAll(): Promise<T[]>;
  delete(entity: T): Promise<void>;
  
  // Domain-specific queries
  findBySpecification(spec: Specification<T>): Promise<T[]>;
  count(spec?: Specification<T>): Promise<number>;
  exists(id: ID): Promise<boolean>;
}

Best Practices

DO ✅

  • Define repositories in the domain layer - The domain decides what it needs
  • Return domain entities - Not database records or DTOs
  • Accept domain entities as parameters - save(user: User) not save(userData: any)
  • Use domain language - findActiveCustomers() not findByStatusEquals1()
  • Keep interfaces simple - Only methods the domain actually needs

DON'T ❌

  • Expose database concerns - No SQL in repository interfaces
  • Return database-specific types - No Promise<QueryResult>
  • Create generic repositories for everything - Be intentional
  • Mix commands and queries indiscriminately - Consider CQRS for complex cases
  • Leak persistence details - No saveWithTransaction() in the interface

Example: Organization Repository

Here's a complete example showing the pattern in practice:

typescript
// Domain entity
class Organization {
  constructor(
    private id: string,
    private name: string,
    private slug: string,
    private members: Member[]
  ) {}
  
  addMember(member: Member): void {
    // Business logic for adding members
  }
  
  // ... other domain methods
}

// Repository interface (defined by domain)
interface OrganizationRepository {
  save(organization: Organization): Promise<Organization>;
  findById(id: string): Promise<Organization | null>;
  findBySlug(slug: string): Promise<Organization | null>;
  findUserOrganizations(userId: string): Promise<Organization[]>;
}

// Use case using the repository
class JoinOrganizationUseCase {
  constructor(
    private orgRepository: OrganizationRepository,
    private userRepository: UserRepository
  ) {}
  
  async execute(userId: string, orgSlug: string): Promise<void> {
    const [user, organization] = await Promise.all([
      this.userRepository.findById(userId),
      this.orgRepository.findBySlug(orgSlug)
    ]);
    
    if (!user || !organization) {
      throw new Error('User or organization not found');
    }
    
    organization.addMember(user);
    await this.orgRepository.save(organization);
  }
}

Testing with Repositories

Repositories make testing easy by providing a clear seam for test doubles:

typescript
// Simple in-memory implementation for tests
class InMemoryOrganizationRepository implements OrganizationRepository {
  private organizations = new Map<string, Organization>();
  
  async save(organization: Organization): Promise<Organization> {
    this.organizations.set(organization.id, organization);
    return organization;
  }
  
  async findById(id: string): Promise<Organization | null> {
    return this.organizations.get(id) || null;
  }
}

// Test uses in-memory repository
it('should add member to organization', async () => {
  const repo = new InMemoryOrganizationRepository();
  const useCase = new JoinOrganizationUseCase(repo, userRepo);
  
  await useCase.execute(userId, orgSlug);
  
  const org = await repo.findById(orgId);
  expect(org.hasMember(userId)).toBe(true);
});

Conclusion

The Repository pattern is beautifully simple: it provides a domain-focused interface for persisting and retrieving domain objects. By operating on domain entities rather than data transfer objects, repositories maintain the integrity of your domain model while keeping infrastructure concerns where they belong—in the infrastructure layer.

Remember: repositories are about your domain, not your database.

Further Reading

  • Repository - Martin Fowler's original pattern description
  • Domain-Driven Design - Eric Evans on repositories and aggregates
  • Patterns of Enterprise Application Architecture - Martin Fowler's complete patterns catalog

For implementation examples, see our Repository Pattern in NestJS guide.