Skip to content

Repository Pattern — Data Access Abstraction (2026)

DodaTech Updated 2026-06-20 5 min read

In this tutorial, you'll learn how the repository pattern decouples business logic from data storage, why it makes testing easier, and how real applications swap database implementations without changing business code.

Imagine you run a library. Readers come to you with a book title, and you retrieve it from wherever books are stored — maybe shelf A today, shelf B tomorrow after a reorganization. The reader doesn't need to know where you keep the books. They just hand you a request and get a book back. The repository pattern does the same for data: your business code asks for a user, and the repository handles the "where" and "how" of fetching it.

Core Concept

The repository pattern introduces an abstraction layer between business logic and data access. Instead of calling a database directly, business code talks to a repository interface. The actual implementation — SQL database, in-memory list, REST API, file system — is hidden behind that interface.

// The interface — business code depends on this
interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
  delete(id: string): Promise<void>;
}

// Business layer only knows the interface
class UserService {
  constructor(private repo: UserRepository) {}

  async updateEmail(id: string, email: string) {
    const user = await this.repo.findById(id);
    if (!user) throw new Error("User not found");
    user.email = email;
    await this.repo.save(user);
  }
}

How It Works

The repository sits between the business layer and the data source. Business code calls repository methods like findById or save. The repository translates these into the appropriate data operations — SQL queries, HTTP calls, or in-memory lookups.

Business Code → Repository Interface → Repository Implementation → Data Source

Here's a complete example with two repository implementations:

// In-memory implementation — great for tests
class InMemoryUserRepository implements UserRepository {
  private users: Map<string, User> = new Map();

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

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

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

// PostgreSQL implementation — used in production
class PostgresUserRepository implements UserRepository {
  async findById(id: string): Promise<User | null> {
    const result = await pool.query(
      "SELECT * FROM users WHERE id = $1", [id]
    );
    return result.rows[0] ?? null;
  }

  async save(user: User): Promise<void> {
    await pool.query(
      `INSERT INTO users (id, name, email)
       VALUES ($1, $2, $3)
       ON CONFLICT (id) DO UPDATE
       SET name = $2, email = $3`,
      [user.id, user.name, user.email]
    );
  }

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

Expected output: Both implementations satisfy the same interface. The UserService works identically with either one. No business code changes when switching from in-memory to Postgres.

Real-World Examples

Unit Testing with Mock Repositories

In Durga Antivirus Pro, the signature scanning engine uses the repository pattern. During development, test suites use an InMemorySignatureRepository that loads test signatures from a JSON file. Production uses a PostgresSignatureRepository. The scanning engine never knows the difference — and tests run in milliseconds without a database.

Multi-Provider Support

DodaZIP supports cloud storage backends (S3, Google Cloud, local file system) through a repository abstraction. Adding a new storage provider means writing one new class that implements the FileRepository interface. Zero changes to the compression or decompression logic.

Pros & Cons

Pros Cons
Business logic is database-agnostic Adds extra interfaces and classes
Unit tests use fast in-memory implementations ORMs already provide some abstraction
Swap databases without touching business code Can feel like over-engineering for simple CRUD
Clear boundary between data access and logic Leaky abstractions if the repository exposes DB-specific types

When to Use

The repository pattern shines when:

  • You need to test business logic without a database
  • Your application might switch databases (prototype with SQLite, deploy with Postgres)
  • Multiple data sources exist — the repository hides whether data comes from a DB, cache, or external API
  • You want clean separation between domain concerns and infrastructure

Skip it for simple applications where an ORM's built-in abstraction is sufficient, or when you're building a throwaway prototype.

FAQ

Is the repository pattern the same as DAO (Data Access Object)?

Not exactly. A DAO is a lower-level abstraction that maps database operations to objects. A repository operates at a higher level — it works with domain objects and aggregates, while a DAO works closer to the database schema. In practice, many implementations blur this line.

Should I use the repository pattern with an ORM like TypeORM or Prisma?

ORMs already provide repository-like methods (findOne, save, delete). Adding your own repository layer on top of an ORM is useful when you want to: (1) decouple from the ORM entirely, (2) add domain-specific query methods, or (3) make the code testable without the ORM.

How does the repository pattern relate to Dependency Injection?

The repository pattern pairs naturally with Dependency Injection. You register the repository interface and its implementation with the DI container. Business services receive the interface via constructor injection. This makes it trivial to swap implementations per environment.

What about generic repositories?

A generic Repository<T> interface (findById, save, delete for any type) works for simple CRUD. But domain-specific repositories are preferred — they express intent (findActiveUsers, findByEmail) and can include domain-specific queries without leaking abstraction.

How do I handle transactions with the repository pattern?

The simplest approach is to pass a unit of work or transaction context through method parameters. More advanced setups use the Unit of Work pattern (used by ORMs like Entity Framework) that tracks changes across multiple repositories and commits them atomically

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro