CRUD operations, entity relationships, and custom queries
September 1, 2025
13 min read
Spring Data JPA abstracts away boilerplate SQL so you can focus on your domain logic. Under the hood it uses Hibernate as the JPA implementation. In this guide you'll master entities, repositories, relationships, and JPQL queries — everything you need to build production-grade data layers.
xml<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <!-- For development/testing use H2 --> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies>
properties# PostgreSQL (production) spring.datasource.url=jdbc:postgresql://localhost:5432/mydb spring.datasource.username=postgres spring.datasource.password=secret spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true # H2 in-memory (development) # spring.datasource.url=jdbc:h2:mem:testdb # spring.h2.console.enabled=true
ddl-auto controls schema generation: create drops and recreates on startup, update applies incremental changes, validate only checks — never use create or create-drop in production.
java@Entity @Table(name = "products") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 100) private String name; @Column(nullable = false) private double price; @Column(name = "created_at", updatable = false) @CreationTimestamp private LocalDateTime createdAt; // Constructors protected Product() {} public Product(String name, double price) { this.name = name; this.price = price; } // Getters and setters... }
Extend JpaRepository and Spring generates all standard CRUD methods automatically:
javapublic interface ProductRepository extends JpaRepository<Product, Long> { // Spring Data derives SQL from the method name List<Product> findByName(String name); List<Product> findByPriceLessThan(double maxPrice); List<Product> findByNameContainingIgnoreCase(String keyword); Optional<Product> findFirstByOrderByCreatedAtDesc(); // Custom JPQL query @Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max ORDER BY p.price") List<Product> findInPriceRange(@Param("min") double min, @Param("max") double max); // Native SQL query @Query(value = "SELECT * FROM products WHERE price > ?1", nativeQuery = true) List<Product> findExpensive(double threshold); // Modifying query @Modifying @Transactional @Query("UPDATE Product p SET p.price = p.price * :factor WHERE p.id = :id") int applyDiscount(@Param("id") Long id, @Param("factor") double factor); }
java@Service @RequiredArgsConstructor public class ProductService { private final ProductRepository repo; public List<Product> getAll() { return repo.findAll(); } public Product getById(Long id) { return repo.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id)); } public Product create(Product product) { return repo.save(product); } public Product update(Long id, Product updated) { Product existing = getById(id); existing.setName(updated.getName()); existing.setPrice(updated.getPrice()); return repo.save(existing); } public void delete(Long id) { if (!repo.existsById(id)) throw new ResourceNotFoundException("Product not found: " + id); repo.deleteById(id); } }
JPA supports all common database relationships:
java// One-to-Many: One Category has many Products @Entity public class Category { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @OneToMany(mappedBy = "category", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private List<Product> products = new ArrayList<>(); } @Entity public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_id") private Category category; } // Many-to-Many: Products and Tags @Entity public class Tag { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToMany(mappedBy = "tags") private Set<Product> products = new HashSet<>(); } // In Product entity: @ManyToMany @JoinTable( name = "product_tags", joinColumns = @JoinColumn(name = "product_id"), inverseJoinColumns = @JoinColumn(name = "tag_id") ) private Set<Tag> tags = new HashSet<>();
Always use FetchType.LAZY on @OneToMany and @ManyToMany to avoid the N+1 query problem. Load related data explicitly with JOIN FETCH in JPQL when needed.
java// Repository method Page<Product> findByCategory_Name(String categoryName, Pageable pageable); // Service usage public Page<Product> getProductsPaged(int page, int size, String sortBy) { Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy).ascending()); return repo.findAll(pageable); } // REST endpoint @GetMapping public Page<Product> list( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "name") String sort ) { return productService.getProductsPaged(page, size, sort); } // GET /api/products?page=0&size=10&sort=price
java@Service @Transactional // all public methods run in a transaction by default public class OrderService { private final OrderRepository orderRepo; private final InventoryRepository inventoryRepo; // This entire method runs in ONE transaction — if anything fails, everything rolls back public Order placeOrder(OrderRequest request) { Inventory item = inventoryRepo.findById(request.productId()) .orElseThrow(() -> new RuntimeException("Product not found")); if (item.getStock() < request.quantity()) { throw new InsufficientStockException("Not enough stock"); } item.setStock(item.getStock() - request.quantity()); inventoryRepo.save(item); Order order = new Order(request.productId(), request.quantity()); return orderRepo.save(order); } @Transactional(readOnly = true) // optimized for reads public List<Order> getAll() { return orderRepo.findAll(); } }
What's Next?
@CreatedDate and @LastModifiedDate