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
- Massive boilerplate — Connection management, statement preparation, result set processing, and resource cleanup for every single query.
- 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.
- Error-prone — Forget to close a connection? Memory leak. Misspell a column name? Runtime error. Wrong parameter index? Wrong data.
- No type safety — SQL is written as strings. The compiler cannot check if your SQL is valid. Errors only appear at runtime.
- 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 methods | Rows 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 TABLESQL 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 mappingEntityManager— the interface for performing CRUD operationsJPQL(Java Persistence Query Language) — a query language for entitiesCriteriaBuilder— 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
classthatimplementsthat 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
Pageableinterface. - Auditing — Automatic
createdAtandupdatedAttimestamps.
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.
A 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 simultaneouslyminimum-idle=5— Keep at least 5 idle connections readyconnection-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 psshould 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:
| Attribute | Default | Description |
|---|---|---|
name | field name | The column name in the database |
nullable | true | Whether the column allows NULL |
unique | false | Whether values must be unique |
length | 255 | Maximum length for VARCHAR columns |
updatable | true | Whether the column is included in UPDATE statements |
insertable | true | Whether the column is included in INSERT statements |
columnDefinition | — | Raw 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 (LocalDateTime, LocalDate, Instant), 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 (LocalDateTime, LocalDate, Instant) 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?
| Environment | Strategy | Reason |
|---|---|---|
| Development | update | Convenient — Hibernate adds new columns/tables as you code |
| Testing | create-drop | Clean database for every test run |
| Production | validate or none | Never 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:
- Scans for interfaces that extend
Repository(or its sub-interfaces) - Creates a proxy class that implements the interface at runtime
- The proxy class uses Hibernate’s
EntityManagerto execute database operations - 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:
@PrePersistand@PreUpdate— These are JPA lifecycle callbacks.@PrePersistruns before the first INSERT, and@PreUpdateruns before each UPDATE. We use them to automatically setcreatedAtandupdatedAttimestamps without manual code in the service layer.updatable = falseoncreatedAt— This ensures Hibernate never includescreated_atin 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:
@ManyToOnemeans: “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.LAZYmeans: “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
- Create a UserController: Build a
GET /api/usersandGET /api/users/{id}endpoint that returns users from the database. Create a simpleUserServicethat delegates to theUserRepository. - Create a CategoryController: Build CRUD endpoints for categories (
GET,POST,DELETE). Include aGET /api/categories/{slug}that finds a category by its slug. - Add more derived query methods: In
PostRepository, add:findByAuthorIdAndStatus(Long authorId, String status)— find posts by a specific author with a specific statusfindByTitleContainingIgnoreCaseOrContentContainingIgnoreCase(String titleKeyword, String contentKeyword)— search in both title and content
- 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.
- Observe the N+1 problem: Add
getAuthor().getUsername()to the post response (remove@JsonIgnoretemporarily). 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.
- Dependencies:
spring-boot-starter-data-jpabrings in everything you need.mariadb-java-clientis the JDBC driver. - DataSource configuration: Connection URL, credentials, HikariCP connection pool, Hibernate dialect, and DDL auto strategy.
- Entity mapping:
@Entitymarks a class for ORM,@Tablespecifies the table name,@Idmarks the primary key,@GeneratedValue(strategy = GenerationType.IDENTITY)uses AUTO_INCREMENT. - Column mapping:
@Columncustomizes column properties,@Enumerated(EnumType.STRING)safely maps enums,@PrePersist/@PreUpdatehandle automatic timestamps. - DDL Auto: Use
updatefor development,validateornonefor production. Never useupdatein 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
| Concept | Description |
|---|---|
| JDBC | Low-level Java API for database access (lots of boilerplate) |
| ORM | Technique that maps Java objects to database tables automatically |
| JPA | Jakarta Persistence API — the standard specification for ORM in Java |
| Hibernate | The most popular JPA implementation |
| Spring Data JPA | Spring’s convenience layer — auto-generates repositories from interfaces |
@Entity | Marks a class as a JPA entity (mapped to a table) |
@Table(name = "...") | Specifies the database table name |
@Id | Marks the primary key field |
@GeneratedValue(IDENTITY) | Uses database AUTO_INCREMENT for ID generation |
@Column | Customizes column mapping (name, nullable, unique, length) |
@Enumerated(STRING) | Maps a Java enum as a string in the database |
@Transient | Excludes a field from database mapping |
@PrePersist | Lifecycle callback — runs before first INSERT |
@PreUpdate | Lifecycle callback — runs before each UPDATE |
@ManyToOne | Defines a many-to-one relationship (e.g., many posts → one author) |
@JoinColumn | Specifies the foreign key column |
FetchType.LAZY | Load related entity only when accessed (recommended) |
JpaRepository<T, ID> | Interface providing full CRUD + pagination methods |
ddl-auto=update | Hibernate auto-applies schema changes (dev only) |
ddl-auto=validate | Hibernate validates schema without changes (production) |
| HikariCP | Default high-performance connection pool in Spring Boot |
