Here is a very familiar snippet in Spring projects: a service class calls entityManager.createQuery("select o from Order o where ...") right in the middle of payment logic, elsewhere the same query is rewritten with a slightly different condition, and every unit test has to spin up a whole database. The Repository pattern exists to clean this up: it puts data access behind an interface that speaks the language of the business, so the rest of the application sees objects as an in-memory collection. It comes with the Unit of Work, a pattern that tracks what changed during a business transaction and writes it to the database once.
The interesting part is that JPA already ships a Unit of Work inside EntityManager, so understanding it helps you drop redundant save() calls and avoid N+1 problems.
This article builds a Repository pattern from an interface, implements it with plain JPA, places the transaction boundary in the service layer, and then watches the Unit of Work run for real through the SQL log. After that we compare it with Spring Data JPA and discuss when you should (or should not) wrap an extra Repository pattern layer. The code uses Java 21, Spring Boot 3.3.x, and Hibernate 6.5.x with in-memory H2, running on an Apple M4 with 16 GB RAM. The previous article, Dependency Injection: building an IoC container to understand Spring, explained why OrderService receives its repository through the constructor; this one goes down one level to the data layer.
What the Repository pattern is and which problem it solves
Martin Fowler describes the Repository pattern in Patterns of Enterprise Application Architecture as a layer between the domain and the data mapping layer that acts "like an in-memory domain object collection". Clients construct queries declaratively and submit them to the Repository pattern; objects can be added to and removed from it as with a simple collection; the mapping code sits behind it. The pattern is useful when the domain is complex, with many classes and many queries, because it removes duplicated query logic and keeps dependencies pointing one way, from the domain to the data layer (Repository pattern description in the PoEAA catalog).
In code, this means you have two separate things:
- The Repository interface of the Repository pattern belongs to the domain:
add,findById,findByStatus... named after business needs, never exposingEntityManager,Session, or SQL. - The implementation belongs to infrastructure: JPA, JDBC, or in-memory storage all work, and it can be swapped out in tests.
This is also a direct application of the dependency inversion principle from SOLID: the service class depends on an abstraction, and the storage details depend back on that abstraction.
Unit of Work: track changes, write once
Fowler defines the Unit of Work as an object that "maintains a list of objects affected by a business transaction" and coordinates writing out the changes. If we wrote to the database every time a property changed, there would be many small, slow calls, and the database transaction would have to stay open for the whole interaction. A Unit of Work remembers new, modified, and deleted objects, and only at the end works out the set of statements to run.
JPA already does this for you. The Hibernate documentation describes Session as a "single-threaded, short-lived object conceptually modeling a Unit of Work", which corresponds to the Jakarta Persistence EntityManager, and it holds a persistence context that acts as a first-level cache (Hibernate ORM User Guide, Architecture section). Three practical consequences:
- Objects loaded through
findor a query are managed: modifying them is enough. At flush time Hibernate compares them with the original snapshot (dirty checking) and generates theUPDATE. - Calling
findwith the same id several times in one persistence context runs only oneSELECT. - When the transaction rolls back, the in-memory changes are discarded together with the persistence context.
So EntityManager already plays the Unit of Work, and the Repository pattern is a business-language wrapper on top of it. The two patterns cooperate: the Repository pattern finds and adds objects, and the Unit of Work takes care of writing.
Example model for the Repository pattern: orders and order lines
A business example close to real enterprise code: an Order has a customer, a status, and several OrderLine entries. Order has its own behavior (pay() checks the status, total() computes the amount) instead of being just a data bag:
@Entity @Table(name = "orders")
public class Order {
public enum Status { NEW, PAID, SHIPPED }
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customer;
@Enumerated(EnumType.STRING)
private Status status = Status.NEW;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
protected Order() {}
public Order(String customer) { this.customer = customer; }
public Order addLine(String sku, int qty, long price) {
lines.add(new OrderLine(this, sku, qty, price));
return this;
}
public void pay() {
if (status != Status.NEW) throw new IllegalStateException("Only NEW orders can be paid");
status = Status.PAID;
}
public long total() { return lines.stream().mapToLong(l -> l.getQty() * l.getPrice()).sum(); }
}Order is the aggregate root: from the outside you only add, find, and remove Order; OrderLine follows thanks to cascade = ALL. This is an important Domain-Driven Design rule: one Repository pattern per aggregate root, not per table. There is no OrderLineRepository.
Implementing the Repository pattern with plain JPA
The interface lives in the domain layer and is named after business operations:
public interface OrderRepository {
Order add(Order order);
Optional<Order> findById(Long id);
List<Order> findByStatus(Order.Status status);
List<Order> findByStatusWithLines(Order.Status status);
void remove(Order order);
}The implementation uses an EntityManager injected by the container. Note two points: there is no commit in the Repository, and @Repository enables Spring's exception translation (PersistenceExceptionTranslationPostProcessor) so the upper layers receive DataAccessException instead of JPA-specific exceptions.
@Repository
public class JpaOrderRepository implements OrderRepository {
@PersistenceContext
private EntityManager em;
public Order add(Order order) { em.persist(order); return order; }
public Optional<Order> findById(Long id) { return Optional.ofNullable(em.find(Order.class, id)); }
public List<Order> findByStatus(Order.Status status) {
return em.createQuery("select o from Order o where o.status = :s order by o.id", Order.class)
.setParameter("s", status).getResultList();
}
public List<Order> findByStatusWithLines(Order.Status status) {
return em.createQuery("select distinct o from Order o join fetch o.lines where o.status = :s order by o.id", Order.class)
.setParameter("s", status).getResultList();
}
public void remove(Order order) { em.remove(order); }
}The service layer holds the transaction boundary. Each @Transactional method is one Unit of Work: it opens a persistence context, runs the business logic, then flushes and commits on exit. This is an important architectural decision: the Repository does not commit, because one business operation usually touches several Repository pattern instances and they must commit or roll back together.
@Service
public class OrderService {
private final OrderRepository orders;
OrderService(OrderRepository orders) { this.orders = orders; }
@Transactional
public void payAndAddGift(Long id) {
Order o = orders.findById(id).orElseThrow();
o.pay(); // only changes the object's state
o.addLine("GIFT-01", 1, 0); // add a line: cascade handles the INSERT
System.out.println(">> business logic done, not committed yet. in-memory status = " + o.getStatus());
}
@Transactional
public void payThenFail(Long id) {
orders.findById(id).orElseThrow().pay();
throw new IllegalStateException("Payment gateway timeout");
}
}Watching the Repository pattern and Unit of Work through the SQL log
Theory is easy to nod along to, so we turn on logging.level.org.hibernate.SQL=DEBUG and run a scenario. The command: cd code/s6-21 && mvn spring-boot:run -Dspring-boot.run.mainClass=shop.Demo. The first part adds three orders in one transaction:
=== 1. Add orders through the Repository (one transaction) ===
insert into orders (customer,status,id) values (?,?,default)
insert into order_lines (order_id,price,qty,sku,id) values (?,?,?,?,default)
insert into order_lines (order_id,price,qty,sku,id) values (?,?,?,?,default)
insert into orders (customer,status,id) values (?,?,default)
insert into order_lines (order_id,price,qty,sku,id) values (?,?,?,?,default)
insert into orders (customer,status,id) values (?,?,default)
insert into order_lines (order_id,price,qty,sku,id) values (?,?,?,?,default)
>> persisted 3 orders, about to commitThe first order has two lines, so there are two consecutive INSERT order_lines statements. One detail worth noting: an IDENTITY primary key forces Hibernate to run the INSERT right at persist to obtain the id, so the statements appear before the "about to commit" line. With SEQUENCE, Hibernate can hold statements until flush and use JDBC batching; that is why many projects avoid IDENTITY when they need bulk writes.
Now to the question "why is save() not needed":
=== 2. Unit of Work: modify the object, no save() call ===
select o1_0.id,o1_0.customer,o1_0.status from orders o1_0 where o1_0.id=?
>> business logic done, not committed yet. in-memory status = PAID
insert into order_lines (order_id,price,qty,sku,id) values (?,?,?,?,default)
update orders set customer=?,status=? where id=?
>> committedThe order of the lines proves the mechanism. During the business logic there is exactly one SELECT, then the "not committed yet" line prints; only after that do the INSERT for the gift and the status UPDATE appear, right before the "committed" line. We never call save() or update(): the Unit of Work detects that status changed and that a new OrderLine is in the cascading collection, then flushes both when the transaction ends. With a hand-written Repository pattern like this one, add is only needed for new objects; existing objects just need to be modified.
=== 3. First-level cache: find the same id twice in one transaction ===
select o1_0.id,o1_0.customer,o1_0.status from orders o1_0 where o1_0.id=?
>> called findById twice above, only the SELECTs above appearTwo findById calls, one SELECT: the second comes from the persistence context. This is also something to watch out for: within one transaction you may read stale data if another process has just changed the database, because Hibernate does not ask again.
=== 4. Rollback: an exception discards all changes ===
select o1_0.id,o1_0.customer,o1_0.status from orders o1_0 where o1_0.id=?
>> caught: Payment gateway timeout
select o1_0.id,o1_0.customer,o1_0.status from orders o1_0 where o1_0.id=?
>> status of order 2 after rollback = NEWpayThenFail called pay() (changing the status to PAID in memory) and then threw an exception. Spring rolls back the transaction, no UPDATE runs, and when we read it again in a new transaction the status is still NEW. That is exactly the value of placing the transaction boundary in the service layer instead of inside the Repository pattern.
N+1 and join fetch: the Repository pattern does not hide query cost
The most common performance bug when using the Repository pattern with JPA is N+1. findByStatus returns a list of Order, while lines is loaded lazily; every call to o.total() that touches lines runs one more SELECT per order. We compare the two methods, counting statements with Hibernate's Statistics:
=== 5. N+1 vs join fetch (NEW orders) ===
select o1_0.id,o1_0.customer,o1_0.status from orders o1_0 where o1_0.status=? order by o1_0.id
select l1_0.order_id,l1_0.id,l1_0.price,l1_0.qty,l1_0.sku from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.price,l1_0.qty,l1_0.sku from order_lines l1_0 where l1_0.order_id=?
>> NEW: 2 orders, total 4200000
>> prepared SQL statements (N+1): 3
select distinct o1_0.id,o1_0.customer,l1_0.order_id,l1_0.id,l1_0.price,l1_0.qty,l1_0.sku,o1_0.status from orders o1_0 join order_lines l1_0 on o1_0.id=l1_0.order_id where o1_0.status=? order by o1_0.id
>> NEW: 2 orders, total 4200000
>> prepared SQL statements (join fetch): 1With 2 orders, N+1 runs 3 statements (1 order query plus 2 line queries), while join fetch runs just 1. As the number of orders grows to N, the first version becomes N+1 statements. The design lesson: because each use case needs different data, a Repository pattern should have intent-named methods (findByStatusWithLines) rather than a single findAll() used everywhere. Other remedies include @EntityGraph, @BatchSize, or projection queries (DTOs) when you only need to read. The numbers 3 and 1 above are real measurements on a 2-order dataset, not a timing benchmark.
Spring Data JPA: the Repository pattern generated by the framework
For most simple queries, hand-writing every em.createQuery method is repetitive. Spring Data lets you declare an interface and generates the implementation. According to the Spring Data Repositories documentation, the central interface is Repository<T, ID> (mostly a marker), CrudRepository adds save, findById, findAll, delete..., and technology-specific interfaces such as JpaRepository extend them. Queries can be derived from method names:
public interface SpringDataOrders extends JpaRepository<Order, Long> {
List<Order> findByCustomerOrderById(String customer);
long countByStatus(Order.Status status);
}=== 6. Spring Data JPA: same idea, less code ===
select o1_0.id,o1_0.customer,o1_0.status from orders o1_0 where o1_0.customer=? order by o1_0.id
>> findByCustomerOrderById: 1 order
select count(o1_0.id) from orders o1_0 where o1_0.status=?
>> countByStatus(PAID) = 1The two methods have no implementation lines yet run exactly the SQL we expect. Understand that Spring Data is not an alternative to the Unit of Work: underneath it is still EntityManager and the persistence context shown above, and save() on an already managed entity does nothing extra (it is only needed for new or detached entities). This is the origin of the redundant habit of "calling save() after every change" that many teams carry around.
Should you wrap Spring Data in your own Repository pattern interface
This question is debated. There are two schools of thought, and both have grounds:
| Approach | When it makes sense | Cost |
|---|---|---|
Use JpaRepository directly in the service | CRUD applications, a simple domain, a team that accepts coupling to Spring Data | The service class sees save, Pageable, and Spring return types; tests need a context or a framework mock |
| A domain Repository interface, implemented with Spring Data or JPA | A complex domain, fast tests with an in-memory fake, a need to hide the storage technology | An extra mapping layer between the domain interface and JpaRepository; easily becomes a pointless pass-through |
A rule of thumb: if your Repository pattern interface just repeats every method of JpaRepository, it adds no value; an interface earns its place only when its methods reflect business needs (findOverduePayments, findByStatusWithLines) and hide query details.
When you choose the second approach, the implementation can delegate to SpringDataOrders internally, and the fake for tests is just a HashMap (the snippet below is a sketch and was not part of the article's run):
class InMemoryOrderRepository implements OrderRepository {
private final Map<Long, Order> store = new HashMap<>();
public Optional<Order> findById(Long id) { return Optional.ofNullable(store.get(id)); }
public List<Order> findByStatus(Order.Status s) {
return store.values().stream().filter(o -> o.getStatus() == s).toList();
}
// add, remove, findByStatusWithLines are similar
}Be careful with the fake: it has no dirty checking, first-level cache, or lazy loading, so a test that passes with the fake can still fail with real JPA. Keep a set of integration tests running against H2 or Testcontainers for the critical paths.
When not to use the Repository pattern
- Reports and complex read queries: many tables, grouping, window functions. The Repository pattern returns aggregates, which does not fit; use a separate query layer (lightweight CQRS) that returns DTOs, or use JdbcTemplate or jOOQ.
- Pure CRUD applications: an admin screen that only stores and displays data gains nothing from another abstraction layer except more files.
- A generic
GenericRepository<T>Repository pattern withfindAll(Specification)for everything: it turns the Repository pattern into a thin shell around the ORM and leaks query details everywhere. See a controlled approach in the next article on Specifications. - Non-relational storage: for caches or queues, the idea of a "collection of objects" may not fit.
Common mistakes
1. Calling save() and flush() everywhere. The result is losing the benefit of the Unit of Work: many small statements and unpredictable write timing. Trust dirty checking and flush manually only for a reason (for example, when you need an id or want to catch a constraint early).
2. A Repository pattern that opens a transaction or commits by itself. When two Repositories each commit separately, a mid-way failure leaves half-written data. Transactions belong to the service layer.
3. Returning entities to the web layer and hitting LazyInitializationException. The persistence context closes when the transaction ends, so accessing lines in a controller throws. In the example we turned off open-in-view so the error shows up early. The right fix is to map to a DTO in the service, or to preload with join fetch.
4. One Repository pattern per table. This scatters business logic and breaks aggregates. Keep one Repository pattern per aggregate root.
5. Forgetting that the persistence context is a cache. A JPQL query after a bulk UPDATE statement will return the stale entity held in the context. After a bulk update you need clear() or @Modifying(clearAutomatically = true).
6. Letting the Repository pattern return null. Use Optional (like findById above) to force the caller to handle the not-found case.
Repository pattern and Unit of Work checklist
- One Repository pattern per aggregate root; name the interface after business operations.
- The interface belongs to the domain, the implementation to infrastructure; the service class only knows the interface.
- The transaction boundary sits in the service layer (
@Transactional); the Repository pattern never commits. - Modifying a managed object is enough: no
save()after every change. - Give each use case a suitable read method to avoid N+1 (
join fetch,@EntityGraph, DTOs). - Complex reports use a separate query path and are not forced through the Repository pattern.
- Keep integration tests against a real database or H2 alongside the in-memory fake.
The full source code and run_output.txt are in code/s6-21/. To read the origin of the concepts, see the Repository pattern and Unit of Work entries in Fowler's catalog cited above, plus the Architecture section of the Hibernate User Guide on Session as a Unit of Work.
Next article
A good Repository pattern must answer many kinds of queries without ballooning into dozens of findByA...AndB... methods. The next article is Specification pattern: dynamic queries with Spring Data JPA, where business conditions are packaged into objects that can be combined.



