Design Patterns, Explained Simply · Modern Patterns and Choosing Well

Repository and Unit of Work

Hide data access behind a collection-like interface, and group changes so they are saved together in one transaction.

The problem

Business logic in an orders service is full of SQL: SELECT * FROM orders WHERE customer_id = ... sits next to the discount rules. Unit tests need a real database, swapping MySQL for an API call is a rewrite, and the same query is copy-pasted in five places with small differences.

Worse, place_order() saves the order, then the order lines, then updates stock, each in its own call. When the stock update fails, the order is already saved without its stock reserved.

Real-world analogy

  • Repository: a library's front desk. You ask for "books by this author" and get them; you don't walk into the stacks or learn the shelving system. The library could move to a new building and your request wouldn't change.
  • Unit of Work: a shopping basket at the till. You collect items, and everything is paid for in one transaction at the end. If the card is declined, you don't leave with half the basket.

Without the pattern

def place_order(customer_id, items):
    db.execute("INSERT INTO orders ...")            # SQL mixed with business rules
    for item in items:
        db.execute("INSERT INTO order_lines ...")
    db.execute("UPDATE stock SET ...")              # if this fails, the order is half-saved

The pattern

Repository (from Martin Fowler's Patterns of Enterprise Application Architecture):

  • An interface that looks like an in-memory collection of domain objects: add(order), get(id), for_customer(id).
  • Implementations hide storage: SQL, an ORM, an HTTP API, or an in-memory list for tests.
  • Business code depends on the interface (dependency inversion).

Unit of Work:

  • Tracks objects that were added or changed during one business operation.
  • commit() writes all of them in one transaction; any failure rolls back everything.
  • Repositories register changes with the unit of work instead of writing immediately.

The code

A repository plus a unit of work that commits or rolls back togetherTry it yourself
class OrderRepository:  # collection-like interface; in-memory for this demo
    def __init__(self, uow):
        self.uow, self.rows = uow, {}

    def add(self, order):
        self.uow.register(("order", order))

    def for_customer(self, customer):
        return [o for o in self.rows.values() if o["customer"] == customer]


class StockRepository:
    def __init__(self, uow, stock):
        self.uow, self.stock = uow, stock

    def reserve(self, sku, qty):
        self.uow.register(("stock", (sku, qty)))


class UnitOfWork:
    def __init__(self):
        self.pending = []

    def register(self, change):
        self.pending.append(change)

    def commit(self, orders, stock):
        print("  BEGIN")
        try:
            for kind, data in self.pending:
                if kind == "order":
                    orders.rows[data["id"]] = data
                    print(f"  INSERT order {data['id']}")
                else:
                    sku, qty = data
                    if stock.stock[sku] < qty:
                        raise ValueError(f"not enough {sku}")
                    stock.stock[sku] -= qty
                    print(f"  UPDATE stock {sku} -{qty}")
            print("  COMMIT")
        except ValueError as e:
            print(f"  ROLLBACK ({e})")
            for kind, data in self.pending:  # undo the in-memory changes
                if kind == "order":
                    orders.rows.pop(data["id"], None)
        self.pending.clear()


uow = UnitOfWork()
orders, stock = OrderRepository(uow), StockRepository(uow, {"mug": 5})

for order_id, qty in [(1, 2), (2, 9)]:
    print(f"place order {order_id} ({qty} mugs)")
    orders.add({"id": order_id, "customer": "ada", "sku": "mug", "qty": qty})
    stock.reserve("mug", qty)
    uow.commit(orders, stock)

print("ada's orders:", [o["id"] for o in orders.for_customer("ada")], "| mugs left:", stock.stock["mug"])

Output

place order 1 (2 mugs)
  BEGIN
  INSERT order 1
  UPDATE stock mug -2
  COMMIT
place order 2 (9 mugs)
  BEGIN
  INSERT order 2
  ROLLBACK (not enough mug)
ada's orders: [1] | mugs left: 3

Order 2 needed 9 mugs but only 3 were left, so its order insert was rolled back too. In a real app, commit() maps to a database transaction, and the business code never sees SQL.

When to use it

  • Business logic is mixed with queries, and you want it testable with an in-memory repository.
  • One business operation changes several objects that must be saved together or not at all.
  • You want a clear boundary between the domain and the storage technology (Domain-Driven Design, hexagonal/clean architecture).

When not to use it

  • Simple CRUD apps. An ORM like Eloquent, Django ORM or Active Record already gives you query methods, and wrapping it in repositories adds a layer that mostly forwards calls.
  • Generic repositories (Repository<T> with get_all(), find_by(any_field)) that just mirror the ORM give little value. Make repository methods domain-specific (overdue_invoices()).
  • Hiding the database completely can hide performance problems (N+1 queries, missing indexes).

You've already used it

  • ORMs as Unit of Work: SQLAlchemy's Session, Hibernate/JPA's EntityManager and Doctrine's EntityManager track changes and flush them in one transaction.
  • Spring Data repositories (interface OrderRepository extends JpaRepository<Order, Long>).
  • Entity Framework: DbContext is a unit of work; each DbSet is repository-like.

Key points

  • Repository: a collection-like interface that hides storage; business code depends on it, tests use in-memory versions.
  • Unit of Work: tracks changes in one operation and commits them in one transaction.
  • Most ORMs already implement Unit of Work; add repositories when they buy testability or a clear domain boundary.
  • Further reading: Martin Fowler's Repository and Unit of Work.

Practice questions

What is the Repository pattern and what problem does it solve?

Answer

A collection-like interface (add, get, for_customer) that hides how domain objects are stored. Business logic no longer contains queries, can be tested with an in-memory implementation, and doesn't change if storage changes.

What does a Unit of Work do?

Answer

It tracks the objects added, changed or removed during one business operation and writes them all in one transaction on commit(), rolling everything back if any step fails.

Name an ORM feature that is a Unit of Work.

Answer

SQLAlchemy's Session, JPA/Hibernate's EntityManager, Doctrine's EntityManager and Entity Framework's DbContext all track changes and flush them together in a transaction.

When is adding repositories on top of an ORM not worth it?

Answer

In simple CRUD apps, or when the repository is a generic wrapper that just forwards every ORM call. Repositories pay off when they expose domain-specific methods and give business logic a testable boundary.

Open in the app to mark lessons complete, take notes and track your progress.