TechLead
Lesson 15 of 27
5 min read
Software Architecture

Repository Pattern

Learn data access abstraction with the repository pattern for clean separation between domain and persistence layers

What is the Repository Pattern?

The Repository Pattern mediates between the domain and data mapping layers, acting like an in-memory collection of domain objects. It encapsulates the logic required to access data sources, centralizing data access functionality and providing a clean separation between the domain model and the data access code.

Benefits

  • Testability: Swap the real repository with an in-memory implementation for unit tests
  • Decoupling: Domain logic does not depend on database technology — switch databases without touching business code
  • Single responsibility: Data access concerns are isolated from business logic
  • Consistency: A single place to add caching, logging, or query optimization for each entity type

Repository Interface (Port)

// Generic repository interface
interface Repository {
  findById(id: TId): Promise;
  findAll(): Promise;
  save(entity: T): Promise;
  delete(id: TId): Promise;
  exists(id: TId): Promise;
}

// Domain-specific repository with custom queries
interface UserRepository extends Repository {
  findByEmail(email: string): Promise;
  findByRole(role: UserRole): Promise;
  findActive(): Promise;
  countByRole(role: UserRole): Promise;
}

interface OrderRepository extends Repository {
  findByCustomer(customerId: CustomerId): Promise;
  findByStatus(status: OrderStatus): Promise;
  findRecent(limit: number): Promise;
  getTotalRevenue(since: Date): Promise;
}

// Specification pattern for complex queries
interface Specification {
  isSatisfiedBy(entity: T): boolean;
  toQuery(): QueryCondition; // Database-specific query
}

class ActiveUsersInRole implements Specification {
  constructor(private readonly role: UserRole) {}

  isSatisfiedBy(user: User): boolean {
    return user.isActive && user.role === this.role;
  }

  toQuery(): QueryCondition {
    return { is_active: true, role: this.role };
  }
}

Concrete Implementations

// PostgreSQL implementation
class PostgresUserRepository implements UserRepository {
  constructor(private readonly pool: Pool) {}

  async findById(id: UserId): Promise {
    const result = await this.pool.query(
      "SELECT * FROM users WHERE id = $1",
      [id.value]
    );
    return result.rows[0] ? this.toDomain(result.rows[0]) : null;
  }

  async findAll(): Promise {
    const result = await this.pool.query("SELECT * FROM users ORDER BY created_at");
    return result.rows.map(row => this.toDomain(row));
  }

  async findByEmail(email: string): Promise {
    const result = await this.pool.query(
      "SELECT * FROM users WHERE email = $1",
      [email]
    );
    return result.rows[0] ? this.toDomain(result.rows[0]) : null;
  }

  async findByRole(role: UserRole): Promise {
    const result = await this.pool.query(
      "SELECT * FROM users WHERE role = $1",
      [role]
    );
    return result.rows.map(row => this.toDomain(row));
  }

  async findActive(): Promise {
    const result = await this.pool.query(
      "SELECT * FROM users WHERE is_active = true"
    );
    return result.rows.map(row => this.toDomain(row));
  }

  async save(user: User): Promise {
    await this.pool.query(
      `INSERT INTO users (id, email, name, role, is_active, created_at, updated_at)
       VALUES ($1, $2, $3, $4, $5, $6, NOW())
       ON CONFLICT (id) DO UPDATE SET
         email = $2, name = $3, role = $4, is_active = $5, updated_at = NOW()`,
      [user.id.value, user.email, user.name, user.role, user.isActive, user.createdAt]
    );
  }

  async delete(id: UserId): Promise {
    await this.pool.query("DELETE FROM users WHERE id = $1", [id.value]);
  }

  async exists(id: UserId): Promise {
    const result = await this.pool.query(
      "SELECT 1 FROM users WHERE id = $1", [id.value]
    );
    return result.rows.length > 0;
  }

  async countByRole(role: UserRole): Promise {
    const result = await this.pool.query(
      "SELECT COUNT(*) FROM users WHERE role = $1", [role]
    );
    return parseInt(result.rows[0].count);
  }

  // Map database row to domain entity
  private toDomain(row: Record): User {
    return new User(
      new UserId(row.id as string),
      row.email as string,
      row.name as string,
      row.role as UserRole,
      row.is_active as boolean,
      row.created_at as Date
    );
  }
}

// In-memory implementation for testing
class InMemoryUserRepository implements UserRepository {
  private users = new Map();

  async findById(id: UserId): Promise {
    return this.users.get(id.value) || null;
  }

  async findAll(): Promise {
    return [...this.users.values()];
  }

  async findByEmail(email: string): Promise {
    return [...this.users.values()].find(u => u.email === email) || null;
  }

  async findByRole(role: UserRole): Promise {
    return [...this.users.values()].filter(u => u.role === role);
  }

  async findActive(): Promise {
    return [...this.users.values()].filter(u => u.isActive);
  }

  async save(user: User): Promise {
    this.users.set(user.id.value, user);
  }

  async delete(id: UserId): Promise {
    this.users.delete(id.value);
  }

  async exists(id: UserId): Promise {
    return this.users.has(id.value);
  }

  async countByRole(role: UserRole): Promise {
    return [...this.users.values()].filter(u => u.role === role).length;
  }
}

Repository with Caching

// Decorator pattern: Add caching to any repository
class CachedUserRepository implements UserRepository {
  private cache = new Map();

  constructor(
    private readonly inner: UserRepository,
    private readonly ttlMs: number = 60000
  ) {}

  async findById(id: UserId): Promise {
    const cached = this.cache.get(id.value);
    if (cached && cached.expiresAt > Date.now()) {
      return cached.user;
    }

    const user = await this.inner.findById(id);
    if (user) {
      this.cache.set(id.value, { user, expiresAt: Date.now() + this.ttlMs });
    }
    return user;
  }

  async save(user: User): Promise {
    await this.inner.save(user);
    // Invalidate cache on write
    this.cache.delete(user.id.value);
  }

  async delete(id: UserId): Promise {
    await this.inner.delete(id);
    this.cache.delete(id.value);
  }

  // Delegate remaining methods
  findAll = () => this.inner.findAll();
  findByEmail = (email: string) => this.inner.findByEmail(email);
  findByRole = (role: UserRole) => this.inner.findByRole(role);
  findActive = () => this.inner.findActive();
  exists = (id: UserId) => this.inner.exists(id);
  countByRole = (role: UserRole) => this.inner.countByRole(role);
}

// Usage — compose repositories with caching
const pgRepo = new PostgresUserRepository(pool);
const cachedRepo = new CachedUserRepository(pgRepo, 30000); // 30s cache
const useCase = new GetUserUseCase(cachedRepo);

Repository Anti-Patterns

  • Generic-only repositories: Do not limit yourself to generic CRUD — add domain-specific query methods
  • Leaking persistence concerns: Repository interfaces should not expose ORM-specific types or SQL fragments
  • Too many methods: If a repository has 30+ methods, it may be doing too much — consider splitting by read/write (CQRS)
  • Returning raw database rows: Always map to domain entities — the repository is the boundary between persistence and domain

Continue Learning