Vasu Patel logoVasu Patel
← Patterns of Good Design
All chapters in this track
  1. 01Chapter 1: Why Patterns Exist — Vocabulary, Not a Checklist
  2. 02Chapter 2: Patterns and SOLID: How They Relate
  3. 03Chapter 3: Creational Patterns I: Factory Method & Abstract Factory
  4. 04Chapter 4: Creational Patterns II: Builder, Singleton, Prototype
  5. 05Chapter 5: Structural Patterns I: Adapter & Facade
  6. 06Chapter 6: Structural Patterns II: Decorator & Proxy
  7. 07Chapter 7: Structural Patterns III: Composite, Bridge & Flyweight
  8. 08Chapter 8: Behavioral Patterns I: Strategy & Template Method
  9. 09Chapter 9: Behavioral Patterns II: Observer & Mediator
  10. 10Chapter 10: Behavioral Patterns III: Command & Memento
  11. 11Chapter 11: Behavioral Patterns IV: State & Chain of Responsibility
  12. 12Chapter 12: Behavioral Patterns V: Iterator & Visitor
  13. 13Chapter 13: Anti-Patterns and Overuse: Recognizing "Pattern Fever"
  14. 14Chapter 14: How Dynamic and Functional Languages Solve These Problems Differently
  15. 15Chapter 15: Architectural Patterns I: MVC/MVVM & Layered/Hexagonal Architecture
  16. 16Chapter 16: Architectural Patterns II: Event-Driven Architecture & CQRS
  17. 17Chapter 17: Architectural Patterns III: The Repository Pattern
  18. 18Chapter 18: Recognizing Which Pattern Fits an Interview Design Prompt

Chapter 17 of 18

Chapter 17: Architectural Patterns III: The Repository Pattern

Why this matters

The Repository pattern is likely the architectural pattern you'll encounter most often day-to-day, in the widest range of codebases — it's the standard answer to "how does business logic talk to a database without being welded to it," and it's already appeared, uncredited, in this book's own OOP companion (Chapter 10's OrderRepository example) and in this book's Hexagonal Architecture section (Chapter 15's UserRepositoryPort). This chapter names it explicitly and treats it as a first-class architectural decision in its own right.

The core concept

The problem it solves: business logic that directly issues SQL (or ORM calls, or any specific persistence mechanism's API) is tightly coupled to that persistence technology — hard to test without a real database, and hard to change if the storage technology ever needs to change.

Before — business logic tightly coupled to a specific persistence mechanism:

class OrderService {
  async placeOrder(customerId: string, items: object[]): Promise<void> {
    // Direct SQL inside business logic — testing this requires a real database connection.
    await db.query("INSERT INTO orders (customer_id, items) VALUES ($1, $2)", [customerId, JSON.stringify(items)]);
  }
}

After — Repository abstracts persistence behind a domain-shaped interface:

interface Order {
  customerId: string;
  items: { sku: string; qty: number }[];
}

interface OrderRepository {
  save(order: Order): Promise<void>;
  findByCustomerId(customerId: string): Promise<Order[]>;
}

class SqlOrderRepository implements OrderRepository {
  async save(order: Order): Promise<void> {
    await db.query("INSERT INTO orders (customer_id, items) VALUES ($1, $2)", [order.customerId, JSON.stringify(order.items)]);
  }
  async findByCustomerId(customerId: string): Promise<Order[]> {
    const rows = await db.query("SELECT * FROM orders WHERE customer_id = $1", [customerId]);
    return rows.map(mapRowToOrder);
  }
}

class InMemoryOrderRepository implements OrderRepository {
  private orders: Order[] = [];
  async save(order: Order): Promise<void> { this.orders.push(order); }
  async findByCustomerId(customerId: string): Promise<Order[]> {
    return this.orders.filter(o => o.customerId === customerId);
  }
}

class OrderService {
  constructor(private repository: OrderRepository) {} // depends on the interface, not SQL

  async placeOrder(customerId: string, items: { sku: string; qty: number }[]): Promise<void> {
    await this.repository.save({ customerId, items }); // business logic, testable with InMemoryOrderRepository
  }
}
from typing import Protocol
from dataclasses import dataclass

@dataclass
class Order:
    customer_id: str
    items: list[dict]

class OrderRepository(Protocol):
    def save(self, order: Order) -> None: ...
    def find_by_customer_id(self, customer_id: str) -> list[Order]: ...

class InMemoryOrderRepository:
    def __init__(self) -> None:
        self._orders: list[Order] = []

    def save(self, order: Order) -> None:
        self._orders.append(order)

    def find_by_customer_id(self, customer_id: str) -> list[Order]:
        return [o for o in self._orders if o.customer_id == customer_id]

class OrderService:
    def __init__(self, repository: OrderRepository) -> None:
        self._repository = repository

    def place_order(self, customer_id: str, items: list[dict]) -> None:
        self._repository.save(Order(customer_id, items))

[IN PRACTICE] This is a direct, named instance of Dependency Inversion (OOP book, Chapter 10) applied specifically to persistence — the "abstraction both sides depend on" is the OrderRepository interface, the "high-level module" is OrderService, and the "low-level module" is SqlOrderRepository. The Repository pattern is essentially DIP with a standard vocabulary and a standard shape (methods like save, findById, findByX) specific to the data-access use case.

Repository vs. a "thin wrapper around the ORM"

[SENIOR INSIGHT] A subtle but important distinction: a class that just re-exposes an ORM's query builder methods one-to-one (findWhere(conditions), queryBuilder()) isn't really providing DIP's benefit — it's still coupling callers to the ORM's query shape, just through an extra layer. A genuine Repository exposes methods in the domain's vocabulary (findActiveCustomers(), findOrdersPendingShipment()) that happen to be implemented using the ORM internally — the test for whether a Repository is doing its job is whether the business logic calling it would need to change if the ORM or database technology were swapped entirely. If the answer is "yes, extensively," the abstraction is leaking.

// Leaky: this "repository" just forwards the ORM's own query API — no real abstraction.
class LeakyOrderRepository {
  findWhere(conditions: Record<string, unknown>): Order[] { /* forwards directly to ORM */ return []; }
}

// Genuine: expresses domain concepts; the ORM is an implementation detail entirely hidden from callers.
class OrderRepository {
  findPendingShipment(): Order[] { /* internally: ORM query for status = 'pending_shipment' */ return []; }
}

When to use it, and when not to

When to use it: business logic needs to be testable independently of a real database, and/or the persistence technology might realistically change, and/or you want a clear, enforced boundary between domain logic and data-access concerns — this covers the large majority of applications with meaningful business logic and a database.

When to avoid it: extremely simple applications with essentially no business logic between the request and the database (thin CRUD APIs) — wrapping a single, simple query in a Repository interface with one implementation can be pure ceremony if there's genuinely no testing or technology-swapping need (Chapter 13's pattern-fever discipline, applied here too).

Complexity cost: low to moderate — one interface plus at least one concrete implementation per aggregate/entity type. The cost scales with the number of distinct entities needing repositories, and is well justified specifically when testability or technology independence is a real requirement.

Common mistakes

  1. Building a "Repository" that's really just a pass-through to the ORM's own query interface — this doesn't achieve real dependency inversion, since callers are still coupled to the ORM's query shape, just via an extra file.
  2. Never actually writing or using a second implementation (like InMemoryOrderRepository for tests), which is a strong sign the abstraction isn't earning its cost — if tests still spin up a real database anyway, the interface isn't delivering its main practical benefit.
  3. Putting business rules inside the repository (e.g., validating that an order total is positive inside OrderRepository.save()) — this is a layering violation (OOP book, Chapter 4's abstraction-layering table): the repository's job is persistence mechanics, not business rules.
  4. Introducing Repository for a trivial CRUD app with no testing or technology-independence need, purely because it's a well-known "best practice" — the same pattern-fever discipline from Chapter 13 applies to architectural patterns just as much as class-level ones.

Discussion / interview questions

  1. "What is the Repository pattern, and how does it relate to Dependency Inversion?" Strong answer: An interface abstracting persistence operations in domain-shaped terms, letting business logic depend on that abstraction rather than a specific database/ORM technology — a direct, named application of DIP to the data-access use case.

  2. "How do you tell a genuine Repository abstraction from a 'leaky' one that's just wrapping the ORM?" Strong answer: Check whether the repository's methods are expressed in domain vocabulary (e.g., findPendingShipment()) versus forwarding the ORM's own query API directly (findWhere(conditions)); the real test is whether swapping the underlying database/ORM would require changing the business logic that calls the repository.

  3. "When would introducing a Repository be unnecessary overhead?" Strong answer: Very simple CRUD applications with no meaningful business logic and no real need for independent testing or technology swapping — the interface-plus-implementation ceremony doesn't pay for itself there.

  4. "Where should business validation rules live relative to a Repository, and why?" Strong answer: Outside the repository, in the business/domain layer — the repository's responsibility is persistence mechanics only; mixing in business rules is a layering violation that couples validation logic to a specific persistence implementation.

Chapter summary / cheat sheet

  • Repository: abstracts persistence behind a domain-shaped interface — Dependency Inversion (OOP book, Chapter 10), specifically for data access, with a standard vocabulary (save, findById, domain-specific finders).
  • A genuine Repository is tested by whether business logic would need to change if the underlying database/ORM were swapped — if yes, the abstraction is leaking (often because it's just forwarding the ORM's own query API).
  • Business rules belong outside the repository, not inside it — the repository's job is persistence mechanics only.
  • Like every architectural pattern in this book, only introduce it for a real testing or technology-independence need — not as a reflexive "best practice" applied to every project regardless of size.