Java Spring Boot Backend

Spring Boot PostgreSQL CRUD with JPA and Hibernate

Spring boot postgresql crud is one of the most practical skills for Java backend developers. Almost every business application needs to create, read, update, and delete data: products in an ecommerce dashboard, customers in a CRM, invoices in an accounting system, tickets in a support tool, or orders in a food delivery platform. Spring Boot gives you the application foundation, PostgreSQL gives you a reliable relational database, JPA gives you a standard persistence API, and Hibernate turns Java objects into database rows without forcing you to write repetitive SQL for every simple operation.

In this beginner-friendly but production-focused guide, you will build a complete CRUD REST API using Java 21, Spring Boot 3.x, PostgreSQL, Spring Data JPA, Hibernate, validation, DTOs, global exception handling, pagination, sorting, and Gradle. This is more than a tiny demo. You will see the folder structure, full source code, JSON request and response samples, Postman examples, cURL commands, common Hibernate mistakes, best practices, production considerations, FAQs, and interview questions.

New to Spring Boot? Start with Spring Boot Getting Started, then come back here to connect your API to PostgreSQL.
Table of Contents

Introduction

PostgreSQL is popular because it is stable, open source, standards-friendly, and trusted in serious production systems. It supports transactions, indexes, constraints, JSON columns, full-text search, window functions, extensions, and strong data integrity. For many teams, PostgreSQL hits the sweet spot: it is friendly enough for small projects and powerful enough for large systems.

JPA and Hibernate are used because Java applications often work with domain objects, while relational databases store rows and tables. Without an ORM, you write SQL queries, map result sets into objects, manage inserts and updates manually, and repeat similar boilerplate across many features. With JPA and Hibernate, you model a table as an entity class, define a repository, and let the framework handle the common persistence work.

Real-world use cases for a Spring Boot PostgreSQL example include inventory management, product catalogs, employee directories, banking ledgers, booking systems, learning platforms, hospital systems, and admin dashboards. CRUD sounds simple, but it is the foundation of most backend features. The difference between a toy CRUD app and a useful backend is how you handle validation, errors, transactions, pagination, sorting, database constraints, and maintainable layers.

What is JPA?

JPA stands for Java Persistence API. In modern Spring Boot 3.x applications, the packages use jakarta.persistence because Java EE moved to Jakarta EE. JPA is not a library by itself in the way many beginners expect. It is a specification: a set of interfaces, annotations, and rules that describe how Java objects should be mapped to relational database tables.

The main benefit of JPA is portability and consistency. You can mark a class with @Entity, map it to a table, define a primary key with @Id, and use a repository to persist it. Your code talks to the JPA model instead of hard-coding low-level JDBC logic everywhere.

Core JPA Concepts

  • Entity: A Java class mapped to a database table.
  • Id: The primary key field that uniquely identifies each row.
  • Column: A field-to-column mapping, often customized with @Column.
  • EntityManager: The lower-level JPA API that manages entity lifecycle.
  • Persistence context: A unit of managed entity instances tracked during a transaction.
  • Repository: In Spring Data JPA, an interface that gives you common database operations.
  • Transaction: A boundary where database work either succeeds together or rolls back together.

In a typical spring data jpa tutorial, you will rarely use EntityManager directly at first. Spring Data JPA provides repositories that make common queries readable and compact. Later, when your application needs custom queries or performance tuning, understanding the lower-level JPA concepts becomes very useful.

What is Hibernate?

Hibernate is an ORM, which means Object Relational Mapping. ORM is the practice of mapping object-oriented code to relational database structures. In our case, a Product Java object maps to a products PostgreSQL table, and fields like name, price, and stockQuantity map to columns.

Hibernate is the default JPA provider used by Spring Boot when you add Spring Data JPA. JPA defines what should happen; Hibernate provides the implementation that actually generates SQL, manages entity state, handles lazy loading, tracks changes, and coordinates database interaction.

JPA vs Hibernate

The short version is simple: JPA is the specification, Hibernate is an implementation. If you use @Entity, @Table, and @Id, you are using JPA annotations. When the application runs and SQL is executed, Hibernate is usually the engine doing the work. Hibernate also provides extra features that are not part of standard JPA, but beginners should first learn the standard JPA model before depending on provider-specific behavior.

Project Setup

Open Spring Initializr and create a project with these settings:

  • Project: Gradle - Groovy
  • Language: Java
  • Spring Boot: 3.x
  • Java: 21
  • Group: com.digitaldrift
  • Artifact: product-api
  • Packaging: Jar

Add these dependencies:

  • Spring Web: Build REST APIs.
  • Spring Data JPA: Repository abstraction and JPA integration.
  • PostgreSQL Driver: Connect Spring Boot to PostgreSQL.
  • Validation: Validate request DTOs with annotations.
  • Lombok: Optional, but this guide avoids Lombok so beginners can see the full code clearly.

If you want a REST-only warm-up before database work, read How to Build a REST API with Spring Boot in 30 Minutes.

Gradle Configuration

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.5'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.digitaldrift'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    runtimeOnly 'org.postgresql:postgresql'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

Installing PostgreSQL

You can install PostgreSQL using the official installer, Docker, Homebrew on macOS, or your operating system's package manager. For beginners, using a local PostgreSQL service is fine. For team projects, Docker Compose is often easier because everyone runs the same database version.

Create Database, User, and Permissions

Open psql as a PostgreSQL admin user and run:

CREATE DATABASE product_db;

CREATE USER product_user WITH PASSWORD 'product_password';

GRANT ALL PRIVILEGES ON DATABASE product_db TO product_user;

PostgreSQL 15 and newer may also require schema privileges after you connect to the database:

\c product_db

GRANT ALL ON SCHEMA public TO product_user;

In production, do not use weak passwords like this. Use a secret manager or environment variables, rotate credentials, and give the application only the permissions it needs.

Configure application.yml

Create src/main/resources/application.yml. This configuration connects Spring Boot to PostgreSQL, lets Hibernate create or update tables during local development, and enables readable SQL logs while you are learning.

spring:
  application:
    name: product-api

  datasource:
    url: jdbc:postgresql://localhost:5432/product_db
    username: product_user
    password: product_password
    driver-class-name: org.postgresql.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.PostgreSQLDialect

server:
  port: 8080
Use ddl-auto: update only for local learning and early development. In production, use database migration tools such as Flyway or Liquibase.

Project Structure

Keep the project organized by responsibility. A clean structure helps beginners understand the flow and helps teams maintain the code later.

product-api/
โ”œโ”€โ”€ build.gradle
โ””โ”€โ”€ src/
    โ””โ”€โ”€ main/
        โ”œโ”€โ”€ java/
        โ”‚   โ””โ”€โ”€ com/
        โ”‚       โ””โ”€โ”€ digitaldrift/
        โ”‚           โ””โ”€โ”€ productapi/
        โ”‚               โ”œโ”€โ”€ ProductApiApplication.java
        โ”‚               โ”œโ”€โ”€ controller/
        โ”‚               โ”‚   โ””โ”€โ”€ ProductController.java
        โ”‚               โ”œโ”€โ”€ dto/
        โ”‚               โ”‚   โ”œโ”€โ”€ ProductRequest.java
        โ”‚               โ”‚   โ””โ”€โ”€ ProductResponse.java
        โ”‚               โ”œโ”€โ”€ entity/
        โ”‚               โ”‚   โ””โ”€โ”€ Product.java
        โ”‚               โ”œโ”€โ”€ exception/
        โ”‚               โ”‚   โ”œโ”€โ”€ ErrorResponse.java
        โ”‚               โ”‚   โ”œโ”€โ”€ GlobalExceptionHandler.java
        โ”‚               โ”‚   โ””โ”€โ”€ ResourceNotFoundException.java
        โ”‚               โ”œโ”€โ”€ repository/
        โ”‚               โ”‚   โ””โ”€โ”€ ProductRepository.java
        โ”‚               โ””โ”€โ”€ service/
        โ”‚                   โ””โ”€โ”€ ProductService.java
        โ””โ”€โ”€ resources/
            โ””โ”€โ”€ application.yml

Complete Source Code

This section contains the complete source code for the hibernate crud example. You can create these files in the same package names and run the application with ./gradlew bootRun.

Application Class

package com.digitaldrift.productapi;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ProductApiApplication {

    public static void main(String[] args) {
        SpringApplication.run(ProductApiApplication.class, args);
    }
}

Create Entity: Product Entity with Validation-Friendly Constraints

The entity represents the database table. Validation annotations belong mainly on DTOs, but database constraints on the entity are still useful because they protect data integrity at the persistence layer.

package com.digitaldrift.productapi.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 120)
    private String name;

    @Column(nullable = false, length = 500)
    private String description;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Column(nullable = false)
    private Integer stockQuantity;

    @Column(nullable = false, length = 80)
    private String category;

    @Column(nullable = false)
    private Instant createdAt;

    @Column(nullable = false)
    private Instant updatedAt;

    protected Product() {
    }

    public Product(String name, String description, BigDecimal price, Integer stockQuantity, String category) {
        this.name = name;
        this.description = description;
        this.price = price;
        this.stockQuantity = stockQuantity;
        this.category = category;
        this.createdAt = Instant.now();
        this.updatedAt = Instant.now();
    }

    public void update(String name, String description, BigDecimal price, Integer stockQuantity, String category) {
        this.name = name;
        this.description = description;
        this.price = price;
        this.stockQuantity = stockQuantity;
        this.category = category;
        this.updatedAt = Instant.now();
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getDescription() {
        return description;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public Integer getStockQuantity() {
        return stockQuantity;
    }

    public String getCategory() {
        return category;
    }

    public Instant getCreatedAt() {
        return createdAt;
    }

    public Instant getUpdatedAt() {
        return updatedAt;
    }
}

Create DTOs: ProductRequest

DTOs protect your entity from direct API exposure. ProductRequest defines what the client is allowed to send. Validation annotations make bad requests fail before they reach the service layer.

package com.digitaldrift.productapi.dto;

import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;

public record ProductRequest(
        @NotBlank(message = "Product name is required")
        @Size(min = 2, max = 120, message = "Product name must be between 2 and 120 characters")
        String name,

        @NotBlank(message = "Description is required")
        @Size(max = 500, message = "Description must not exceed 500 characters")
        String description,

        @NotNull(message = "Price is required")
        @DecimalMin(value = "0.01", message = "Price must be greater than zero")
        BigDecimal price,

        @NotNull(message = "Stock quantity is required")
        @Min(value = 0, message = "Stock quantity cannot be negative")
        Integer stockQuantity,

        @NotBlank(message = "Category is required")
        @Size(max = 80, message = "Category must not exceed 80 characters")
        String category
) {
}

Create DTOs: ProductResponse

package com.digitaldrift.productapi.dto;

import com.digitaldrift.productapi.entity.Product;
import java.math.BigDecimal;
import java.time.Instant;

public record ProductResponse(
        Long id,
        String name,
        String description,
        BigDecimal price,
        Integer stockQuantity,
        String category,
        Instant createdAt,
        Instant updatedAt
) {
    public static ProductResponse from(Product product) {
        return new ProductResponse(
                product.getId(),
                product.getName(),
                product.getDescription(),
                product.getPrice(),
                product.getStockQuantity(),
                product.getCategory(),
                product.getCreatedAt(),
                product.getUpdatedAt()
        );
    }
}

Create Repository Layer

The repository layer talks to the database. By extending JpaRepository, you get methods like save, findById, findAll, existsById, and delete without writing SQL.

package com.digitaldrift.productapi.repository;

import com.digitaldrift.productapi.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {

    boolean existsByNameIgnoreCase(String name);
}

Custom Exception

package com.digitaldrift.productapi.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

Error Response DTO

package com.digitaldrift.productapi.exception;

import java.time.Instant;
import java.util.Map;

public record ErrorResponse(
        Instant timestamp,
        int status,
        String error,
        String message,
        String path,
        Map<String, String> validationErrors
) {
}

Global Exception Handling with @ControllerAdvice

A global exception handler keeps controller methods clean and gives clients consistent error responses. Validation errors return field-level messages, while missing records return a clear 404 Not Found.

package com.digitaldrift.productapi.exception;

import jakarta.servlet.http.HttpServletRequest;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(
            ResourceNotFoundException exception,
            HttpServletRequest request
    ) {
        ErrorResponse response = new ErrorResponse(
                Instant.now(),
                HttpStatus.NOT_FOUND.value(),
                "Not Found",
                exception.getMessage(),
                request.getRequestURI(),
                Map.of()
        );

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException exception,
            HttpServletRequest request
    ) {
        Map<String, String> validationErrors = new HashMap<>();

        exception.getBindingResult().getFieldErrors().forEach(error ->
                validationErrors.put(error.getField(), error.getDefaultMessage())
        );

        ErrorResponse response = new ErrorResponse(
                Instant.now(),
                HttpStatus.BAD_REQUEST.value(),
                "Bad Request",
                "Validation failed",
                request.getRequestURI(),
                validationErrors
        );

        return ResponseEntity.badRequest().body(response);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpectedErrors(
            Exception exception,
            HttpServletRequest request
    ) {
        ErrorResponse response = new ErrorResponse(
                Instant.now(),
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Internal Server Error",
                "An unexpected error occurred",
                request.getRequestURI(),
                Map.of()
        );

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
}

Create Service Layer

The service layer contains business logic. Controllers should handle HTTP concerns; repositories should handle persistence; services should coordinate application behavior. This separation keeps your Spring Boot PostgreSQL CRUD project easier to test and evolve.

package com.digitaldrift.productapi.service;

import com.digitaldrift.productapi.dto.ProductRequest;
import com.digitaldrift.productapi.dto.ProductResponse;
import com.digitaldrift.productapi.entity.Product;
import com.digitaldrift.productapi.exception.ResourceNotFoundException;
import com.digitaldrift.productapi.repository.ProductRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public ProductResponse createProduct(ProductRequest request) {
        Product product = new Product(
                request.name(),
                request.description(),
                request.price(),
                request.stockQuantity(),
                request.category()
        );

        Product savedProduct = productRepository.save(product);
        return ProductResponse.from(savedProduct);
    }

    @Transactional(readOnly = true)
    public ProductResponse getProductById(Long id) {
        Product product = findProduct(id);
        return ProductResponse.from(product);
    }

    @Transactional(readOnly = true)
    public Page<ProductResponse> getAllProducts(Pageable pageable) {
        return productRepository.findAll(pageable).map(ProductResponse::from);
    }

    @Transactional
    public ProductResponse updateProduct(Long id, ProductRequest request) {
        Product product = findProduct(id);

        product.update(
                request.name(),
                request.description(),
                request.price(),
                request.stockQuantity(),
                request.category()
        );

        return ProductResponse.from(product);
    }

    @Transactional
    public void deleteProduct(Long id) {
        Product product = findProduct(id);
        productRepository.delete(product);
    }

    private Product findProduct(Long id) {
        return productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
    }
}

Create Controller Layer

The controller exposes REST endpoints. It uses @Valid so request validation runs automatically. It also accepts Pageable, which lets clients send page, size, and sort query parameters.

package com.digitaldrift.productapi.controller;

import com.digitaldrift.productapi.dto.ProductRequest;
import com.digitaldrift.productapi.dto.ProductResponse;
import com.digitaldrift.productapi.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(@Valid @RequestBody ProductRequest request) {
        ProductResponse response = productService.createProduct(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getProductById(@PathVariable Long id) {
        return ResponseEntity.ok(productService.getProductById(id));
    }

    @GetMapping
    public ResponseEntity<Page<ProductResponse>> getAllProducts(
            @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
    ) {
        return ResponseEntity.ok(productService.getAllProducts(pageable));
    }

    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> updateProduct(
            @PathVariable Long id,
            @Valid @RequestBody ProductRequest request
    ) {
        return ResponseEntity.ok(productService.updateProduct(id, request));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
        return ResponseEntity.noContent().build();
    }
}

CRUD APIs

Your API now supports the complete CRUD lifecycle. This is the practical heart of the spring boot postgresql example.

  • Create Product: POST /api/products
  • Get Product By ID: GET /api/products/{id}
  • Get All Products: GET /api/products
  • Update Product: PUT /api/products/{id}
  • Delete Product: DELETE /api/products/{id}

Create Product Request

{
  "name": "Mechanical Keyboard",
  "description": "Hot-swappable wireless mechanical keyboard for developers",
  "price": 129.99,
  "stockQuantity": 50,
  "category": "Accessories"
}

Create Product Response

{
  "id": 1,
  "name": "Mechanical Keyboard",
  "description": "Hot-swappable wireless mechanical keyboard for developers",
  "price": 129.99,
  "stockQuantity": 50,
  "category": "Accessories",
  "createdAt": "2026-06-17T08:30:00Z",
  "updatedAt": "2026-06-17T08:30:00Z"
}

Pagination and Sorting

Pagination prevents your API from returning thousands of rows in one response. Sorting lets the client choose a useful order. Spring Data JPA supports both through Pageable, PageRequest, and Sort.

In the controller above, Spring automatically creates a Pageable object from query parameters. You can request the first page with five products sorted by price from low to high:

GET /api/products?page=0&size=5&sort=price,asc

You can also create a pageable manually in service code when needed:

PageRequest pageRequest = PageRequest.of(
        0,
        10,
        Sort.by(Sort.Direction.DESC, "createdAt")
);

A typical paginated response includes content plus metadata:

{
  "content": [
    {
      "id": 1,
      "name": "Mechanical Keyboard",
      "description": "Hot-swappable wireless mechanical keyboard for developers",
      "price": 129.99,
      "stockQuantity": 50,
      "category": "Accessories",
      "createdAt": "2026-06-17T08:30:00Z",
      "updatedAt": "2026-06-17T08:30:00Z"
    }
  ],
  "pageable": {
    "pageNumber": 0,
    "pageSize": 5
  },
  "totalElements": 1,
  "totalPages": 1,
  "last": true,
  "first": true,
  "size": 5,
  "number": 0
}

Testing APIs with Postman

Start the application:

./gradlew bootRun

In Postman, set the base URL to http://localhost:8080. Use Content-Type: application/json for create and update requests.

Postman Examples

  • Create: POST http://localhost:8080/api/products
  • Read one: GET http://localhost:8080/api/products/1
  • Read all: GET http://localhost:8080/api/products?page=0&size=10&sort=createdAt,desc
  • Update: PUT http://localhost:8080/api/products/1
  • Delete: DELETE http://localhost:8080/api/products/1

Validation Error Response

{
  "timestamp": "2026-06-17T08:35:00Z",
  "status": 400,
  "error": "Bad Request",
  "message": "Validation failed",
  "path": "/api/products",
  "validationErrors": {
    "name": "Product name is required",
    "price": "Price must be greater than zero"
  }
}

cURL Examples

Create

curl -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{
    "name": "USB-C Dock",
    "description": "Multi-port USB-C dock for laptops",
    "price": 89.99,
    "stockQuantity": 25,
    "category": "Accessories"
  }'

Read

curl http://localhost:8080/api/products/1

curl "http://localhost:8080/api/products?page=0&size=5&sort=price,asc"

Update

curl -X PUT http://localhost:8080/api/products/1 \
  -H "Content-Type: application/json" \
  -d '{
    "name": "USB-C Dock Pro",
    "description": "Premium USB-C dock with HDMI, Ethernet, and fast charging",
    "price": 119.99,
    "stockQuantity": 18,
    "category": "Accessories"
  }'

Delete

curl -X DELETE http://localhost:8080/api/products/1

Common Hibernate Mistakes

N+1 Problem

The N+1 problem happens when Hibernate runs one query to load a list and then one extra query per item to load related data. With 100 rows, that can become 101 queries. It often appears with relationships such as product reviews, order items, users, and roles. Use fetch joins, entity graphs, projection queries, or carefully designed DTO queries when you load relationships.

LazyInitializationException

LazyInitializationException usually means your code tried to access a lazy relationship after the Hibernate session was closed. Avoid returning entities directly from controllers. Use DTOs inside transactional service methods so the data is loaded while the transaction is active.

Missing Transactions

Write operations should run inside transactions. In Spring, place @Transactional on service methods, not usually on controllers. Read-only methods can use @Transactional(readOnly = true) to communicate intent and allow optimization.

Incorrect Cascade Types

Cascades control what happens to related entities when you persist, update, or delete a parent. Do not blindly use CascadeType.ALL. It can accidentally delete child records or persist data you did not intend to save. Choose cascade behavior based on ownership and lifecycle.

Best Practices

  • Use the DTO pattern: Keep API models separate from database entities.
  • Validate input: Use jakarta.validation annotations and return useful error messages.
  • Keep service layer separation: Put business rules in services, not controllers.
  • Handle errors properly: Use @ControllerAdvice for consistent API responses.
  • Use transactions intentionally: Write operations should be transactional; read operations can be read-only.
  • Avoid exposing entities: Entities often contain internal fields and relationships clients should not control.
  • Name database columns clearly: Prefer predictable table and column names for long-term maintainability.

If you plan to add authentication to this API, the next useful read is Spring Boot JWT Authentication Guide.

Production Considerations

Database Indexing

Index columns that are used frequently in filters, joins, and sorting. For this product API, you may later add indexes on category, name, or created_at. Indexes improve reads but add write overhead, so create them based on real query patterns.

Connection Pooling

Spring Boot uses HikariCP by default. In production, tune pool size based on application concurrency, database capacity, and request latency. A bigger pool is not always better; too many connections can overload PostgreSQL.

spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      minimum-idle: 2
      connection-timeout: 30000

Query Optimization

Use PostgreSQL's EXPLAIN ANALYZE to inspect slow queries. Keep an eye on generated SQL logs during development. For complex read endpoints, DTO projections or custom queries can be faster than loading full entities and relationships.

Database Migrations

Replace ddl-auto: update with Flyway or Liquibase before production. Migrations make schema changes reviewable, repeatable, and safe across environments.

Security

Protect write endpoints with authentication and authorization. Validate request size, avoid logging sensitive data, and configure CORS only for trusted origins. CRUD APIs are simple to call, which means they are also simple to abuse if left open.

Frequently Asked Questions

Is Spring Boot good for PostgreSQL CRUD APIs?

Yes. Spring Boot reduces setup work, Spring Data JPA removes repetitive repository code, Hibernate handles ORM behavior, and PostgreSQL provides a reliable relational database. The combination is common in real backend teams.

Do I need to know SQL if I use JPA?

Yes, eventually. JPA saves time, but SQL knowledge helps you understand performance, indexes, joins, constraints, and generated queries. Good backend developers are comfortable with both ORM and SQL.

Can I use MySQL instead of PostgreSQL?

Yes. Replace the PostgreSQL driver and datasource URL with MySQL equivalents. The JPA entity, DTO, repository, service, and controller patterns remain almost the same.

Should I use records for DTOs?

Java records are a great fit for immutable request and response DTOs. They reduce boilerplate and clearly express that the object is a data carrier.

Why not return Product entity directly?

Returning entities couples your API to your database model, can expose internal fields, and can trigger serialization issues with relationships. DTOs keep the API contract explicit.

Interview Questions and Answers

  • What is JPA? JPA is a Java specification for mapping objects to relational database tables.
  • What is Hibernate? Hibernate is an ORM framework and a popular JPA implementation.
  • What is Spring Data JPA? It is a Spring project that simplifies repository creation and database access using JPA.
  • What does JpaRepository provide? It provides CRUD methods, pagination, sorting, flushing, and batch-related operations.
  • What is @Entity? It marks a Java class as a persistent JPA entity.
  • What is @Transactional? It defines a transaction boundary where database operations commit or roll back together.
  • What is the N+1 problem? It is a performance issue where one query loads parent rows and then extra queries load related data for each row.
  • What is the difference between save and update? Spring Data JPA uses save for both new and existing entities; Hibernate decides whether to insert or update based on entity state.
  • Why use DTOs? DTOs separate API contracts from persistence models and make validation safer.
  • Why use PostgreSQL? PostgreSQL is reliable, open source, feature-rich, and widely used for production relational workloads.

Conclusion

You have built a complete Spring Boot PostgreSQL CRUD API with JPA and Hibernate using Java 21, Spring Boot 3.x, Gradle, validation, DTOs, repository, service, controller, pagination, sorting, and global exception handling. This is the foundation behind many real backend systems: model the data, validate inputs, persist safely, return clear responses, and handle failure predictably.

The next step is to extend this project in a production direction. Add authentication with JWT, introduce Flyway migrations, write integration tests with Testcontainers, add search filters, and monitor slow queries. Once you understand this pattern deeply, you can apply it to orders, users, payments, bookings, invoices, and almost any business workflow.

Dhiraj Roy
Dhiraj Roy

Backend developer & tech writer. Writing about Java, Spring Boot, Python, and AI at Digital Drift.