Spring Data JPA and Repository Design
Spring Data JPA removes the repetitive DAO code that normally sits between a Spring Boot service and a relational database. The outcome is not magic persistence; it is a generated repository proxy that translates a small Java interface into calls against JPA’s EntityManager. In this lesson you will design repositories that match use cases, choose derived queries or explicit queries deliberately, keep transactions in the service layer, and recognize when a repository method is hiding an expensive or ambiguous database operation.
This chapter fits the persistence section of the Spring Boot course because repository design is where domain code first meets tables, transactions, indexes, and lazy object graphs. A clean controller can still create a fragile application if repository methods expose every table shape.
What Spring Data JPA Builds
At startup, Spring Boot detects repository interfaces through auto-configuration and Spring Data scans. An interface such as CustomerRepository does not get implemented by you. Spring Data creates a proxy object and registers it as a bean. When your service calls findById, save, or a custom query method, the proxy delegates to a repository implementation backed by the JPA EntityManager. The EntityManager tracks managed entities in a persistence context, converts state changes into SQL during flush, and coordinates with the active transaction.
The main repository base types define different contracts. Repository is a marker for selective method exposure. CrudRepository adds simple create, read, update, and delete operations. PagingAndSortingRepository adds paging and sorting. JpaRepository adds JPA-specific conveniences such as flushing, batch deletes, and list-returning variants. Choosing JpaRepository everywhere is common, but it exposes more operations than every aggregate needs.
Repository methods are not isolated from transaction rules. Read methods can run without an explicit service transaction, but lazy relationships and repeatable reads become harder to reason about. Write methods should normally be called inside a service method annotated with @Transactional, so the use case has one atomic boundary. The repository should express persistence operations; the service should decide when multiple operations succeed or fail as one unit.
API Anatomy
A useful repository design starts with entity identity. JPA identity is represented by @Id, and Spring Data’s generic type uses the same identifier type: JpaRepository<Customer, Long>. Entity fields describe column constraints and relationships. Repository method names then describe query intent. In a derived query, Spring Data parses the method name into a property path and predicate: findByEmailAndStatus means a select from Customer where email and status match the parameters. Keywords such as Containing, IgnoreCase, Between, OrderBy, Top, and Distinct change the generated query.
Use @Query when the name becomes hard to read, when the query needs joins, aggregation, vendor functions, or a projection. JPQL queries name entities and fields, not tables and columns. Native SQL queries name tables and columns directly and tie the repository more tightly to the database. For read models, interface projections and DTO projections can return only the selected columns instead of materializing an entire entity graph.
Paging is part of the method signature. A method that returns Page<Customer> receives a Pageable and usually executes a content query plus a count query. Slice<Customer> avoids the full count and only reports whether a next slice exists. A plain List<Customer> is acceptable for naturally small result sets, but not for open-ended screens or exports without an explicit limit.
Example 1: A Focused Aggregate Repository
The first example models a customer aggregate with a unique email. The repository exposes only operations the application actually uses: find a customer by business key and status, test whether an email exists, and page through customers by status. The expected behavior is deterministic: after saving ada@example.com as ACTIVE, findByEmailAndStatus("ada@example.com", "ACTIVE") returns an Optional containing that customer, while the same email with DISABLED returns Optional.empty().
@Entity
@Table(name = "customers", uniqueConstraints = @UniqueConstraint(name = "uk_customer_email", columnNames = "email"))
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 160)
private String email;
@Column(nullable = false, length = 80)
private String status;
protected Customer() {
}
public Customer(String email, String status) {
this.email = email;
this.status = status;
}
}
public interface CustomerRepository extends JpaRepository<Customer, Long> {
Optional<Customer> findByEmailAndStatus(String email, String status);
boolean existsByEmail(String email);
Page<Customer> findByStatusOrderByEmailAsc(String status, Pageable pageable);
}
This design keeps table access behind domain language. Callers do not ask for arbitrary customers and then filter in memory. They ask the database for the exact predicate, which lets an index on email or status do useful work. The unique constraint belongs in the database as well as in application checks because two concurrent requests can both observe that an email does not exist before either insert commits.
Example 2: Query Shape and Fetch Plans
The second example introduces an order that has a many-to-one relationship to a customer. The relationship is marked LAZY, which means loading an order does not automatically load the customer. That default is often correct, but it creates a common trap: iterating orders in a view and reading order.getCustomer().getEmail() can produce one extra select per row. @EntityGraph tells JPA to fetch the named association for this query, keeping the repository method honest about the object graph it returns.
@Entity
@Table(name = "orders")
public class CustomerOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Customer customer;
@Column(nullable = false)
private BigDecimal total;
}
public interface OrderRepository extends JpaRepository<CustomerOrder, Long> {
@EntityGraph(attributePaths = "customer")
@Query("select o from CustomerOrder o where o.total >= :minimum order by o.total desc")
List<CustomerOrder> findLargeOrdersWithCustomer(@Param("minimum") BigDecimal minimum);
}
With three matching orders, the intended behavior is one query that returns orders with their customer data available, not one query for orders plus three more queries for customers. The exact SQL varies by provider and database, but the observable result is stable: accessing each returned order’s customer inside the transaction should not trigger additional lazy-loading selects.
Example 3: Transactional Use-Case Method
The third example places a repository behind a service. The service normalizes the email, checks for duplicates, saves the entity, and returns the database identity. The important detail is the transaction boundary: the use case owns the unit of work, not the controller and not a chain of repository calls scattered across the codebase.
@Service
public class CustomerRegistrationService {
private final CustomerRepository customers;
public CustomerRegistrationService(CustomerRepository customers) {
this.customers = customers;
}
@Transactional
public Long register(String rawEmail) {
String email = rawEmail.trim().toLowerCase(Locale.ROOT);
if (customers.existsByEmail(email)) {
throw new DuplicateCustomerException(email);
}
Customer saved = customers.save(new Customer(email, "ACTIVE"));
return saved.getId();
}
}
If register(" ADA@example.com ") runs against an empty customer table, the service stores ada@example.com with status ACTIVE and returns the generated id. If the unique database constraint is violated because another request inserted the same email concurrently, the application should translate the persistence exception into the same user-level duplicate response. The pre-check improves the normal error path; the database constraint provides the final correctness guarantee.
Example 4: Testing the Repository Boundary
A repository test should verify mapping, query parsing, and database behavior without starting the whole web application. @DataJpaTest starts a focused slice with JPA components and rolls back each test by default. Flushing and clearing are useful because they force SQL execution and prevent a test from passing only because the entity is still present in the first-level cache.
@DataJpaTest
class CustomerRepositoryTest {
@Autowired CustomerRepository customers;
@Autowired TestEntityManager entityManager;
@Test
void findsActiveCustomerByEmail() {
customers.save(new Customer("ada@example.com", "ACTIVE"));
customers.save(new Customer("ada@example.net", "DISABLED"));
entityManager.flush();
entityManager.clear();
Optional<Customer> found = customers.findByEmailAndStatus("ada@example.com", "ACTIVE");
assertThat(found).isPresent();
assertThat(found.get().getStatus()).isEqualTo("ACTIVE");
}
}
The expected test result is a pass with exactly one active customer found. If the derived method references a misspelled property, the application context fails to start. If the column mapping or predicate is wrong, the assertion fails after the database is queried rather than after an in-memory object is reused.
Design Choices and Trade-Offs
Derived queries are concise and refactor-friendly when they describe a simple predicate. They become a liability when the method name reads like a sentence fragment with five conditions and two sorts. At that point, @Query, a specification, Query by Example, or a dedicated query component is easier to review. Specifications help with composable search filters, but they can spread query logic across many small predicates. Query by Example is convenient for simple equality matching and less expressive for joins, ranges, and custom ordering.
Returning entities gives callers managed objects that can be changed and flushed. That is useful inside a command transaction and risky for read-only views. Projections reduce memory, avoid accidental lazy traversal, and make API responses more stable, but they are less suitable when the caller must change the aggregate. Bulk update and delete queries can be fast, yet they bypass normal entity lifecycle handling and can leave the persistence context stale.
Repository boundaries should follow aggregate boundaries rather than mirroring every table. A customer repository should not become a place where unrelated billing, shipment, and audit queries accumulate just because those queries mention customer ids. When a screen needs a cross-aggregate report, a read-only query repository or projection is clearer than forcing the operation into a command aggregate repository.
Failure Modes and Troubleshooting
Startup fails with a property reference error. Symptom: the application context fails before serving requests, often naming a repository method. Cause: a derived query method references a Java property that does not exist or uses an invalid property path. Diagnose by reading the full exception and comparing the method name with entity field names, not column names. Correct by renaming the method, adding the missing mapped property, or replacing an unreadable derived name with @Query.
LazyInitializationException appears in a controller or serializer. Symptom: a request fails after the service returns, usually when JSON serialization touches a lazy relationship. Cause: the persistence context is closed and the code is traversing an unfetched association. Diagnose by locating the field being serialized and checking repository fetch plans. Correct by returning a DTO or projection, adding a query-specific entity graph, or moving required traversal inside the transactional service. Avoid making every relationship eager; that often creates larger and slower queries.
A page endpoint becomes slow as data grows. Symptom: the content query is acceptable but the endpoint spends time counting rows, or the database performs full scans. Cause: Page requires a count query and predicates lack supporting indexes. Diagnose with SQL logging and the database’s execution plan. Correct by adding indexes that match the predicate and sort, switching to Slice when total counts are unnecessary, or using keyset pagination for deep scrolling.
Duplicate rows appear despite an existence check. Symptom: two concurrent requests create the same business identity. Cause: application-level existence checks are not serialization. Diagnose by reproducing with concurrent requests and inspecting table constraints. Correct by adding a unique constraint, handling the resulting persistence exception, and keeping the service transaction small enough that locks are not held longer than needed.
Security, Performance, and Reliability
Spring Data JPA does parameter binding for derived queries and JPQL parameters, which avoids string-concatenated SQL injection when used correctly. Native queries and dynamic sorting still need care: never concatenate untrusted field names or clauses into a query string. Expose a whitelist of sortable fields at the API boundary and map them to known entity properties.
Performance depends on query shape, fetch plan, indexes, and transaction length. Repositories should make expensive operations visible in the method name or documentation: findAll on a large table is rarely a safe default for request handling. Reliability improves when writes are idempotent where possible, constraints enforce invariants, and exceptions are translated into stable application errors. Spring’s persistence exception translation helps, but your service still needs to decide which errors are retryable, validation failures, or operational incidents.
Hands-On Lab
Prerequisites: a Spring Boot project with Spring Data JPA, a test database such as H2 or a local containerized database, and a test framework with AssertJ or equivalent assertions. Use a small customer table; do not point the lab at shared production data.
- Create the
Customerentity withid,email, andstatus. Add a database uniqueness rule foremail. - Create
CustomerRepositoryextendingJpaRepository<Customer, Long>withfindByEmailAndStatus,existsByEmail, andfindByStatusOrderByEmailAsc. - Add
CustomerRegistrationServicewith a@Transactionalregistermethod that normalizes email and saves an active customer. - Write a
@DataJpaTestthat saves active and disabled customers, flushes, clears, and verifies the active lookup. - Add a paging test with three active customers and page size two. Verify that the first page has two rows and reports a following page.
- Run the tests with SQL logging enabled long enough to inspect the generated select, insert, and count queries.
Verification: the repository test passes, the service stores normalized email, duplicate email attempts fail consistently, and SQL logging shows bounded selects for paged methods. For the order example, verify that the entity graph prevents extra customer selects while iterating results inside the transaction.
Cleanup: remove test rows if you used a persistent local database, disable verbose SQL logging after the lab, and roll back any experimental eager relationship changes. Keep the unique constraint and focused repository methods if they match your application model.
Assessment Exercises
- A repository method named
findByStatusAndEmailContainingIgnoreCaseOrderByCreatedAtDescis added for a customer search screen. Decide whether to keep it as a derived query or replace it, and justify the choice using readability, indexing, and future filter changes. - A controller returns entities and starts failing with
LazyInitializationExceptionafter a new relationship is added. Propose a fix that does not make every relationship eager, and explain how you would test it. - A product owner asks for total result counts on every search page. Explain when
Pageis appropriate, whenSliceis better, and what database evidence you would gather before deciding. - Two users can register the same email during a load test. Identify the missing guarantee and describe the repository, service, and database changes needed to make the operation reliable.
- Design a read-only report that joins orders and customers. Explain why it may belong in a projection or query repository instead of either aggregate’s command repository.
Summary
Spring Data JPA repositories are generated adapters over JPA, not a substitute for persistence design. Good repositories expose use-case-shaped queries, keep transaction ownership in services, choose fetch plans deliberately, and rely on database constraints for final invariants. Derived methods, JPQL, projections, paging, and entity graphs are useful when their trade-offs are visible. The practical test is whether a reader can predict the query shape, transaction boundary, and failure behavior from the repository and service names.
