Beginner13 min readDesign Patternslive prototype

Repository

Hide *where* and *how* your data is stored behind a plain collection-like API — `save`, `findById`, `findAll`, `delete`. Think of a **librarian**: you say "get me the book with id 7" and a book comes back. You never touch the shelves, the card catalog, or which building it lives in. Swap a SQL database for an in-memory list or a REST API and your business code doesn't change a single line — only the librarian does.

The idea

What it is

The Repository pattern gives your business code a collection-like interface for data — methods like save, findById, findAll, and delete — so it never knows or cares how or where that data is actually stored. To the caller, a repository looks and feels like a plain in-memory list you can add to, remove from, and search. Behind that friendly face sits the real machinery: a SQL database, a REST API, a file, or a genuine in-memory array. The caller can't tell the difference, and that's the whole point.

The mental model is a librarian. You walk up and say "get me the book with id 7" or "here, save this new book". A book comes back, or your book gets stored — done. You never touch the shelves, flip through the card catalog, or figure out which building the book lives in. The librarian hides all of that. And here's the payoff: the day the library switches from paper shelves to a computer system, your request doesn't change at all — you still just ask the librarian. Swapping Postgres for an in-memory fake (for tests) or for a REST API changes only the repository implementation, never the code that calls it.

The one sentence to remember

A Repository makes stored data look like a plain in-memory collection (save, findById, findAll, delete) — so your domain code talks to that collection and never to the database, and you can swap the real storage underneath without touching a single caller.

You've already met it

Spring Data JPA repositories, Rails ActiveRecord-style models, and .NET Entity Framework DbSets are all the Repository idea: your code says userRepository.findById(7) and stays blissfully unaware of the SQL underneath.

Mechanics

How it works

The interface — a collection you can swap

A repository starts as an interface: a small set of collection-like methods that describe what you can do with your data, not how it's done. For a User, that's usually the CRUD-ish quartet plus any domain queries you actually need:

typescript
interface UserRepository {
  save(user: User): void;          // add or update
  findById(id: number): User | null;
  findAll(): User[];
  delete(id: number): void;
}

Notice there's no SQL, no HTTP, no file path — nothing about storage at all. The caller depends only on this interface. That's [[program-to-interfaces]]: your domain code is written against the abstraction, so any implementation that honors it can be dropped in.

Concrete implementations behind it

Now you write one or more classes that satisfy that interface, each backed by a different store:

  • `SqlUserRepository` — turns findById(7) into SELECT * FROM users WHERE id = 7 and save(user) into an INSERT/UPDATE. It talks to a real database.
  • `InMemoryUserRepository` — keeps a plain Map or array in memory. save pushes, findById looks up the key. Perfect for tests: no database required.
  • `ApiUserRepository` — turns the same calls into HTTP requests: findById(7) becomes a GET /users/7, save(user) a POST /users.

All three are interchangeable because they honor the same UserRepository interface. The caller receives whichever one it's handed — usually via [[dependency-injection-and-ioc]] — and can't tell which. High-level code depending on an abstraction while the low-level storage details depend on that same abstraction is [[dependency-inversion]] in action.

Business code the caller UserRepository interface 🗄 SQL DB SELECT / INSERT 🧠 In-Memory array / map 🌐 REST API GET / POST one interface, swappable backends
The caller only ever touches the interface. Which concrete backend answers is a choice made elsewhere — swap it and the caller never notices.

Why bother — three concrete payoffs

  • Testability — hand your service an InMemoryUserRepository in tests. No database to spin up, no network, no flakiness. Tests run in milliseconds and stay deterministic.
  • One place for query logic — every way of fetching users lives inside the repository. Need a new query? It goes in one file, not scattered across a dozen call sites.
  • Persistence-ignorant domain — your business rules read like business rules, not like database plumbing. The domain never imports a SQL driver or an HTTP client.

Repository vs DAO — close, but not the same

People mix these up. A DAO (Data Access Object) is usually one table, one class, offering thin CRUD that maps very closely to the database — it thinks in rows and tables. A Repository is more domain-oriented: it deals in whole business objects (aggregates), behaves like an in-memory collection, and may pull data from several tables — or even several sources — to hand you one complete object. Roughly: DAO speaks database, Repository speaks domain. In practice a Repository is often implemented using one or more DAOs underneath.

The trap: leaky and pointless repositories

A repository earns its keep only if it truly hides the store. Two common failures: (1) Leaky abstractions — returning an IQueryable, a raw query builder, or exposing SQL fragments. Now the caller is writing database queries again, just through your object, and you can't swap the backend anymore. (2) The thin wrapper — a repository per table that just forwards straight to the ORM with zero added value. If your repo method is a one-line passthrough to dbContext.Users, it's ceremony, not abstraction. Repositories should hide the store and hold real query logic, not rubber-stamp it.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A tiny app with a fixed caller panel on the left — repo.findAll(), repo.save(user), repo.delete(id) — and three storage backend chips on the right: 🗄 SQL Database, 🧠 In-Memory, 🌐 REST API. Click a chip and watch the middle Repository box re-wire to a different backend while the caller panel stays identical — the counter reads client code changed: 0 lines. Then hit + Add user, 🔍 Find all, or 🗑 Delete: the same call runs, but the animation of where the data goes changes — SQL shows rows in a table, In-Memory shows a JS array, REST API shows a fetch to a URL. The librarian is the only thing that knows the backend.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Swap the backend, change zero caller code

Click the 🧠 In-Memory chip, then 🗄 SQL Database, then 🌐 REST API. Watch the middle Repository box re-wire to a different store each time — but the caller panel on the left never changes, and the counter stays at 'client code changed: 0 lines'. That's the promise: the store is swappable, the caller is untouched.

try 02

Run the same call on each store

With SQL active, hit + Add user — a new row slides into the table. Switch to In-Memory and hit + Add user again — this time the value is pushed onto a JS array. Switch to REST API and add once more — now you see a POST fetch to a URL. Same repo.save(user) call, three totally different destinations.

try 03

Find and delete through the interface

Click 🔍 Find all to run repo.findAll() — the whole collection lights up regardless of which backend holds it. Then hit 🗑 Delete to run repo.delete(id) and watch one user leave the store. Finish with ↺ Reset. The caller only ever said 'find all' and 'delete' — the librarian did the rest.

In practice

When to use it — and what trips people up

Reach for Repository when...

  • You want to swap storage without touching business code — you're on Postgres today but want an in-memory fake for tests, or you foresee moving to a different store or a REST API later.
  • Testing keeps dragging in a real database — an in-memory repository lets your service tests run fast, offline, and deterministically.
  • Query logic is scattered — you want one home for all the ways your code fetches a given kind of object, instead of raw queries sprinkled across call sites.
  • Your domain should stay persistence-ignorant — business rules that read like business rules, with no SQL or HTTP leaking in.

Skip it when...

  • It's a tiny app with one data source that will never change — the extra interface and class are ceremony with no payoff.
  • Your ORM already gives you a good-enough abstraction — if a repository would just forward straight to dbContext.Users or a Spring Data interface with nothing added, it's a pointless passthrough.
  • You'd end up leaking the store anyway — if the only way to express your queries is to hand back an IQueryable or raw builder, the abstraction buys you nothing.

What it gives you

  • Swappable storage — change SQL → in-memory → REST API by writing a new implementation, with zero changes to callers.
  • Testability — drop in an in-memory fake and your service tests run fast, offline, and deterministically.
  • Centralized query logic — every way of fetching an object lives in one place instead of being scattered across the codebase.
  • Persistence-ignorant domain — business code depends on a collection-like interface, never on a database driver or HTTP client.

Common mistakes

  • Extra indirection — one more layer and interface to write and maintain, which is overkill for tiny or throwaway apps.
  • Passthrough risk — a repository-per-table that just forwards to the ORM adds ceremony without real abstraction.
  • Leaky abstractions — expose an IQueryable or raw query builder and callers are writing database queries again, killing the swap-ability.
  • Duplicated ORM features — a good ORM may already provide change tracking, caching, and querying you now partly re-implement.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

// The collection-like interface — the ONLY thing the caller depends on.
interface User { id: number; name: string; }

interface UserRepository {
  save(user: User): void;
  findById(id: number): User | undefined;
  findAll(): User[];
  delete(id: number): void;
}

// One concrete implementation — a plain in-memory store (great for tests).
class InMemoryUserRepository implements UserRepository {
  private users = new Map<number, User>();
  save(user: User): void { this.users.set(user.id, user); }
  findById(id: number) { return this.users.get(id); }
  findAll(): User[] { return [...this.users.values()]; }
  delete(id: number): void { this.users.delete(id); }
}

// The caller talks to the INTERFACE — it has no idea what's behind it.
function greetEveryone(repo: UserRepository): void {
  repo.save({ id: 1, name: "Ada" });
  repo.save({ id: 2, name: "Linus" });
  for (const u of repo.findAll()) console.log("Hi " + u.name);
}

greetEveryone(new InMemoryUserRepository());  // swap this for SqlUserRepository, unchanged caller

References & further reading

5 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 06

What does the caller actually see when it uses a Repository?

question 02 / 06

You switch a service from a SQL database to an in-memory store for tests. What has to change?

question 03 / 06

How does a Repository differ from a DAO?

question 04 / 06

Which of these is the classic leaky-abstraction trap for repositories?

question 05 / 06

Why is a Repository good for testing?

question 06 / 06

When is a Repository probably NOT worth it?

0/6 answered