Spring Data JPA & Hibernate Fundamentals

Table of Contents

The Problem — Why Not Write Raw SQL in Java?

In Lecture 4, you wrote SQL directly in the MariaDB shell. That is fine for database administration, but what about executing SQL from your Java application? Let us look at how Java traditionally talks to databases.

JDBC — The Traditional Way

Java Database Connectivity (JDBC) is the standard Java API for database access. It has been around since Java 1.1 (1997). Here is what it looks like:

public User findUserById(Long id) {
    String sql = "SELECT id, username, email, full_name, bio, role, " +
                 "is_active, created_at, updated_at FROM users WHERE id = ?";
    
    Connection connection = null;
    PreparedStatement statement = null;
    ResultSet resultSet = null;
    
    try {
        // 1. Get a database connection
        connection = DriverManager.getConnection(
            "jdbc:mariadb://localhost:3306/blogdb", "bloguser", "blogpass");
        
        // 2. Prepare the SQL statement
        statement = connection.prepareStatement(sql);
        statement.setLong(1, id);  // Set the ? parameter
        
        // 3. Execute the query
        resultSet = statement.executeQuery();
        
        // 4. Map the result to a Java object — MANUALLY, column by column
        if (resultSet.next()) {
            User user = new User();
            user.setId(resultSet.getLong("id"));
            user.setUsername(resultSet.getString("username"));
            user.setEmail(resultSet.getString("email"));
            user.setFullName(resultSet.getString("full_name"));
            user.setBio(resultSet.getString("bio"));
            user.setRole(resultSet.getString("role"));
            user.setActive(resultSet.getBoolean("is_active"));
            user.setCreatedAt(resultSet.getTimestamp("created_at").toLocalDateTime());
            user.setUpdatedAt(resultSet.getTimestamp("updated_at").toLocalDateTime());
            return user;
        }
        
        return null;
        
    } catch (SQLException e) {
        throw new RuntimeException("Failed to find user", e);
    } finally {
        // 5. Close everything — in reverse order, checking for null
        try { if (resultSet != null) resultSet.close(); } catch (SQLException e) { }
        try { if (statement != null) statement.close(); } catch (SQLException e) { }
        try { if (connection != null) connection.close(); } catch (SQLException e) { }
    }
}

That is lines of code to execute one simple SELECT query. And this is just for reading one user. Imagine writing this for every query in your application — INSERT, UPDATE, DELETE, JOINs, pagination, filtering. It would be thousands of lines of boilerplate code.

The Problems with Raw JDBC

  1. Massive boilerplate — Connection management, statement preparation, result set processing, and resource cleanup for every single query.
  2. Manual mapping — You map every column to every field by hand. If a column name changes in the database, you must find and update every SQL string and mapping in your Java code.
  3. Error-prone — Forget to close a connection? Memory leak. Misspell a column name? Runtime error. Wrong parameter index? Wrong data.
  4. No type safety — SQL is written as strings. The compiler cannot check if your SQL is valid. Errors only appear at runtime.
  5. Database-specific SQL — If you switch from MariaDB to PostgreSQL, some SQL syntax may need to change. Your code is tied to a specific database.

We need a better approach. That approach is called ORM.


ORM Concepts — Object-Relational Mapping Explained

The Impedance Mismatch

Java and relational databases think about data in fundamentally different ways:

Java (Object-Oriented)Relational Database
Objects with fields and methodsRows with columns
Inheritance (class hierarchy)No native inheritance
References (object points to another object)Foreign keys (integer pointing to another table)
Collections (List, Set, Map)JOIN queries across tables
Identity (== and .equals())Primary key

This fundamental mismatch between object-oriented programming and relational databases is called the object-relational impedance mismatch. Bridging this gap manually (as we saw with JDBC) is tedious and error-prone.

What is ORM?

Object-Relational Mapping (ORM) is a technique that automatically converts data between Java objects and database tables. An ORM framework handles the mapping so you do not have to write it by hand.

The core idea is simple:

Java Class    ←→  Database Table
Object        ←→  Row
Field         ←→  Column
Reference     ←→  Foreign Key
Collection    ←→  JOIN Query

With an ORM, instead of writing SQL and manually mapping results, you work with Java objects directly:

// WITHOUT ORM (JDBC) — 40 lines of boilerplate
User user = findUserById(1L);  // Complex JDBC code

// WITH ORM (JPA/Hibernate) — one line
User user = entityManager.find(User.class, 1L);

The ORM framework generates the SQL, executes it, and maps the results back to Java objects — all automatically.

How ORM Maps Objects to Tables

You teach the ORM how to map your classes to tables using annotations:

@Entity                    // "This class maps to a database table"
@Table(name = "users")     // "The table is called 'users'"
public class User {

    @Id                    // "This field is the primary key"
    @GeneratedValue        // "Auto-increment the value"
    private Long id;

    @Column(name = "username")  // "Maps to the 'username' column"
    private String username;

    @Column(name = "email")
    private String email;
    
    // getters and setters...
}

With these annotations in place, the ORM can:

  • Generate CREATE TABLE SQL from your class definition
  • Generate INSERT INTO users (username, email) VALUES (?, ?) when you save an object
  • Generate SELECT * FROM users WHERE id = ? when you load an object
  • Generate UPDATE users SET username = ?, email = ? WHERE id = ? when you modify an object
  • Generate DELETE FROM users WHERE id = ? when you delete an object

You write Java. The ORM writes SQL. That is the deal.


JPA Specification vs Hibernate Implementation

This distinction confuses many beginners, so let us be very clear.

JPA — The Specification

JPA (Jakarta Persistence API) is a specification — a set of interfaces and annotations that define how ORM should work in Java. It is a document that says: “An ORM framework must provide these annotations, these interfaces, and this behavior.”

JPA itself does not contain any implementation code. It is like a blueprint. Key JPA interfaces and annotations include:

  • @Entity@Table@Column@Id — annotations for mapping
  • EntityManager — the interface for performing CRUD operations
  • JPQL (Java Persistence Query Language) — a query language for entities
  • CriteriaBuilder — a programmatic way to build queries

Hibernate — The Implementation

Hibernate is the most popular implementation of JPA. It is the actual library that does the work — generating SQL, managing connections, caching, lazy loading, and everything else.

Think of it this way:

  • JPA is like a Java interface — it defines what methods exist
  • Hibernate is like a Java class that implements that interface — it defines how those methods work
// Analogy:
// JPA is the interface
public interface PaymentGateway {
    void charge(double amount);
}

// Hibernate is the implementation
public class StripePaymentGateway implements PaymentGateway {
    public void charge(double amount) {
        // actual Stripe API calls
    }
}

Why This Matters

Because JPA is a specification, you can theoretically switch from Hibernate to another JPA implementation (like EclipseLink or OpenJPA) without changing your code. In practice, almost everyone uses Hibernate, and it is the default in Spring Boot.

When we write JPA annotations (@Entity@Column), we are using the standard. When Hibernate executes the queries, it is the implementation at work. Spring Boot auto-configures Hibernate as the JPA provider.

Spring Data JPA — The Spring Layer

On top of JPA and Hibernate, Spring adds another layer called Spring Data JPA. This further simplifies database access by providing:

  • Repository interfaces — You declare an interface, and Spring generates the implementation at runtime. No implementation code needed.
  • Derived query methods — Spring generates SQL queries from method names like findByEmail(String email).
  • Pagination and sorting — Built-in support with the Pageable interface.
  • Auditing — Automatic createdAt and updatedAt timestamps.

The stack looks like this:

Your Code
    ↓
Spring Data JPA  (Repository interfaces, derived queries, pagination)
    ↓
JPA              (Standard annotations and interfaces)
    ↓
Hibernate        (Actual ORM implementation, SQL generation)
    ↓
JDBC             (Low-level database communication)
    ↓
MariaDB Driver   (MariaDB-specific protocol)
    ↓
MariaDB Server

You will primarily work with the top two layers (your code and Spring Data JPA). The rest happens behind the scenes.


Adding Spring Data JPA & MariaDB Dependencies

Let us add the required dependencies to your pom.xml. Open the file and add these dependencies inside the <dependencies> section:

<dependencies>
    <!-- Existing dependency -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- NEW: Spring Data JPA — includes Hibernate, JPA, and Spring Data -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- NEW: MariaDB JDBC Driver — allows Java to communicate with MariaDB -->
    <dependency>
        <groupId>org.mariadb.jdbc</groupId>
        <artifactId>mariadb-java-client</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Existing test dependency -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Let us understand what each dependency brings:

spring-boot-starter-data-jpa is a starter that pulls in:

  • Spring Data JPA (repository interfaces, derived queries)
  • Hibernate (the ORM implementation)
  • Jakarta Persistence API (the JPA annotations)
  • HikariCP (a high-performance database connection pool)
  • Spring JDBC and Spring Transaction (low-level data access and transaction support)

mariadb-java-client is the MariaDB JDBC driver. It translates Java JDBC calls into the MariaDB wire protocol. The <scope>runtime</scope> means this library is only needed when the application runs, not when compiling your Java code (because your code only references JPA interfaces, not MariaDB-specific classes).

After adding these dependencies, reload your Maven project in IntelliJ (right-click on pom.xml → Maven → Reload Project) or run:

./mvnw dependency:resolve

Configuring the DataSource in application.yml

Now tell Spring Boot how to connect to your MariaDB database. Open src/main/resources/application.properties and add:

# ============================================
# DataSource Configuration
# ============================================
# The JDBC URL tells Spring how to connect to MariaDB.
# Format: jdbc:mariadb://HOST:PORT/DATABASE_NAME
spring.datasource.url=jdbc:mariadb://localhost:3306/blogdb

# The database user and password we created in Lecture 4
spring.datasource.username=bloguser
spring.datasource.password=blogpass

# The JDBC driver class — Spring Boot can auto-detect this from the URL,
# but being explicit avoids surprises
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver

# ============================================
# JPA / Hibernate Configuration
# ============================================
# DDL Auto strategy — controls how Hibernate manages the database schema.
# "update" means: compare entities to the database and apply changes.
# We will discuss all strategies in Section 8 of this lecture.
spring.jpa.hibernate.ddl-auto=update

# Show the SQL statements Hibernate generates (useful for learning/debugging)
spring.jpa.show-sql=true

# Format the SQL for readability (instead of one long line)
spring.jpa.properties.hibernate.format_sql=true

# The Hibernate dialect tells Hibernate which SQL variant to generate.
# MariaDB dialect ensures optimal SQL is generated for MariaDB.
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect

# ============================================
# HikariCP Connection Pool Configuration
# ============================================
# HikariCP is the default connection pool in Spring Boot.
# A connection pool reuses database connections instead of creating
# a new one for every query (which is expensive).
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=30000

What is a Connection Pool?

Creating a database connection is expensive — it involves TCP handshakes, authentication, and protocol negotiation. Doing this for every query would be extremely slow.

connection pool maintains a set of pre-created connections. When your code needs a connection, it borrows one from the pool. When it is done, the connection goes back to the pool instead of being closed.

Without pool:     Request → Create Connection → Query → Close Connection → Response
                  (100ms)       (5ms)     (100ms)

With pool:        Request → Borrow Connection → Query → Return Connection → Response
                  (0.1ms)        (5ms)      (0.1ms)

HikariCP is the default connection pool in Spring Boot 2+. It is the fastest Java connection pool available. The configuration above sets:

  • maximum-pool-size=10 — At most 10 connections can be active simultaneously
  • minimum-idle=5 — Keep at least 5 idle connections ready
  • connection-timeout=30000 — Wait at most 30 seconds for a connection before throwing an error

Verify the Connection

Start your application. If the configuration is correct, you should see in the console:

HikariPool-1 - Starting...
HikariPool-1 - Added connection org.mariadb.jdbc.Connection@xxxxx
HikariPool-1 - Start completed.

If you see an error like Communications link failure or Access denied, double-check:

  • MariaDB is running (docker ps should show the blogdb container)
  • The URL, username, and password match what you configured in Lecture 4
  • Port 3306 is not blocked by a firewall

Entity Mapping — @Entity, @Table, @Id, @GeneratedValue

Now the exciting part — mapping Java classes to database tables. We will transform our plain Java model classes into JPA entities.

What is an Entity?

An entity is a Java class that represents a database table. Each instance of the entity represents a row in that table. JPA uses annotations to define the mapping between the class and the table.

The Essential Annotations

Let us start with the User entity:

package com.example.blogapi.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

// @Entity marks this class as a JPA entity — it will be mapped to a database table.
// Without this annotation, JPA ignores the class completely.
@Entity

// @Table specifies which table this entity maps to.
// If omitted, JPA uses the class name as the table name ("User" → "user" table).
// We specify "users" explicitly because "user" is a reserved word in some databases.
@Table(name = "users")
public class User {

    // @Id marks this field as the primary key.
    // Every entity MUST have exactly one @Id field.
    @Id

    // @GeneratedValue tells JPA that the database generates this value.
    // GenerationType.IDENTITY means the database uses AUTO_INCREMENT.
    // This matches our MariaDB table definition: id BIGINT AUTO_INCREMENT PRIMARY KEY
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Other fields...
    private String username;
    private String email;

    // JPA requires a no-argument constructor (can be protected for encapsulation).
    // This is used internally by Hibernate to create instances via reflection.
    protected User() {
    }

    public User(String username, String email) {
        this.username = username;
        this.email = email;
    }

    // Getters and setters...
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

GenerationType Options

The @GeneratedValue annotation has four strategy options:

// IDENTITY — uses the database's auto-increment feature (MariaDB, MySQL, SQL Server).
// This is the best choice for MariaDB.
@GeneratedValue(strategy = GenerationType.IDENTITY)

// SEQUENCE — uses a database sequence (PostgreSQL, Oracle).
// Not commonly used with MariaDB (though MariaDB 10.3+ supports sequences).
@GeneratedValue(strategy = GenerationType.SEQUENCE)

// TABLE — uses a separate table to generate IDs.
// Portable but slow. Rarely used.
@GeneratedValue(strategy = GenerationType.TABLE)

// AUTO — lets the JPA provider choose the strategy based on the database.
// This is the default, but it can be unpredictable. Better to be explicit.
@GeneratedValue(strategy = GenerationType.AUTO)

For MariaDB, always use GenerationType.IDENTITY. It maps directly to AUTO_INCREMENT, which is efficient and well-understood.

The No-Argument Constructor Requirement

JPA requires every entity to have a no-argument constructor. Hibernate uses this constructor to create entity instances via reflection when loading data from the database.

You have two choices:

// Option 1: Public no-arg constructor (simple)
public User() { }

// Option 2: Protected no-arg constructor (better encapsulation)
// Only JPA/Hibernate can use this, not your application code
protected User() { }

Option 2 is preferred because it prevents accidental creation of entities without required fields. Your business code uses the parameterized constructor:

User user = new User("alice", "alice@example.com");  // Good — with required data
// User user = new User();  // Won't compile if constructor is protected (for code outside the package)

Entity Equals and HashCode

For JPA entities, you should implement equals() and hashCode() based on the primary key (not all fields):

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    User user = (User) o;
    // Use id for equality — two User objects represent the same row
    // if they have the same id
    return id != null && id.equals(user.id);
}

@Override
public int hashCode() {
    // Use a constant — this ensures consistency even before the id is assigned
    // (e.g., when the entity is new and id is still null)
    return getClass().hashCode();
}

Why a constant hashCode()? Because the id is null before the entity is saved. If hashCode() depends on id, it would change after saving, which breaks HashMap and HashSet behavior. Using the class hash code is safe — it just means all User objects end up in the same hash bucket, which is a minor performance trade-off for correctness.


Column Mapping — @Column, @Enumerated, @Temporal

@Column — Fine-Grained Column Mapping

By default, JPA maps each field to a column with the same name. Use @Column when you need to customize this mapping:

@Entity
@Table(name = "users")
public class User {

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

    // Map to a column named "username" — with constraints
    @Column(name = "username", nullable = false, unique = true, length = 50)
    private String username;

    // Map to a column named "email"
    @Column(name = "email", nullable = false, unique = true, length = 255)
    private String email;

    // "password_hash" uses snake_case in the database,
    // but "passwordHash" uses camelCase in Java.
    // @Column bridges this naming difference.
    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    @Column(name = "full_name", length = 100)
    private String fullName;

    // TEXT columns — use columnDefinition for database-specific types
    @Column(columnDefinition = "TEXT")
    private String bio;

    // Non-updatable column — JPA will include this in INSERT but not in UPDATE.
    // Perfect for created_at timestamps that should never change.
    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    // ...
}

Key @Column attributes:

AttributeDefaultDescription
namefield nameThe column name in the database
nullabletrueWhether the column allows NULL
uniquefalseWhether values must be unique
length255Maximum length for VARCHAR columns
updatabletrueWhether the column is included in UPDATE statements
insertabletrueWhether the column is included in INSERT statements
columnDefinitionRaw SQL column definition (e.g., “TEXT”, “DECIMAL(10,2)”)

Naming Strategy — camelCase to snake_case

You probably noticed that Java uses camelCase (e.g., fullName) while databases typically use snake_case (e.g., full_name). Spring Boot’s default naming strategy, SpringPhysicalNamingStrategy, automatically converts camelCase to snake_case:

// Java field:         fullName
// Generated column:   full_name   (automatic conversion!)

// Java field:         createdAt
// Generated column:   created_at  (automatic conversion!)

This means you often do not need @Column(name = "...") at all — the automatic conversion handles it:

// These two are equivalent when using Spring Boot's default naming strategy:
@Column(name = "full_name")
private String fullName;

// @Column not needed — Spring Boot automatically maps fullName → full_name
private String fullName;

Use @Column(name = "...") only when the column name does not follow the standard camelCase-to-snake_case pattern.

@Enumerated — Mapping Java Enums

Java enums are a common way to represent a fixed set of values (status, role, category). You can map them to database columns with @Enumerated:

// First, define the enum
public enum PostStatus {
    DRAFT,
    PUBLISHED,
    ARCHIVED
}

public enum UserRole {
    ROLE_USER,
    ROLE_AUTHOR,
    ROLE_ADMIN
}
@Entity
@Table(name = "posts")
public class Post {

    // EnumType.STRING stores the enum name as a string in the database.
    // The "status" column will contain "DRAFT", "PUBLISHED", or "ARCHIVED".
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private PostStatus status = PostStatus.DRAFT;

    // ...
}

There are two options for @Enumerated:

// STRING — stores the enum name: "DRAFT", "PUBLISHED", "ARCHIVED"
// RECOMMENDED: readable, safe if you reorder enum constants
@Enumerated(EnumType.STRING)
private PostStatus status;

// ORDINAL — stores the enum index: 0, 1, 2
// DANGEROUS: if you reorder or insert a new constant, existing data breaks!
@Enumerated(EnumType.ORDINAL)
private PostStatus status;

Always use EnumType.STRING. With ORDINAL, if you add a new status between DRAFT and PUBLISHED, all existing “PUBLISHED” rows (ordinal 1) would suddenly become the new status. This is a common and painful bug.

Temporal Annotations and Java Time API

With Java 8+ time types (LocalDateTimeLocalDateInstant), you do not need @Temporal — Hibernate maps them automatically:

// Java 8+ types — no @Temporal needed, Hibernate handles them natively
private LocalDateTime createdAt;   // → TIMESTAMP in MariaDB
private LocalDate birthDate;       // → DATE in MariaDB
private Instant lastLogin;         // → TIMESTAMP in MariaDB

@Temporal is only needed for the legacy java.util.Date and java.util.Calendar types:

// Legacy types (avoid in new code)
@Temporal(TemporalType.TIMESTAMP)
private Date createdAt;

@Temporal(TemporalType.DATE)
private Date birthDate;

Always use java.time types (LocalDateTimeLocalDateInstant) in new code. They are immutable, type-safe, and work with Hibernate without extra annotations.

@Transient — Excluding Fields from Mapping

Sometimes you have fields in your entity that should not be mapped to the database:

@Entity
@Table(name = "posts")
public class Post {

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

    // @Transient tells JPA to ignore this field completely.
    // It will not be stored in or loaded from the database.
    // Use it for calculated or temporary values.
    @Transient
    private int wordCount;

    public int getWordCount() {
        if (content == null) return 0;
        return content.split("\\s+").length;
    }
}

DDL Auto Strategies — create, update, validate, none

The spring.jpa.hibernate.ddl-auto property controls what Hibernate does with the database schema on startup. This is one of the most important settings to understand.

The Options

# CREATE — Drop all tables and recreate them every time the app starts.
# WARNING: ALL DATA IS LOST on every restart!
spring.jpa.hibernate.ddl-auto=create

# CREATE-DROP — Same as CREATE, but also drops tables when the app shuts down.
# Used for testing — the database is completely clean between test runs.
spring.jpa.hibernate.ddl-auto=create-drop

# UPDATE — Compare entity classes to the database and apply changes.
# Adds new tables and columns, but NEVER removes or renames anything.
# Safe for development — data is preserved.
spring.jpa.hibernate.ddl-auto=update

# VALIDATE — Compare entities to the database and throw an error if they don't match.
# Does NOT modify the database in any way.
# Used in production — ensures your code matches the schema.
spring.jpa.hibernate.ddl-auto=validate

# NONE — Do nothing. Hibernate ignores schema differences entirely.
spring.jpa.hibernate.ddl-auto=none

Which Strategy for Which Environment?

EnvironmentStrategyReason
DevelopmentupdateConvenient — Hibernate adds new columns/tables as you code
Testingcreate-dropClean database for every test run
Productionvalidate or noneNever let Hibernate modify production schema

Why “update” is Dangerous in Production

The update strategy seems convenient, but it has serious limitations:

  • It never drops columns or tables (even if you remove a field from your entity)
  • It never renames columns (if you rename a field, it creates a new column and leaves the old one)
  • It can create incorrect indexes or miss constraint changes
  • It runs on every startup, adding unpredictable latency
  • There is no rollback — if something goes wrong, you cannot undo the changes

In production, database schema changes should be deliberate, reviewed, tested, and reversible. This is why we will use Flyway (Lecture 10) for production migrations.

For now, during development, update is perfectly fine.

Seeing DDL in the Console

With spring.jpa.show-sql=true, you can see the SQL Hibernate generates:

Hibernate: 
    create table users (
        id bigint not null auto_increment,
        username varchar(50) not null,
        email varchar(255) not null,
        password_hash varchar(255) not null,
        full_name varchar(100),
        bio text,
        role varchar(20) not null,
        is_active boolean not null default true,
        created_at timestamp(6) not null,
        updated_at timestamp(6) not null,
        primary key (id)
    ) engine=InnoDB

This is incredibly useful for learning. You can see exactly how Hibernate translates your entity annotations into SQL.


Introduction to Spring Data Repositories — JpaRepository

This is where the real magic happens. Spring Data JPA lets you create a fully functional repository with zero implementation code.

The Repository Interface

Remember the UserRepository we wrote in Lecture 2? It was a @Repository class with methods like save()findById()findAll(), and deleteById(). We wrote all the code manually using an in-memory list.

With Spring Data JPA, you replace all of that with a single interface:

package com.example.blogapi.repository;

import com.example.blogapi.model.User;
import org.springframework.data.jpa.repository.JpaRepository;

// That's IT. No class, no implementation, no @Repository annotation needed.
// Spring Data JPA generates the implementation at runtime.
public interface UserRepository extends JpaRepository<User, Long> {
    // JpaRepository<User, Long> means:
    // - User is the entity type
    // - Long is the type of the primary key (id field)
}

By extending JpaRepository<User, Long>, you automatically get these methods without writing any code:

// CREATE / UPDATE
User save(User entity);                          // Save or update a user
List<User> saveAll(Iterable<User> entities);      // Save multiple users

// READ
Optional<User> findById(Long id);                 // Find by primary key
List<User> findAll();                              // Get all users
List<User> findAllById(Iterable<Long> ids);        // Find multiple by IDs
long count();                                      // Count total users
boolean existsById(Long id);                       // Check if user exists

// DELETE
void deleteById(Long id);                          // Delete by ID
void delete(User entity);                          // Delete by entity
void deleteAll();                                   // Delete all users

// PAGINATION & SORTING
Page<User> findAll(Pageable pageable);             // Find all with pagination
List<User> findAll(Sort sort);                     // Find all with sorting

That is over 15 methods for free. No SQL, no JDBC, no boilerplate.

The Repository Hierarchy

Spring Data provides several repository interfaces at different levels of abstraction:

Repository                    (marker interface — no methods)
    ↓
CrudRepository               (basic CRUD: save, findById, findAll, delete, count)
    ↓
ListCrudRepository            (same as CrudRepository but returns List instead of Iterable)
    ↓
PagingAndSortingRepository   (adds pagination and sorting)
    ↓
JpaRepository                (adds JPA-specific methods: flush, saveAndFlush, batch deletes)

Always extend JpaRepository — it includes everything from the layers above, plus JPA-specific features like flush() and saveAndFlush().

How Does Spring Generate the Implementation?

When your application starts, Spring Data JPA:

  1. Scans for interfaces that extend Repository (or its sub-interfaces)
  2. Creates a proxy class that implements the interface at runtime
  3. The proxy class uses Hibernate’s EntityManager to execute database operations
  4. Registers the proxy as a Spring bean so it can be injected like any other component

You never see this implementation — it is generated in memory. But conceptually, Spring creates something like this behind the scenes:

// This is what Spring generates (approximately) — you never write this!
public class UserRepositoryImpl implements UserRepository {

    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public Optional<User> findById(Long id) {
        User user = entityManager.find(User.class, id);
        return Optional.ofNullable(user);
    }

    @Override
    public User save(User user) {
        if (user.getId() == null) {
            entityManager.persist(user);  // INSERT
        } else {
            entityManager.merge(user);    // UPDATE
        }
        return user;
    }

    // ... all other methods ...
}

Using the Repository

Inject the repository into your service just like any other Spring bean:

@Service
public class UserService {

    private final UserRepository userRepository;

    // Spring injects the auto-generated implementation
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User createUser(User user) {
        return userRepository.save(user);  // Executes: INSERT INTO users ...
    }

    public User getUserById(Long id) {
        return userRepository.findById(id) // Executes: SELECT * FROM users WHERE id = ?
                .orElseThrow(() -> new RuntimeException("User not found with id: " + id));
    }

    public List<User> getAllUsers() {
        return userRepository.findAll();   // Executes: SELECT * FROM users
    }

    public void deleteUser(Long id) {
        userRepository.deleteById(id);     // Executes: DELETE FROM users WHERE id = ?
    }
}

Compare this to the 40-line JDBC code in Section 1. The service layer is clean, focused on business logic, and completely free of database boilerplate.


Hands-on: Connect the Blog API to MariaDB with JPA Entities

Let us replace our in-memory storage with a real MariaDB database. We will create JPA entities for the blog schema we designed in Lecture 4 and wire them to Spring Data repositories.

Step 1: Create the User Entity

File: src/main/java/com/example/blogapi/model/User.java

Replace the existing User.java with this JPA entity:

package com.example.blogapi.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "users")
public class User {

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

    // nullable = false → NOT NULL constraint in the database
    // unique = true → UNIQUE constraint (auto-creates an index)
    @Column(nullable = false, unique = true, length = 50)
    private String username;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    @Column(name = "full_name", length = 100)
    private String fullName;

    @Column(columnDefinition = "TEXT")
    private String bio;

    // Store the enum as a String in the database: "ROLE_USER", "ROLE_ADMIN", etc.
    @Column(nullable = false, length = 20)
    private String role = "ROLE_USER";

    @Column(name = "is_active", nullable = false)
    private boolean active = true;

    // updatable = false → Hibernate will never change this after initial insert
    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    // Lifecycle callback: called automatically before the entity is first saved
    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    // Lifecycle callback: called automatically before each update
    @PreUpdate
    protected void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    // JPA requires a no-arg constructor
    protected User() {
    }

    public User(String username, String email, String passwordHash) {
        this.username = username;
        this.email = email;
        this.passwordHash = passwordHash;
    }

    // --- Getters and Setters ---
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public String getPasswordHash() { return passwordHash; }
    public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }

    public String getFullName() { return fullName; }
    public void setFullName(String fullName) { this.fullName = fullName; }

    public String getBio() { return bio; }
    public void setBio(String bio) { this.bio = bio; }

    public String getRole() { return role; }
    public void setRole(String role) { this.role = role; }

    public boolean isActive() { return active; }
    public void setActive(boolean active) { this.active = active; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return id != null && id.equals(user.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }

    @Override
    public String toString() {
        return "User{id=" + id + ", username='" + username + "', email='" + email + "'}";
    }
}

Key new concepts in this entity:

  • @PrePersist and @PreUpdate — These are JPA lifecycle callbacks. @PrePersist runs before the first INSERT, and @PreUpdate runs before each UPDATE. We use them to automatically set createdAt and updatedAt timestamps without manual code in the service layer.
  • updatable = false on createdAt — This ensures Hibernate never includes created_at in UPDATE statements, even if the field is accidentally modified in Java code.

Step 2: Create the Category Entity

File: src/main/java/com/example/blogapi/model/Category.java

package com.example.blogapi.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "categories")
public class Category {

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

    @Column(nullable = false, unique = true, length = 100)
    private String name;

    @Column(nullable = false, unique = true, length = 100)
    private String slug;

    @Column(columnDefinition = "TEXT")
    private String description;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
    }

    protected Category() {
    }

    public Category(String name, String slug) {
        this.name = name;
        this.slug = slug;
    }

    // --- Getters and Setters ---
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getSlug() { return slug; }
    public void setSlug(String slug) { this.slug = slug; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public LocalDateTime getCreatedAt() { return createdAt; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Category category = (Category) o;
        return id != null && id.equals(category.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}

Step 3: Create the Post Entity

File: src/main/java/com/example/blogapi/model/Post.java

package com.example.blogapi.model;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "posts")
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Post {

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

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

    @Column(nullable = false, length = 500, unique = true)
    private String slug;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    @Column(length = 1000)
    private String excerpt;

    @Column(nullable = false, length = 20)
    private String status = "DRAFT";

    // @ManyToOne defines the relationship: many posts belong to one user.
    // This is the "many" side of a One-to-Many relationship.
    //
    // @JoinColumn specifies the foreign key column in the posts table.
    // "author_id" in the posts table references the "id" column in the users table.
    //
    // FetchType.LAZY means: do NOT load the User object when loading the Post.
    // The User is loaded only when you call post.getAuthor().
    // This prevents unnecessary database queries.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    @JsonIgnore  // Prevent infinite loop in JSON serialization (we will fix this with DTOs in Lecture 8)
    private User author;

    // Many posts can belong to one category
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    @JsonIgnore
    private Category category;

    @Column(name = "published_at")
    private LocalDateTime publishedAt;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    protected Post() {
    }

    public Post(String title, String slug, String content) {
        this.title = title;
        this.slug = slug;
        this.content = content;
    }

    // --- Getters and Setters ---
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }

    public String getSlug() { return slug; }
    public void setSlug(String slug) { this.slug = slug; }

    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }

    public String getExcerpt() { return excerpt; }
    public void setExcerpt(String excerpt) { this.excerpt = excerpt; }

    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }

    public User getAuthor() { return author; }
    public void setAuthor(User author) { this.author = author; }

    public Category getCategory() { return category; }
    public void setCategory(Category category) { this.category = category; }

    public LocalDateTime getPublishedAt() { return publishedAt; }
    public void setPublishedAt(LocalDateTime publishedAt) { this.publishedAt = publishedAt; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }

    // Helper methods for the API response (since we @JsonIgnore the entities)
    // These expose just the IDs so the client knows the relationships
    public Long getAuthorId() {
        return author != null ? author.getId() : null;
    }

    public Long getCategoryId() {
        return category != null ? category.getId() : null;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Post post = (Post) o;
        return id != null && id.equals(post.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}

The @ManyToOne annotation introduces entity relationships. We will cover all relationship types in depth in Lecture 7. For now, understand that:

  • @ManyToOne means: “Many posts belong to one user” and “Many posts belong to one category”
  • @JoinColumn(name = "author_id") means: “The foreign key column in the posts table is called author_id”
  • FetchType.LAZY means: “Do not load the related entity until I explicitly ask for it”

Step 4: Create the Repository Interfaces

File: src/main/java/com/example/blogapi/repository/UserRepository.java

package com.example.blogapi.repository;

import com.example.blogapi.model.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    // Spring Data JPA generates the implementation from the method name!
    // findByEmail → SELECT * FROM users WHERE email = ?
    Optional<User> findByEmail(String email);

    // findByUsername → SELECT * FROM users WHERE username = ?
    Optional<User> findByUsername(String username);

    // existsByEmail → SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)
    boolean existsByEmail(String email);

    // existsByUsername → SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)
    boolean existsByUsername(String username);
}

File: src/main/java/com/example/blogapi/repository/CategoryRepository.java

package com.example.blogapi.repository;

import com.example.blogapi.model.Category;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface CategoryRepository extends JpaRepository<Category, Long> {

    Optional<Category> findBySlug(String slug);

    boolean existsByName(String name);
}

File: src/main/java/com/example/blogapi/repository/PostRepository.java

package com.example.blogapi.repository;

import com.example.blogapi.model.Post;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface PostRepository extends JpaRepository<Post, Long> {

    // Find all posts with a specific status
    List<Post> findByStatus(String status);

    // Find all posts by a specific author
    List<Post> findByAuthorId(Long authorId);

    // Find a post by slug (for URL routing)
    Optional<Post> findBySlug(String slug);

    // Find posts whose title contains a keyword (case-insensitive)
    // "Containing" → LIKE '%keyword%'
    // "IgnoreCase" → case-insensitive comparison
    List<Post> findByTitleContainingIgnoreCase(String keyword);

    // Count posts by status
    long countByStatus(String status);
}

Step 5: Update the Post Service

File: src/main/java/com/example/blogapi/service/PostService.java

package com.example.blogapi.service;

import com.example.blogapi.model.Category;
import com.example.blogapi.model.Post;
import com.example.blogapi.model.User;
import com.example.blogapi.repository.CategoryRepository;
import com.example.blogapi.repository.PostRepository;
import com.example.blogapi.repository.UserRepository;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.List;

@Service
public class PostService {

    private final PostRepository postRepository;
    private final UserRepository userRepository;
    private final CategoryRepository categoryRepository;

    public PostService(PostRepository postRepository,
                       UserRepository userRepository,
                       CategoryRepository categoryRepository) {
        this.postRepository = postRepository;
        this.userRepository = userRepository;
        this.categoryRepository = categoryRepository;
    }

    public Post createPost(String title, String content, String excerpt,
                           Long authorId, Long categoryId) {
        // Validate and fetch the author
        User author = userRepository.findById(authorId)
                .orElseThrow(() -> new RuntimeException("Author not found with id: " + authorId));

        // Create the post
        String slug = title.toLowerCase()
                .replaceAll("[^a-z0-9\\s-]", "")
                .replaceAll("\\s+", "-");

        Post post = new Post(title, slug, content);
        post.setExcerpt(excerpt);
        post.setAuthor(author);

        // Set category if provided
        if (categoryId != null) {
            Category category = categoryRepository.findById(categoryId)
                    .orElseThrow(() -> new RuntimeException(
                            "Category not found with id: " + categoryId));
            post.setCategory(category);
        }

        // save() generates: INSERT INTO posts (title, slug, content, ...) VALUES (?, ?, ?, ...)
        return postRepository.save(post);
    }

    public Post getPostById(Long id) {
        // findById() generates: SELECT * FROM posts WHERE id = ?
        return postRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Post not found with id: " + id));
    }

    public List<Post> getAllPosts() {
        // findAll() generates: SELECT * FROM posts
        return postRepository.findAll();
    }

    public List<Post> getPostsByStatus(String status) {
        // findByStatus() generates: SELECT * FROM posts WHERE status = ?
        return postRepository.findByStatus(status);
    }

    public List<Post> searchPosts(String keyword) {
        // findByTitleContainingIgnoreCase() generates:
        // SELECT * FROM posts WHERE LOWER(title) LIKE LOWER('%keyword%')
        return postRepository.findByTitleContainingIgnoreCase(keyword);
    }

    public Post publishPost(Long id) {
        Post post = getPostById(id);
        post.setStatus("PUBLISHED");
        post.setPublishedAt(LocalDateTime.now());
        // save() on an existing entity generates: UPDATE posts SET ... WHERE id = ?
        return postRepository.save(post);
    }

    public void deletePost(Long id) {
        if (!postRepository.existsById(id)) {
            throw new RuntimeException("Post not found with id: " + id);
        }
        // deleteById() generates: DELETE FROM posts WHERE id = ?
        postRepository.deleteById(id);
    }
}

Step 6: Update the Post Controller

File: src/main/java/com/example/blogapi/controller/PostController.java

package com.example.blogapi.controller;

import com.example.blogapi.model.Post;
import com.example.blogapi.service.PostService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/posts")
public class PostController {

    private final PostService postService;

    public PostController(PostService postService) {
        this.postService = postService;
    }

    @PostMapping
    public ResponseEntity<Post> createPost(@RequestBody Map<String, Object> request) {
        String title = (String) request.get("title");
        String content = (String) request.get("content");
        String excerpt = (String) request.get("excerpt");
        Long authorId = Long.valueOf(request.get("authorId").toString());
        Long categoryId = request.get("categoryId") != null
                ? Long.valueOf(request.get("categoryId").toString())
                : null;

        Post created = postService.createPost(title, content, excerpt, authorId, categoryId);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @GetMapping
    public List<Post> getAllPosts(@RequestParam(required = false) String status) {
        if (status != null) {
            return postService.getPostsByStatus(status);
        }
        return postService.getAllPosts();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Post> getPostById(@PathVariable Long id) {
        try {
            return ResponseEntity.ok(postService.getPostById(id));
        } catch (RuntimeException e) {
            return ResponseEntity.notFound().build();
        }
    }

    @GetMapping("/search")
    public List<Post> searchPosts(@RequestParam String keyword) {
        return postService.searchPosts(keyword);
    }

    @PatchMapping("/{id}/publish")
    public ResponseEntity<Post> publishPost(@PathVariable Long id) {
        try {
            return ResponseEntity.ok(postService.publishPost(id));
        } catch (RuntimeException e) {
            return ResponseEntity.notFound().build();
        }
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deletePost(@PathVariable Long id) {
        try {
            postService.deletePost(id);
            return ResponseEntity.noContent().build();
        } catch (RuntimeException e) {
            return ResponseEntity.notFound().build();
        }
    }
}

Step 7: Test with curl

Make sure MariaDB is running (docker start blogdb), then start your Spring Boot application.

First, insert a test user (since we need an author to create posts). You can do this through the MariaDB shell or create a quick user endpoint:

# Insert a test user directly into the database
docker exec -it blogdb mariadb -u bloguser -pblogpass blogdb \
  -e "INSERT INTO users (username, email, password_hash, full_name, role, is_active, created_at, updated_at) VALUES ('alice', 'alice@example.com', 'temphash', 'Alice Johnson', 'ROLE_ADMIN', true, NOW(), NOW());"

# Insert a test category
docker exec -it blogdb mariadb -u bloguser -pblogpass blogdb \
  -e "INSERT INTO categories (name, slug, created_at) VALUES ('Tutorial', 'tutorial', NOW());"

Now test the API:

# Create a post (authorId=1 is Alice, categoryId=1 is Tutorial)
curl -X POST http://localhost:8080/api/posts \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Getting Started with Spring Data JPA",
    "content": "Spring Data JPA makes database access incredibly simple...",
    "excerpt": "Learn how to use Spring Data JPA with MariaDB",
    "authorId": 1,
    "categoryId": 1
  }'
# Get all posts
curl http://localhost:8080/api/posts

# Get a specific post
curl http://localhost:8080/api/posts/1

# Search posts
curl http://localhost:8080/api/posts/search?keyword=spring

# Publish a post
curl -X PATCH http://localhost:8080/api/posts/1/publish

# Get only published posts
curl http://localhost:8080/api/posts?status=PUBLISHED

Now restart the application and fetch the posts again:

# Stop and restart the application, then:
curl http://localhost:8080/api/posts

# The posts are STILL THERE! Data is persisted in MariaDB.

This is the moment — your data survives application restarts. The in-memory storage problem from Lectures 2 and 3 is solved.

Step 8: Observe the SQL

With spring.jpa.show-sql=true, watch the console as you make requests. You will see the SQL that Hibernate generates:

// POST /api/posts:
Hibernate: 
    insert into posts (author_id, category_id, content, created_at, excerpt, 
    published_at, slug, status, title, updated_at) 
    values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

// GET /api/posts:
Hibernate: 
    select p1_0.id, p1_0.author_id, p1_0.category_id, p1_0.content, 
    p1_0.created_at, p1_0.excerpt, p1_0.published_at, p1_0.slug, 
    p1_0.status, p1_0.title, p1_0.updated_at 
    from posts p1_0

// GET /api/posts/search?keyword=spring:
Hibernate: 
    select p1_0.id, ... 
    from posts p1_0 
    where upper(p1_0.title) like upper(?) escape ''

Study these SQL statements. They show you exactly what Hibernate is doing behind the scenes. This understanding will be invaluable when you need to optimize performance in Lecture 16.

Step 9: Exercises

  1. Create a UserController: Build a GET /api/users and GET /api/users/{id} endpoint that returns users from the database. Create a simple UserService that delegates to the UserRepository.
  2. Create a CategoryController: Build CRUD endpoints for categories (GETPOSTDELETE). Include a GET /api/categories/{slug} that finds a category by its slug.
  3. Add more derived query methods: In PostRepository, add:
    • findByAuthorIdAndStatus(Long authorId, String status) — find posts by a specific author with a specific status
    • findByTitleContainingIgnoreCaseOrContentContainingIgnoreCase(String titleKeyword, String contentKeyword) — search in both title and content
  4. Test data persistence: Create several posts through the API, restart the application, and verify all data is still there. Try deleting a post and restarting to confirm the delete is also persistent.
  5. Observe the N+1 problem: Add getAuthor().getUsername() to the post response (remove @JsonIgnore temporarily). Watch the SQL output — you will see an extra SELECT for each post’s author. This is the N+1 problem, which we will solve in Lecture 7.

Summary

This lecture was a turning point in the series — you moved from in-memory storage to a real, persistent database:

  • The JDBC problem: Raw JDBC requires massive boilerplate for every query — connection management, statement preparation, manual mapping, and resource cleanup.
  • ORM (Object-Relational Mapping): Bridges the gap between Java objects and database tables. You annotate classes with metadata, and the ORM handles SQL generation and result mapping.
  • JPA vs Hibernate: JPA is the specification (the blueprint), Hibernate is the implementation (the actual code). Spring Data JPA adds a convenience layer on top with auto-generated repositories.
  • Dependenciesspring-boot-starter-data-jpa brings in everything you need. mariadb-java-client is the JDBC driver.
  • DataSource configuration: Connection URL, credentials, HikariCP connection pool, Hibernate dialect, and DDL auto strategy.
  • Entity mapping@Entity marks a class for ORM, @Table specifies the table name, @Id marks the primary key, @GeneratedValue(strategy = GenerationType.IDENTITY) uses AUTO_INCREMENT.
  • Column mapping@Column customizes column properties, @Enumerated(EnumType.STRING) safely maps enums, @PrePersist/@PreUpdate handle automatic timestamps.
  • DDL Auto: Use update for development, validate or none for production. Never use update in production.
  • JpaRepository: Extend this interface and get 15+ CRUD methods for free. No implementation code needed. Spring generates it at runtime.

What is Next

In Lecture 6, we will explore CRUD operations with Spring Data JPA in depth — derived query methods, custom JPQL queries, native SQL queries, pagination, sorting, and auditing. You will learn how Spring generates SQL from method names and how to write complex queries when derived methods are not enough.


Quick Reference

ConceptDescription
JDBCLow-level Java API for database access (lots of boilerplate)
ORMTechnique that maps Java objects to database tables automatically
JPAJakarta Persistence API — the standard specification for ORM in Java
HibernateThe most popular JPA implementation
Spring Data JPASpring’s convenience layer — auto-generates repositories from interfaces
@EntityMarks a class as a JPA entity (mapped to a table)
@Table(name = "...")Specifies the database table name
@IdMarks the primary key field
@GeneratedValue(IDENTITY)Uses database AUTO_INCREMENT for ID generation
@ColumnCustomizes column mapping (name, nullable, unique, length)
@Enumerated(STRING)Maps a Java enum as a string in the database
@TransientExcludes a field from database mapping
@PrePersistLifecycle callback — runs before first INSERT
@PreUpdateLifecycle callback — runs before each UPDATE
@ManyToOneDefines a many-to-one relationship (e.g., many posts → one author)
@JoinColumnSpecifies the foreign key column
FetchType.LAZYLoad related entity only when accessed (recommended)
JpaRepository<T, ID>Interface providing full CRUD + pagination methods
ddl-auto=updateHibernate auto-applies schema changes (dev only)
ddl-auto=validateHibernate validates schema without changes (production)
HikariCPDefault high-performance connection pool in Spring Boot

Leave a Reply

Your email address will not be published. Required fields are marked *