Skip to main content

Repository

complexitypopularity

Put a collection-like interface between domain logic and data access, so business code depends on `find`/`save` instead of SQL, an ORM, or an API client.

The problem

OrderService.cancelOrder() needs to look up an order, cancel it, and save the change. The most direct way to write that also welds the method to SQL: a query string, a row-to-object conversion, an UPDATE statement, all inline in business logic that has nothing to do with databases.

Two consequences follow immediately. Testing cancelOrder() now requires a real database, because the SQL is inseparable from the logic. And if the company ever swaps Postgres for something else, or adds a cache in front of it, every method that touched the database directly needs surgery.

The solution

Describe the storage need the way the domain actually thinks about it - not "run this query" but "find an order by id" and "save this order" - and put that description in an interface. Business logic depends on the interface, never on the database driver underneath it.

One implementation of that interface talks to the real database. A second, much simpler one keeps everything in a plain in-memory map. Both honor the exact same contract, so OrderService runs identically against either one - it has no way to tell which storage technology, if any, is on the other side.

Test suiteProduction wiringOrderServiceOrderRepository (whichever is wired in)new OrderService(new PostgresOrderRepository())1findById(orderId)2SELECT * FROM orders WHERE id = ?3order.cancel()4save(order)5new OrderService(new InMemoryOrderRepository())6findById(orderId)7assert order.status == CANCELLED8
  1. 1In production, the service is handed a repository backed by a real database.
  2. 2cancelOrder() asks the repository for the order, through the interface, never through SQL directly.
  3. 3PostgresOrderRepository does whatever a real database call requires. The service never sees this line.
  4. 4Business logic runs entirely in domain terms: an Order object, a cancel() method, no rows or columns in sight.
  5. 5The service persists the change the same way it read it: through the interface.
  6. 6A unit test wires up the exact same service, but with an in-memory stand-in - no database container, no network.
  7. 7Identical call, identical service code. This time it hits a plain map instead of a table.
  8. 8The test runs in milliseconds and never touched a database - because the domain logic never knew there was one.

Structure

OrderService has exactly one dependency in this diagram, and it is an interface. Both concrete repositories sit beside each other as interchangeable implementations - the diagram has no arrow at all between OrderService and either concrete class.

«interface»OrderRepositoryfindById(id)findByCustomer(customerId)save(order)delete(id)DOMAIN-FACINGPostgresOrderRepositoryfindById(id)findByCustomer(customerId)save(order)delete(id)REALInMemoryOrderRepositoryfindById(id)findByCustomer(customerId)save(order)delete(id)TESTOrderServiceorders: OrderRepositorycancelOrder(id)DOMAIN
implementsuses

Code

Same example three ways: an order-cancellation flow that never mentions SQL.

// Business logic and a SQL driver, welded together.
class OrderService is
field db: SqlConnection
 
method cancelOrder(orderId) is
row = db.query("SELECT * FROM orders WHERE id = ?", orderId)
if row == null then
throw new NotFoundError()
order = Order.fromRow(row)
order.cancel()
db.execute("UPDATE orders SET status = ? WHERE id = ?", order.status, order.id)
// Testing this means standing up a real database. Every time.
// OrderService never imports a SQL driver, an ORM, or a connection string.
class OrderService {
constructor(private orders /* : OrderRepository */) {}
 
cancelOrder(orderId) {
const order = this.orders.findById(orderId);
if (!order) throw new NotFoundError();
order.cancel();
this.orders.save(order); // could be Postgres, could be a test double
}
}
// Production: new OrderService(new PostgresOrderRepository(db))
// Tests: new OrderService(new InMemoryOrderRepository())
// The interface the domain layer is allowed to depend on.
interface OrderRepository is
method findById(orderId)
method findByCustomer(customerId)
method save(order)
method delete(orderId)
 
// The real implementation. All the SQL lives here, and nowhere else.
class PostgresOrderRepository implements OrderRepository is
field db: SqlConnection
 
method findById(orderId) is
row = db.query("SELECT * FROM orders WHERE id = ?", orderId)
if row == null then
return null
return Order.fromRow(row)
 
method findByCustomer(customerId) is
rows = db.query("SELECT * FROM orders WHERE customer_id = ?", customerId)
return rows.map(row => Order.fromRow(row))
 
method save(order) is
db.execute("UPDATE orders SET status = ?, total = ? WHERE id = ?",
order.status, order.total, order.id)
 
method delete(orderId) is
db.execute("DELETE FROM orders WHERE id = ?", orderId)
 
// A second implementation for tests. Same interface, a map underneath.
class InMemoryOrderRepository implements OrderRepository is
field orders: map of id to Order
 
method findById(orderId) is
return orders.get(orderId)
 
method findByCustomer(customerId) is
return orders.values().filter(o => o.customerId == customerId)
 
method save(order) is
orders.put(order.id, order)
 
method delete(orderId) is
orders.remove(orderId)
 
// Business logic depends only on the interface.
class OrderService is
field orders: OrderRepository
 
constructor OrderService(orders) is
this.orders = orders
 
method cancelOrder(orderId) is
order = orders.findById(orderId)
if order == null then
throw new NotFoundError()
order.cancel()
orders.save(order)

When to use it

  • Business logic currently mixes domain rules with query strings, ORM sessions, or storage-specific error types, and you want the two concerns physically separated.
  • Tests need to run fast and offline against real logic, not against mocks that fake what a database would have done.
  • The storage technology is genuinely likely to change, or already varies across environments (a real database in production, an in-memory store in tests).

Pitfalls

  • The leaky repository. Returning ORM entities, accepting raw SQL fragments as parameters, or exposing pagination cursors tied to one database engine all defeat the point - the domain layer is still coupled to the storage technology, just through an extra hop.
  • One repository per query. A repository is a collection abstraction, not a home for every ad-hoc report. A dashboard query that joins six tables for a chart usually belongs in its own read-side query object, not bolted onto OrderRepository.
  • Fighting the ORM. If the team already uses an ORM with its own unit-of-work and querying story, wrapping it a second time in a hand-rolled repository can add a layer that answers a question nobody asked.

Don't confuse it with

  • Facade. A Facade picks one simple door into a larger, messier subsystem; it is not collection-shaped and does not promise find/save semantics. A Repository specifically models "a collection of domain objects", nothing broader.
  • Adapter. Adapter exists because two interfaces already exist and need to cooperate. Repository does not adapt an existing storage interface - it invents a domain-shaped one that the storage layer never had.
  • Data Access Object (DAO). In practice the two overlap heavily; the usual distinction is that a DAO tends to mirror the storage schema (table-shaped methods), while a Repository is defined in the domain's vocabulary and may aggregate several tables behind one method.

Check yourself

Question 1 of 5

What is the domain layer allowed to import in a properly applied Repository pattern?