| name | 312-frameworks-spring-data-jdbc | ||||
|---|---|---|---|---|---|
| description | Use when you need to use Spring Data JDBC with Java records — including entity design with records, repository pattern, immutable updates, aggregate relationships, custom queries, transaction management, and avoiding N+1 problems. For programmatic JDBC (`JdbcTemplate`, `NamedParameterJdbcTemplate`), hand-written SQL, and maximum control without repository abstraction, use `@311-frameworks-spring-jdbc`. For Flyway-backed DDL and versioned schema changes, use `@313-frameworks-spring-db-migrations-flyway`. | ||||
| license | Apache-2.0 | ||||
| metadata |
|
You are a Senior software engineer with extensive experience in Spring Data and Java persistence
Spring Data JDBC maps rows to domain types with minimal magic: one repository call typically loads a whole aggregate in predictable SQL. Java records fit this model because they are immutable, constructor-friendly, and explicit. Success means correct @Column/@Id mapping, repositories that express intent through naming or @Query, small aggregates with Set children or foreign keys—not JPA-style graphs—and transactions declared at the service layer. For programmatic JDBC, reporting, or batch SQL without Spring Data repositories, use @311-frameworks-spring-jdbc instead of forcing everything through repositories.
Before applying any recommendations, ensure the project is in a valid state by running Maven compilation. Compilation failure is a BLOCKING condition that prevents any further processing.
- MANDATORY: Run
./mvnw compileormvn compilebefore applying any change - PREREQUISITE: Project must compile successfully and pass basic validation checks before any Spring Data JDBC refactoring
- CRITICAL SAFETY: If compilation fails, IMMEDIATELY STOP and DO NOT CONTINUE with any recommendations
- BLOCKING CONDITION: Compilation errors must be resolved by the user before proceeding with persistence changes
- NO EXCEPTIONS: Under no circumstances should data-access recommendations be applied to a project that fails to compile
- VERIFY: Run
./mvnw clean verifyormvn clean verifyafter applying improvements
- Example 1: Records as JDBC entities
- Example 2: Repository interfaces
- Example 3: Updates with immutable records
- Example 4: Aggregate relationships
- Example 5: Custom @Query usage
- Example 6: Transaction boundaries
- Example 7: Single-query aggregate loading
- Example 8: @Table and @Embedded mapping annotations
- Example 9: INSERT vs UPDATE: how save() decides
- Example 10: @Version for optimistic locking
- Example 11: DTO projections for read-only queries
- Example 12: @DataJdbcTest slice tests
- Example 13: Transaction propagation
- Example 14: Self-invocation pitfall
Title: Map columns explicitly; use @PersistenceCreator when multiple constructors exist
Description: Prefer records for thread-safe, concise persistence types. Use @Column when names differ from properties. If you add extra constructors, mark the persistence-facing one with @PersistenceCreator. Use factories like of(...) for new rows before insert.
Good example:
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.PersistenceCreator;
import org.springframework.data.relational.core.mapping.Column;
import java.time.LocalDateTime;
public record Customer(
@Id
@Column("customer_id")
Long id,
@Column("first_name")
String firstName,
@Column("last_name")
String lastName,
@Column("email_address")
String email,
@Column("created_at")
LocalDateTime createdAt
) {
@PersistenceCreator
public Customer(Long id, String firstName, String lastName, String email, LocalDateTime createdAt) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.createdAt = createdAt;
}
public static Customer of(String firstName, String lastName, String email) {
return new Customer(null, firstName, lastName, email, LocalDateTime.now());
}
}Bad example:
import org.springframework.data.annotation.Id;
import java.time.LocalDateTime;
// Multiple constructors without @PersistenceCreator — mapping is ambiguous
public record Customer(
@Id Long id,
String firstName,
String lastName,
String email,
LocalDateTime createdAt
) {
public Customer(Long id, String firstName, String lastName, String email, LocalDateTime createdAt) {
this(id, firstName, lastName, email, createdAt);
}
public Customer(String firstName, String lastName, String email) {
this(null, firstName, lastName, email, LocalDateTime.now());
}
}
// Mutable entity: boilerplate and accidental mutation
public class CustomerEntity {
@Id
private Long id;
private String email;
// getters/setters — easy to leave entity in inconsistent state
}Title: Extend ListCrudRepository (preferred over CrudRepository); derive methods or use @Query with parameters
Description: Annotate interfaces with @Repository, extend ListCrudRepository (preferred—returns List<T> instead of Iterable<T>) or PagingAndSortingRepository, use query derivation names that match property paths, and bind @Query parameters with @Param—never hardcode volatile literals for user data.
Good example:
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface CustomerRepository extends ListCrudRepository<Customer, Long> {
List<Customer> findByLastName(String lastName);
Optional<Customer> findByEmail(String email);
@Query("SELECT * FROM customer WHERE email LIKE :pattern")
List<Customer> findByEmailPattern(@Param("pattern") String pattern);
}Bad example:
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.CrudRepository;
import java.util.List;
// Missing @Repository; non-idiomatic names; unsafe literal in SQL;
// CrudRepository returns Iterable<T> — prefer ListCrudRepository for List<T>
public interface CustomerRepository extends CrudRepository<Customer, Long> {
List<Customer> getCustomersWithLastName(String lastName);
@Query("SELECT * FROM customer WHERE email LIKE '%@gmail.com%'")
List<Customer> findGmailUsers();
}Title: Return new instances via with* methods; save in a transaction
Description: Records cannot be mutated in place. Expose withEmail, withName, or similar methods that copy fields and replace what changed, then save the new instance inside a transactional service.
Good example:
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
public record Customer(
@Id @Column("customer_id") Long id,
@Column("first_name") String firstName,
@Column("last_name") String lastName,
@Column("email_address") String email,
@Column("created_at") LocalDateTime createdAt
) {
public Customer withEmail(String newEmail) {
return new Customer(id, firstName, lastName, newEmail, createdAt);
}
}
@Service
class CustomerService {
private final CustomerRepository customerRepository;
CustomerService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
@Transactional
Customer updateCustomerEmail(Long customerId, String newEmail) {
return customerRepository.findById(customerId)
.map(c -> c.withEmail(newEmail))
.map(customerRepository::save)
.orElseThrow(() -> new IllegalArgumentException("customer: " + customerId));
}
}
interface CustomerRepository extends org.springframework.data.repository.ListCrudRepository<Customer, Long> {
java.util.Optional<Customer> findById(Long id);
}Bad example:
import org.springframework.transaction.annotation.Transactional;
// Attempting to "mutate" a record is invalid — no setters on components
// Anti-pattern: partial re-construction that drops fields or bypasses with* helpers
@Transactional
class CustomerService {
Customer updateEmail(Long id, String email, CustomerRepository repo) {
Customer c = repo.findById(id).orElseThrow();
return repo.save(new Customer(c.id(), email)); // wrong arity / lost fields if record has more components
}
}
record Customer(Long id, String firstName, String lastName, String email, java.time.LocalDateTime createdAt) {
Customer(Long id, String email) {
this(id, null, null, email, null);
}
}
interface CustomerRepository extends org.springframework.data.repository.CrudRepository<Customer, Long> {
java.util.Optional<Customer> findById(Long id);
}Title: Sets inside the root; FK ids across aggregates; avoid bidirectional graphs
Description: Model one-to-many as Set<Child> on the aggregate root loaded with the root. Use scalar foreign keys (Long customerId) for references to other aggregates. Many-to-many: junction entity or denormalization—not Set<Course> on Student and Set<Student> on Course. Do not expose separate repositories for every line item if the line is part of the order aggregate.
Good example:
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Embedded;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Set;
public record Order(
@Id @Column("order_id") Long id,
@Column("customer_id") Long customerId,
@Column("order_date") LocalDateTime orderDate,
Set<OrderItem> items
) {}
public record OrderItem(
@Id @Column("item_id") Long id,
@Column("product_name") String productName,
@Column("price") BigDecimal price,
@Column("quantity") int quantity
) {}
// Junction-style link entity for many-to-many style data
public record Enrollment(
@Id @Column("enrollment_id") Long id,
@Column("student_id") Long studentId,
@Column("course_id") Long courseId,
@Column("enrolled_at") LocalDateTime enrolledAt
) {}Bad example:
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import java.util.Set;
// Full object graph / bidirectional — breaks aggregate boundaries for JDBC
public record Order(
@Id @Column("order_id") Long id,
Customer customer,
Set<OrderItem> items
) {}
public record OrderItem(
@Id @Column("item_id") Long id,
String productName,
Order order
) {}
// Direct many-to-many sets — not supported like JPA
public record Student(@Id Long id, String name, Set<Course> courses) {}
public record Course(@Id Long id, String title, Set<Student> students) {}
// Child repository for entities that belong to Order aggregate
interface OrderItemRepository extends org.springframework.data.repository.CrudRepository<OrderItem, Long> {
java.util.List<OrderItem> findByOrderId(Long orderId);
}Title: Parameterized SQL; @Modifying for updates
Description: Use text-block or string SQL with @Param bindings. For updates/deletes, add @Modifying where required. Split oversized multi-join reports into views, separate reads, or bounded DTO queries instead of List<Object[]>.
Good example:
import org.springframework.data.jdbc.repository.query.Modifying;
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface CustomerRepository extends ListCrudRepository<Customer, Long> {
@Query("""
SELECT c.* FROM customer c
JOIN orders o ON c.id = o.customer_id
WHERE o.order_date BETWEEN :startDate AND :endDate
GROUP BY c.id
HAVING COUNT(o.id) >= :minOrders
""")
List<Customer> findActiveCustomers(
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate,
@Param("minOrders") int minOrders
);
@Modifying
@Query("UPDATE customer SET email = :email WHERE id = :id")
void updateCustomerEmail(@Param("id") Long id, @Param("email") String email);
}Bad example:
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.CrudRepository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface CustomerRepository extends CrudRepository<Customer, Long> {
// Never build SQL by concatenating untrusted input
@Query("SELECT * FROM customer WHERE email = :email")
Customer findByEmailUnsafe(@org.springframework.data.repository.query.Param("email") String email);
// Over-wide join returning Object[] — hard to maintain
@Query("""
SELECT c.*, o.*, oi.* FROM customer c
LEFT JOIN orders o ON c.id = o.customer_id
LEFT JOIN order_item oi ON o.id = oi.order_id
WHERE c.created_at > :d
""")
List<Object[]> findEverything(@org.springframework.data.repository.query.Param("d") LocalDateTime date);
}Title: @Transactional on services; readOnly for queries
Description: Declare transactions at the service layer. Use readOnly = true on classes or methods that only read. Avoid relying on implicit per-call auto-commit for multi-step writes.
Good example:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Service
@Transactional(readOnly = true)
class CustomerService {
private final CustomerRepository customerRepository;
CustomerService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
Optional<Customer> findByEmail(String email) {
return customerRepository.findByEmail(email);
}
@Transactional
Customer createCustomer(String firstName, String lastName, String email) {
return customerRepository.save(Customer.of(firstName, lastName, email));
}
@Transactional
Customer updateCustomerEmail(Long customerId, String newEmail) {
return customerRepository.findById(customerId)
.map(c -> c.withEmail(newEmail))
.map(customerRepository::save)
.orElseThrow();
}
}Bad example:
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
// No class-level transaction; each repository call may commit independently
class CustomerService {
private final CustomerRepository customerRepository;
Customer createCustomer(String firstName, String lastName, String email) {
return customerRepository.save(Customer.of(firstName, lastName, email));
}
// Read path marked @Transactional without readOnly — misses optimization hint
@Transactional
Optional<Customer> findByEmail(String email) {
return customerRepository.findByEmail(email);
}
}Title: Prefer Spring Data JDBC aggregate load over JPA lazy N+1 or manual fan-out
Description: Loading an aggregate root typically runs one SQL statement with joins for its collection. Iterate order.items() without expecting extra lazy queries (unlike JPA LAZY). Design aggregates so that payload size stays acceptable; avoid pulling huge graphs in one root.
Good example:
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
public record Order(
@Id @Column("order_id") Long id,
@Column("customer_id") Long customerId,
@Column("order_date") LocalDateTime orderDate,
@Column("total_amount") BigDecimal totalAmount,
Set<OrderItem> items
) {}
public record OrderItem(
@Id @Column("item_id") Long id,
@Column("unit_price") BigDecimal unitPrice,
@Column("quantity") int quantity
) {}
@Service
@Transactional(readOnly = true)
class OrderService {
private final OrderRepository orderRepository;
OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
List<OrderSummary> getCustomerOrderSummaries(Long customerId) {
return orderRepository.findByCustomerId(customerId).stream()
.map(order -> new OrderSummary(
order.id(),
order.orderDate(),
order.totalAmount(),
order.items().size(),
order.items().stream()
.mapToDouble(i -> i.unitPrice().doubleValue() * i.quantity())
.sum()
))
.toList();
}
}
record OrderSummary(Long orderId, LocalDateTime orderDate, BigDecimal totalAmount, int itemCount, double lineTotal) {}
interface OrderRepository extends org.springframework.data.repository.ListCrudRepository<Order, Long> {
List<Order> findByCustomerId(Long customerId);
}Bad example:
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
// Manual two-step load and map — error-prone when JDBC already loads the aggregate
@Service
class OrderService {
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
OrderService(OrderRepository orderRepository, OrderItemRepository orderItemRepository) {
this.orderRepository = orderRepository;
this.orderItemRepository = orderItemRepository;
}
List<OrderSummary> getCustomerOrderSummaries(Long customerId) {
List<Order> orders = orderRepository.findByCustomerId(customerId);
List<Long> ids = orders.stream().map(Order::id).toList();
List<OrderItem> allItems = orderItemRepository.findByOrderIdIn(ids);
Map<Long, List<OrderItem>> byOrder = allItems.stream()
.collect(Collectors.groupingBy(OrderItem::orderId));
return orders.stream()
.map(o -> new OrderSummary(o.id(), o.orderDate(), o.totalAmount(), byOrder.getOrDefault(o.id(), List.of()).size(), 0))
.toList();
}
}
record Order(Long id, java.time.LocalDateTime orderDate, java.math.BigDecimal totalAmount) {}
record OrderItem(Long id, Long orderId, java.math.BigDecimal unitPrice, int quantity) {}
interface OrderRepository extends org.springframework.data.repository.CrudRepository<Order, Long> {
List<Order> findByCustomerId(Long customerId);
}
interface OrderItemRepository {
List<OrderItem> findByOrderIdIn(List<Long> orderIds);
}
record OrderSummary(Long orderId, java.time.LocalDateTime orderDate, java.math.BigDecimal totalAmount, int itemCount, double lineTotal) {}
// JPA-style mental model: lazy collections causing N+1 if misapplied
// @Entity class Order { @jakarta.persistence.OneToMany(fetch = jakarta.persistence.FetchType.LAZY) Set<OrderItem> items; }Title: Name the table explicitly; inline value objects with @Embedded
Description: Use @Table to declare the exact SQL table name when it does not match the record name (e.g. Customer vs customers). Use @Embedded(onEmpty = USE_NULL) to inline a value object's columns into the parent table, keeping a rich object model while the schema stays flat. Value objects embedded this way have no independent lifecycle and need no separate repository.
Good example:
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Embedded;
import org.springframework.data.relational.core.mapping.Table;
@Table("customers")
public record Customer(
@Id @Column("customer_id") Long id,
@Column("first_name") String firstName,
@Column("last_name") String lastName,
@Column("email_address") String email,
@Embedded(onEmpty = Embedded.OnEmpty.USE_NULL)
Address address
) {
public static Customer of(String firstName, String lastName, String email, Address address) {
return new Customer(null, firstName, lastName, email, address);
}
}
// Columns street, city, postal_code live in the customers table — no join needed
public record Address(
@Column("street") String street,
@Column("city") String city,
@Column("postal_code") String postalCode
) {}Bad example:
import org.springframework.data.annotation.Id;
// Missing @Table — Spring Data JDBC derives "customer" from class name;
// breaks silently when the actual table is "customers"
public record Customer(
@Id Long id,
String firstName,
String email,
// Storing address as a JSON blob loses SQL filterability
String addressJson
) {}
// Separate aggregate for Address when it has no independent lifecycle — creates
// needless foreign-key join and an extra repository in the wrong aggregate boundary
public record Address(@Id Long id, Long customerId, String street, String city) {}
interface AddressRepository extends org.springframework.data.repository.CrudRepository<Address, Long> {}Title: Null @Id triggers INSERT; non-null @Id triggers UPDATE — use factory methods to stay explicit
Description: Spring Data JDBC checks isNew(): if @Id is null it issues INSERT and returns the saved record with the database-generated id; if @Id is non-null it issues UPDATE. Use a static factory (of(...)) that leaves id as null for new rows. Always use with* helpers to carry the existing id when updating so no fields are accidentally dropped.
Good example:
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Table("products")
public record Product(
@Id Long id,
String name,
BigDecimal price
) {
public static Product of(String name, BigDecimal price) {
return new Product(null, name, price); // null id → INSERT
}
public Product withPrice(BigDecimal newPrice) {
return new Product(id, name, newPrice); // same id → UPDATE
}
}
@Service
class ProductService {
private final ProductRepository repository;
ProductService(ProductRepository repository) { this.repository = repository; }
@Transactional
Product create(String name, BigDecimal price) {
Product saved = repository.save(Product.of(name, price));
// saved.id() now holds the database-generated value
return saved;
}
@Transactional
Product updatePrice(Long id, BigDecimal newPrice) {
return repository.findById(id)
.map(p -> p.withPrice(newPrice))
.map(repository::save)
.orElseThrow();
}
}
interface ProductRepository extends org.springframework.data.repository.ListCrudRepository<Product, Long> {}Bad example:
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Table("products")
public record Product(@Id Long id, String name, BigDecimal price) {}
@Service
class ProductService {
private final ProductRepository repository;
ProductService(ProductRepository repository) { this.repository = repository; }
@Transactional
Product create(String name, BigDecimal price) {
// Hardcoded id=1L triggers UPDATE if a row with id=1 already exists; silent data corruption
return repository.save(new Product(1L, name, price));
}
@Transactional
Product updatePrice(Long id, BigDecimal newPrice) {
// Drops the name field; causes data loss or a NOT NULL constraint violation
return repository.save(new Product(id, null, newPrice));
}
}
interface ProductRepository extends org.springframework.data.repository.CrudRepository<Product, Long> {}Title: Prevent lost updates under concurrency by adding a version field to the aggregate root
Description: Add @Version Long version to the aggregate root record. Spring Data JDBC increments it on every save and includes a WHERE version = ? clause in the UPDATE. If another transaction has already committed a newer version, an OptimisticLockingFailureException is thrown, preventing silent lost updates without database-level locking.
Good example:
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Table("inventory_items")
public record InventoryItem(
@Id @Column("item_id") Long id,
String sku,
int quantity,
@Version Long version
) {
public static InventoryItem of(String sku, int quantity) {
return new InventoryItem(null, sku, quantity, null);
}
public InventoryItem deduct(int amount) {
if (amount > quantity) throw new IllegalArgumentException("Insufficient stock for: " + sku);
return new InventoryItem(id, sku, quantity - amount, version);
}
}
@Service
class InventoryService {
private final InventoryItemRepository repository;
InventoryService(InventoryItemRepository repository) { this.repository = repository; }
@Transactional
InventoryItem deductStock(Long itemId, int amount) {
return repository.findById(itemId)
.map(item -> item.deduct(amount))
.map(repository::save) // throws OptimisticLockingFailureException on stale version
.orElseThrow();
}
}
interface InventoryItemRepository extends org.springframework.data.repository.ListCrudRepository<InventoryItem, Long> {}Bad example:
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
// No @Version — two concurrent threads read quantity=10, both deduct 8,
// both save quantity=2; net stock should be -6 but nothing throws
@Table("inventory_items")
public record InventoryItem(@Id Long id, String sku, int quantity) {}
@Service
class InventoryService {
private final InventoryItemRepository repository;
InventoryService(InventoryItemRepository repository) { this.repository = repository; }
@Transactional
InventoryItem deductStock(Long itemId, int amount) {
InventoryItem item = repository.findById(itemId).orElseThrow();
return repository.save(new InventoryItem(item.id(), item.sku(), item.quantity() - amount));
}
}
interface InventoryItemRepository extends org.springframework.data.repository.CrudRepository<InventoryItem, Long> {}Title: Map complex query results to a record DTO via rowMapperClass instead of Object[]
Description: When a query joins multiple tables or aggregates computed columns, map results to a dedicated DTO record using rowMapperClass on @Query. This avoids the fragile Object[] anti-pattern and keeps the aggregate root free of reporting-only fields. The DTO record lives in the read/query side; it is never saved.
Good example:
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;
// DTO record — separate from the aggregate root; read-only, never saved
public record CustomerOrderSummary(
Long customerId,
String customerName,
long orderCount,
BigDecimal totalSpent
) {}
@Repository
public interface CustomerRepository extends ListCrudRepository<Customer, Long> {
@Query(value = """
SELECT c.customer_id,
c.first_name || ' ' || c.last_name AS customer_name,
COUNT(o.order_id) AS order_count,
COALESCE(SUM(o.total_amount), 0) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE c.created_at >= :since
GROUP BY c.customer_id, c.first_name, c.last_name
ORDER BY total_spent DESC
""",
rowMapperClass = CustomerOrderSummaryMapper.class)
List<CustomerOrderSummary> findTopCustomerSummaries(@Param("since") LocalDate since);
}
class CustomerOrderSummaryMapper implements RowMapper<CustomerOrderSummary> {
@Override
public CustomerOrderSummary mapRow(ResultSet rs, int rowNum) throws SQLException {
return new CustomerOrderSummary(
rs.getLong("customer_id"),
rs.getString("customer_name"),
rs.getLong("order_count"),
rs.getBigDecimal("total_spent")
);
}
}Bad example:
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.List;
// Object[] — column order is an implicit contract; breaks silently when the query changes
public interface CustomerRepository extends CrudRepository<Customer, Long> {
@Query("""
SELECT c.customer_id, c.first_name || ' ' || c.last_name,
COUNT(o.order_id), COALESCE(SUM(o.total_amount), 0)
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE c.created_at >= :since
GROUP BY c.customer_id, c.first_name, c.last_name
""")
List<Object[]> findTopCustomers(@Param("since") LocalDate since);
// caller: (Long) row[0], (String) row[1] — fragile position-based access
}Title: Test repositories in isolation — no web layer, no service beans
Description: Use @DataJdbcTest to spin up only the JDBC slice: the DataSource, JdbcTemplate, and Spring Data JDBC repositories. Seed fixtures with @Sql. Never mock the repository inside this test — the entire point is to exercise real SQL against an embedded database, verifying derived queries, @Query statements, and aggregate loading.
Good example:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest;
import org.springframework.test.context.jdbc.Sql;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@DataJdbcTest
@Sql("/sql/customer-test-data.sql")
class CustomerRepositoryTest {
@Autowired
CustomerRepository customerRepository;
@Test
void findByEmail_returnsCustomer_whenEmailExists() {
Optional<Customer> result = customerRepository.findByEmail("alice@example.com");
assertThat(result).isPresent();
assertThat(result.get().firstName()).isEqualTo("Alice");
}
@Test
void save_generatesId_forNewRecord() {
Customer saved = customerRepository.save(Customer.of("Bob", "Smith", "bob@example.com"));
assertThat(saved.id()).isNotNull();
}
@Test
void findByLastName_returnsAllMatches() {
List<Customer> customers = customerRepository.findByLastName("Smith");
assertThat(customers).hasSize(2);
}
}Bad example:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
// @SpringBootTest loads the full context — web layer, security, services — far too heavy for a repository test
@SpringBootTest
class CustomerRepositoryTest {
@Autowired
CustomerRepository customerRepository;
@Test
void findsNothing_becauseMockIsUsed() {
// Anti-pattern: mocking the very repository being tested adds no value;
// it only verifies Mockito wiring, not SQL correctness
CustomerRepository mockRepo = mock(CustomerRepository.class);
when(mockRepo.findByEmail("x@example.com")).thenReturn(java.util.Optional.empty());
assertThat(mockRepo.findByEmail("x@example.com")).isEmpty();
}
}Title: REQUIRES_NEW for independent audit writes; MANDATORY to enforce caller contract
Description: The default propagation REQUIRED joins an existing transaction or starts a new one. Use REQUIRES_NEW when an operation must commit independently — such as audit logging — so it persists even if the outer transaction rolls back. Use MANDATORY to assert that a transaction must already exist, failing fast when called without one.
Good example:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
public record AuditLog(@org.springframework.data.annotation.Id Long id, String action, long entityId) {
static AuditLog of(String action, long entityId) { return new AuditLog(null, action, entityId); }
}
interface AuditLogRepository extends org.springframework.data.repository.ListCrudRepository<AuditLog, Long> {}
@Service
class AuditService {
private final AuditLogRepository auditLogRepository;
AuditService(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
// REQUIRES_NEW: always commits audit entry in its own transaction,
// even if the caller's transaction is rolled back
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String action, long entityId) {
auditLogRepository.save(AuditLog.of(action, entityId));
}
}
@Service
@Transactional(readOnly = true)
class OrderService {
private final OrderRepository orderRepository;
private final AuditService auditService;
OrderService(OrderRepository orderRepository, AuditService auditService) {
this.orderRepository = orderRepository;
this.auditService = auditService;
}
@Transactional
void cancelOrder(Long orderId) {
orderRepository.findById(orderId)
.map(Order::cancel)
.map(orderRepository::save)
.orElseThrow();
auditService.record("order-cancelled", orderId); // commits in its own independent tx
}
}Bad example:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class AuditService {
private final AuditLogRepository auditLogRepository;
AuditService(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
// Bad: default REQUIRED joins the caller's transaction —
// if cancelOrder rolls back, the audit entry is also lost
@Transactional
void record(String action, long entityId) {
auditLogRepository.save(AuditLog.of(action, entityId));
}
}Title: Calling @Transactional from within the same bean bypasses the proxy
Description: Spring applies @Transactional through a proxy. Calling a @Transactional method via this.method() within the same bean bypasses the proxy — no new transaction is opened and no propagation rule is applied. Extract the method to a separate Spring-managed bean to guarantee proxy interception.
Good example:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
// Separate bean — proxy is intercepted correctly
@Service
class CustomerEmailService {
private final CustomerRepository customerRepository;
CustomerEmailService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
@Transactional
public void updateEmail(Long customerId, String newEmail) {
customerRepository.findById(customerId)
.map(c -> c.withEmail(newEmail))
.map(customerRepository::save)
.orElseThrow();
}
}
@Service
@Transactional(readOnly = true)
class CustomerService {
private final CustomerRepository customerRepository;
private final CustomerEmailService customerEmailService;
CustomerService(CustomerRepository customerRepository,
CustomerEmailService customerEmailService) {
this.customerRepository = customerRepository;
this.customerEmailService = customerEmailService;
}
@Transactional
void onboardCustomer(String firstName, String lastName, String email) {
Customer c = customerRepository.save(Customer.of(firstName, lastName, email));
customerEmailService.updateEmail(c.id(), email); // proxy intercepted — correct
}
}Bad example:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
class CustomerService {
private final CustomerRepository customerRepository;
CustomerService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
@Transactional
void onboardCustomer(String firstName, String lastName, String email) {
Customer c = customerRepository.save(Customer.of(firstName, lastName, email));
this.updateEmail(c.id(), email); // Bad: self-invocation — proxy bypassed, no transaction opened
}
@Transactional
void updateEmail(Long customerId, String newEmail) {
customerRepository.findById(customerId)
.map(cu -> cu.withEmail(newEmail))
.map(customerRepository::save)
.orElseThrow();
}
}- ANALYZE persistence code: record/entity mapping, repository APIs, aggregate shape,
@Querysafety, transaction placement, and load patterns (single query vs extra round-trips) - CATEGORIZE issues by impact (CORRECTNESS, PERFORMANCE, MAINTAINABILITY) and by layer (entity mapping, repository, service/transaction, aggregate design)
- APPLY Spring Data JDBC–aligned fixes: add
@Column/@Id, narrow repositories, introducewith*updates, reshape aggregates and FKs, parameterize SQL, move@Transactionalto services withreadOnlywhere fit - IMPLEMENT changes so schema, aggregates, and tests stay consistent; prefer Flyway migrations (
@313-frameworks-spring-db-migrations-flyway) and integration tests for repository behavior - EXPLAIN trade-offs (aggregate size vs query size, explicit SQL vs derived queries,
@Embeddedvs FK reference) - TEST repository behavior with
@DataJdbcTestslices seeded via@Sql; never mock repositories inside persistence tests - VALIDATE with
./mvnw compilebefore and./mvnw clean verifyafter changes
- BLOCKING SAFETY CHECK: Run
./mvnw compileormvn compilebefore ANY persistence refactoring - CRITICAL VALIDATION: Run
./mvnw clean verifyafter changes; exercise repository integration tests - DATA SAFETY: Review DDL and migration impact before changing aggregate boundaries or
@Queryupdates - SQL INJECTION: Never concatenate user input into
@Querystrings; use parameters - INCREMENTAL SAFETY: Change one aggregate or repository surface at a time when possible
- SAFETY PROTOCOL: Stop if compilation or data tests fail after edits
- ROLLBACK RULES: Default
@Transactionaldoes not roll back on checked exceptions — declarerollbackForexplicitly when checked exceptions must abort the transaction - PROPAGATION: Use
Propagation.REQUIRES_NEWfor operations that must commit independently (e.g. audit logs) regardless of the outer transaction outcome - SELF-INVOCATION: Never call a
@Transactionalmethod viathis.method()inside the same bean — the Spring proxy is bypassed; extract to a separate Spring-managed bean