Repositories

Declare a repository interface and let Spring Data implement it, pick the right base interface, and know what save() actually does.

Syntaxinterface BookRepository extends JpaRepository<Book, Long> { Optional<Book> findByIsbn(String isbn); } Book saved = books.save(book); // use the returned instance

A 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 like deleteAll() out of reach.
  • ListCrudRepository<T, ID>: save, findById, findAll, deleteById and friends, returning List rather than Iterable.
  • JpaRepository<T, ID>: adds paging and sorting, flush, saveAndFlush, batch deletes and getReferenceById. 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

Example · java
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;

@Entity
class Customer {
    @Id
    @GeneratedValue
    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);
}

@Service
public class CustomerService {
    private final CustomerRepository customers;

    public CustomerService(CustomerRepository customers) {
        this.customers = customers;
    }

    @Transactional
    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();
    }

    @Transactional
    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.

Example · java
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.

Example · java
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.

Example · java
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

  • Be the first to comment on this lesson.