Repositories
Declare a repository interface and let Spring Data implement it, pick the right base interface, and know what save() actually does.
interface BookRepository extends JpaRepository<Book, Long> {
Optional<Book> findByIsbn(String isbn);
}
Book saved = books.save(book); // use the returned instanceA Spring Data repository is an interface. At startup Spring Data finds every interface that extends Repository below your main class's package and creates an implementation bean for it. You never write the class and you do not need @Repository on the interface.
Which base interface
Repository<T, ID>: a marker with no methods. You declare only the ones you need, which keeps destructive methods likedeleteAll()out of reach.ListCrudRepository<T, ID>:save,findById,findAll,deleteByIdand friends, returningListrather thanIterable.JpaRepository<T, ID>: adds paging and sorting,flush,saveAndFlush, batch deletes andgetReferenceById. The common default.
What save() does
save asks whether the entity is new. By default that means its id is null (or a @Version field is null). New entities are passed to persist; anything else goes to merge, which runs a SELECT first and returns a different managed instance. Always use the returned object, not the one you passed in.
This bites when you assign ids yourself, such as a UUID set in the constructor. Every save looks like an update, costs an extra query, and never fails on a duplicate. Implement Persistable to tell Spring Data what new means.
For a loaded entity inside a transaction you do not need save at all: dirty checking writes the change on commit. Calling it anyway is harmless but suggests the author expected otherwise.
Transactions
The methods inherited from ListCrudRepository and JpaRepository each run in their own transaction when none is active, with reads marked read-only. Query methods you declare yourself get no transaction of their own. Two calls in a controller are therefore two separate units of work. Put the use case in a @Transactional service method so the reads and writes commit or roll back together.
Adding your own code
When a query is too dynamic for derived methods, define a fragment interface and a class named after it with the Impl suffix, then extend the fragment from your repository. Spring Data wires your class in and callers still see a single repository.
getReferenceById returns a proxy without querying. It is useful for setting a foreign key (order.setCustomer(customers.getReferenceById(id))), but it throws EntityNotFoundException later, on first access, if the row does not exist.
Example
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.util.Optional;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
class Customer {
private Long id;
private String email;
private String name;
protected Customer() { }
Customer(String email, String name) {
this.email = email;
this.name = name;
}
Long getId() { return id; }
void rename(String name) { this.name = name; }
}
interface CustomerRepository extends ListCrudRepository<Customer, Long> {
Optional<Customer> findByEmail(String email);
boolean existsByEmail(String email);
}
public class CustomerService {
private final CustomerRepository customers;
public CustomerService(CustomerRepository customers) {
this.customers = customers;
}
public Long register(String email, String name) {
if (customers.existsByEmail(email)) {
throw new IllegalStateException("email already registered");
}
Customer saved = customers.save(new Customer(email, name));
return saved.getId();
}
public void rename(Long id, String name) {
customers.findById(id).orElseThrow().rename(name);
}
}When to use it
- Give the orders service a repository with lookups by customer and status without writing any implementation class.
- Store imported records whose UUIDs come from an upstream system without an extra SELECT per insert.
- Add a search endpoint with optional filters while keeping one repository for callers.
More examples
A narrow repository
An audit log should never be updated in bulk or deleted from application code. Extending the bare Repository and declaring only save and findById means deleteAll simply does not exist on this type. Spring Data recognises the method signatures and wires them to its standard implementation.
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.util.Optional;
import org.springframework.data.repository.Repository;
@Entity
class AuditEntry {
@Id
private Long id;
private String action;
protected AuditEntry() { }
}
public interface AuditEntryRepository extends Repository<AuditEntry, Long> {
AuditEntry save(AuditEntry entry);
Optional<AuditEntry> findById(Long id);
}Assigned ids with Persistable
Without Persistable, an Invoice with its id already set looks existing, so save() calls merge, selects first, and an accidental duplicate id overwrites a row instead of failing. Here a freshly constructed invoice reports isNew() as true and goes straight to persist; once loaded or saved, the callbacks flip the flag.
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.PostLoad;
import jakarta.persistence.PostPersist;
import jakarta.persistence.Transient;
import java.util.UUID;
import org.springframework.data.domain.Persistable;
@Entity
public class Invoice implements Persistable<UUID> {
@Id
private UUID id;
private String number;
@Transient
private boolean isNew = true;
protected Invoice() { }
public Invoice(String number) {
this.id = UUID.randomUUID();
this.number = number;
}
@Override
public UUID getId() { return id; }
@Override
public boolean isNew() { return isNew; }
@PostLoad
@PostPersist
void markNotNew() { this.isNew = false; }
}A custom fragment
Spring Data finds TicketSearchImpl by the fragment name plus Impl and merges it into the repository bean. The query text only ever contains fixed fragments and named parameters, never user input, so optional filters stay safe from injection.
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Id;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
@Entity
class Ticket {
@Id
private Long id;
private String status;
private String assignee;
protected Ticket() { }
}
interface TicketSearch {
List<Ticket> search(String status, String assignee);
}
class TicketSearchImpl implements TicketSearch {
private final EntityManager em;
TicketSearchImpl(EntityManager em) { this.em = em; }
@Override
public List<Ticket> search(String status, String assignee) {
StringBuilder jpql = new StringBuilder("select t from Ticket t where 1 = 1");
if (status != null) jpql.append(" and t.status = :status");
if (assignee != null) jpql.append(" and t.assignee = :assignee");
var query = em.createQuery(jpql.toString(), Ticket.class);
if (status != null) query.setParameter("status", status);
if (assignee != null) query.setParameter("assignee", assignee);
return query.getResultList();
}
}
public interface TicketRepository extends JpaRepository<Ticket, Long>, TicketSearch { }
Discussion