Command Palette

Search for a command to run...

PHASE 2Beginner ~17 min· topic 25 of 28Enterprise

Enterprise 2 — Repository and Unit of Work

In one line

A Repository gives the domain a collection-like interface for loading and saving aggregates (findById, save) and hides SQL or other storage details. A Unit of Work tracks changes made during one business operation and commits them together in one transaction.

0/28 · 0%

Think of it like this

A library's front desk. You ask for a book by title and hand books back; you never walk into the stacks or know the shelving system. At closing time the desk records all the day's loans and returns in one ledger update.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Repository
An interface that loads and stores domain objects as if they were in a collection.
Aggregate
A cluster of objects treated as one unit for changes, like an order and its order lines, with one root (the order).
Unit of Work
An object that tracks changes during a business operation and commits them in one transaction.
Persistence context
JPA's set of managed entities for a transaction: it tracks changes and flushes them on commit.

Step by step

01The problem: SQL scattered through business logic

Tiffin's PlaceOrder use case built SQL strings inline, opened its own connections, and committed after each statement. When stock decrement succeeded but the order insert failed, the database was left inconsistent, and unit tests needed a running database.

02Repository for the aggregate, Unit of Work for the transaction

The use case now talks to OrderRepository and StockRepository interfaces, and runs inside one unit of work that commits both changes together. In Spring Data, the interfaces are generated from method names and @Transactional provides the unit of work.

PlaceOrder.javawhole filejava
import java.util.*;

record Order(String id, String dishId, int qty, String status) {}

interface OrderRepository { void save(Order o); Optional<Order> findById(String id); }
interface StockRepository { int available(String dishId); void reserve(String dishId, int qty); }

/** Unit of Work: buffers changes and applies them together (or not at all). */
final class UnitOfWork {
  private final List<Runnable> pending = new ArrayList<>();
  void register(Runnable change) { pending.add(change); }
  void commit() { pending.forEach(Runnable::run); pending.clear(); }   // real code: one DB transaction
  void rollback() { pending.clear(); }
}

final class PlaceOrder {
  private final OrderRepository orders;
  private final StockRepository stock;
  PlaceOrder(OrderRepository orders, StockRepository stock) { this.orders = orders; this.stock = stock; }

  String handle(String orderId, String dishId, int qty) {
    UnitOfWork uow = new UnitOfWork();
    try {
      if (stock.available(dishId) < qty) throw new IllegalStateException("out of stock");
      uow.register(() -> stock.reserve(dishId, qty));
      uow.register(() -> orders.save(new Order(orderId, dishId, qty, "PLACED")));
      uow.commit();
      return "PLACED";
    } catch (IllegalStateException e) {
      uow.rollback();
      return "REJECTED: " + e.getMessage();
    }
  }
}

// in-memory implementations for tests
final class MemOrders implements OrderRepository {
  final Map<String, Order> rows = new HashMap<>();
  public void save(Order o) { rows.put(o.id(), o); }
  public Optional<Order> findById(String id) { return Optional.ofNullable(rows.get(id)); }
}
final class MemStock implements StockRepository {
  final Map<String, Integer> left = new HashMap<>(Map.of("biryani", 3));
  public int available(String d) { return left.getOrDefault(d, 0); }
  public void reserve(String d, int q) { left.merge(d, -q, Integer::sum); }
}

03What it looks like with Spring Data JPA

interface OrderRepository extends JpaRepository<OrderEntity, String> { List<OrderEntity> findByCustomerIdAndStatus(String customerId, Status status); } generates the implementation from the method name. Marking the use case @Transactional makes Spring open one transaction for the method: every managed entity change is flushed at commit, and any runtime exception rolls everything back.

Keep JPA entities out of the domain if the model is rich (map them in the repository implementation), or use them directly in simpler CRUD services: both are valid; be consistent.

What it looks like with Spring Data JPAdiagram
Rendering diagram…

Try it yourself

  1. 1

    Place orders against in-memory repositories

    The second order asks for more than the remaining stock, so nothing from it is saved.

    terminal
    $ jshell> /open PlaceOrder.java
    jshell> var orders = new MemOrders(); var stock = new MemStock()
    jshell> var place = new PlaceOrder(orders, stock)
    jshell> place.handle("o1", "biryani", 2)
    jshell> place.handle("o2", "biryani", 5)
    jshell> stock.left.get("biryani") + " left, orders: " + orders.rows.keySet()
    ── expected output ──
    $5 ==> "PLACED"
    $6 ==> "REJECTED: out of stock"
    $7 ==> "1 left, orders: [o1]"

In your stack

  • →

    Spring Data (JpaRepository, CrudRepository, derived query methods, @Query) generates repositories; @Transactional and JPA's EntityManager persistence context provide the Unit of Work. In plain JDBC, the unit of work is connection.setAutoCommit(false) … commit() / rollback().

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Self-invocation skips the transaction

OrderService.placeAll() loops and calls this.placeOne(), which is the method annotated @Transactional. The second order's stock update fails halfway.

terminal
$ psql -c "select id, status from orders where id in ('o7','o8'); select qty from stock where dish='paneer';"
── what you'll see ──
id | status
----+--------
o7 | PLACED
o8 | PLACED
 
qty
-----
-1

Myth vs fact

Myth

Every table needs its own repository.

Fact

Repositories belong to aggregates. Order lines are saved through the order's repository, not their own.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Keep transactions short and free of remote calls: holding a database transaction open while calling a payment API holds locks for seconds. Use the outbox pattern (Phase 7) when a commit must also publish an event reliably.

Remember this

  1. 1

    A Repository looks like an in-memory collection of domain objects: orders.findById(id), orders.save(order), orders.findOpenByCustomer(c). The domain code doesn't know whether it's backed by PostgreSQL, DynamoDB or a map in a test.

  2. 2

    Design repositories per aggregate (an order with its lines), not per table, and give them domain-named query methods instead of a generic query(String sql).

  3. 3

    A Unit of Work collects everything changed in one business operation (new order, stock decrement, payment record) and writes it all in one transaction: all or nothing. JPA's persistence context plus @Transactional is a Unit of Work.

  4. 4

    Together they keep business logic free of persistence code and make it easy to test with in-memory repositories.

  5. 5

    Beware the generic "repository over everything" layer that simply re-exposes every query as a method: it adds indirection without hiding anything. Keep repositories small and meaningful.

Explain it without notes

01

What does a Repository hide, and what should its methods look like?

02

What problem does a Unit of Work solve?

Practice

01

Add findByStatus(String status) to OrderRepository and implement it in MemOrders.

02

The use case must also write a payment record. How do you keep all three writes consistent?

Trade-offs

  • ↔

    Business code free of persistence details and easy to test, versus an extra layer that can become a pointless pass-through if repositories just mirror tables.

Done when you can

  • I can design an aggregate repository with domain-named methods.

  • I can explain how @Transactional implements a Unit of Work and when it silently doesn't.

Back to phase