Skip to content
TypeScript ts patterns 4 min read

Repository Pattern

The Repository pattern places a typed abstraction between your domain logic and the mechanism that actually stores data. Instead of sprinkling SQL queries, HTTP calls, or ORM specifics throughout your services, you funnel all persistence through a small interface that speaks in domain terms — findById, save, delete. Callers depend on the interface, not on Postgres or a REST endpoint, so storage can change without touching business rules. In TypeScript a repository is naturally generic, giving you reusable CRUD contracts with full type safety. This page shows the interface, an in-memory and a real implementation, and where the pattern helps or hurts.

The problem it solves

When services talk to the database directly, persistence details leak everywhere. Tests need a live database, swapping data sources means a rewrite, and the same query logic is duplicated across call sites.

// ❌ Business logic welded to the database driver
async function deactivateUser(id: string) {
  const rows = await db.query("SELECT * FROM users WHERE id = $1", [id]);
  if (!rows[0]) throw new Error("not found");
  await db.query("UPDATE users SET active = false WHERE id = $1", [id]);
}

This function cannot be unit-tested without a database, and the SQL string is repeated in every place that loads a user. A repository hides those details behind a method call.

Defining the repository contract

Start with a generic interface for the operations every aggregate needs. Keeping it generic means each entity gets the same predictable surface.

interface Repository<T, ID = string> {
  findById(id: ID): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: ID): Promise<void>;
}

interface User {
  id: string;
  email: string;
  active: boolean;
}

interface UserRepository extends Repository<User> {
  findByEmail(email: string): Promise<User | null>;
}

Repository<T, ID> captures the common CRUD shape, while UserRepository extends it with domain-specific queries. Returning T | null (rather than throwing) makes “not found” an explicit, type-checked outcome that callers must handle.

An in-memory implementation for tests

Because consumers depend only on the interface, a Map-backed implementation is a perfect test double — no database, fully deterministic.

class InMemoryUserRepository implements UserRepository {
  private readonly store = new Map<string, User>();

  async findById(id: string): Promise<User | null> {
    return this.store.get(id) ?? null;
  }

  async findAll(): Promise<User[]> {
    return [...this.store.values()];
  }

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

  async save(user: User): Promise<User> {
    this.store.set(user.id, user);
    return user;
  }

  async delete(id: string): Promise<void> {
    this.store.delete(id);
  }
}

Tests instantiate this in microseconds and assert against real behavior. The ?? null conversions keep the implementation faithful to the interface’s T | null contract.

A real implementation

The production version implements the same interface against an actual data source. Swapping it in requires no change to any consumer.

class SqlUserRepository implements UserRepository {
  constructor(private readonly db: { query: <R>(sql: string, p: unknown[]) => Promise<R[]> }) {}

  async findById(id: string): Promise<User | null> {
    const rows = await this.db.query<User>("SELECT * FROM users WHERE id = $1", [id]);
    return rows[0] ?? null;
  }

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

  async findAll(): Promise<User[]> {
    return this.db.query<User>("SELECT * FROM users", []);
  }

  async save(user: User): Promise<User> {
    await this.db.query(
      "INSERT INTO users (id, email, active) VALUES ($1,$2,$3) ON CONFLICT (id) DO UPDATE SET email = $2, active = $3",
      [user.id, user.email, user.active],
    );
    return user;
  }

  async delete(id: string): Promise<void> {
    await this.db.query("DELETE FROM users WHERE id = $1", [id]);
  }
}

Now the service from earlier depends on UserRepository, accepts either implementation, and contains zero SQL — it is pure, testable domain logic.

When to use and pitfalls

Use Repository when…Reconsider when…
Domain logic must stay storage-agnosticThe app is a thin CRUD wrapper with no logic
You need fast, DB-free unit testsA capable ORM already gives a repository-like API
Multiple data sources back one entityThe abstraction only ever has one implementation
You want a single place for query logicLeaky methods re-expose storage details

A repository that returns ORM entities or query builders leaks its implementation. Return plain domain objects so callers stay decoupled from how data is fetched.

Use the Repository pattern when business rules are substantial and you value testability and the freedom to change storage. Skip it for trivial apps where it just wraps the ORM in another layer that adds indirection without insulating anything.

Best Practices

  • Define repositories as interfaces; depend on the interface, never the concrete class.
  • Keep methods in domain language (findActiveCustomers), not storage language.
  • Return domain objects or null, never ORM entities or raw query builders.
  • Provide an in-memory implementation to make unit tests fast and deterministic.
  • Make “not found” explicit with T | null instead of throwing for control flow.
  • Inject the repository so production and test wiring choose the implementation.
  • Avoid one giant repository; scope each to a single aggregate root.
Last updated June 29, 2026
Was this helpful?