Almost every real application needs to remember things between restarts: users, orders, posts, settings. That is what a database is for. In this tutorial you will connect a Spring Boot app to a database and read and write data using Spring Data JPA. The goal is that by the end you can store Java objects and get them back without writing SQL by hand, and you understand what is happening under the surface.
What JPA and Spring Data are
Two names come up together and it helps to separate them. JPA, the Java Persistence API, is a standard way to map Java objects to database tables. It lets you treat rows in a table as ordinary Java objects. Spring Data JPA is a Spring library that sits on top of JPA and removes most of the repetitive code, giving you ready-made ways to save and query data. You will mostly interact with Spring Data JPA, and it uses JPA underneath.
The mental model is simple. A Java class becomes a table. An object of that class becomes a row. A field becomes a column. Once that mapping is set up, saving an object writes a row, and loading a row gives you back an object.
Adding the dependencies
You need two things: the Spring Data JPA starter, and a database driver. For learning, an in-memory database called H2 is perfect because it needs no installation and resets each time you run the app. In your project, include:
- spring-boot-starter-data-jpa: the JPA and Spring Data tools.
- The H2 database, as a runtime dependency.
With H2 on the classpath, Spring Boot automatically configures a database connection for you. You do not have to write connection code. For a real deployment you would swap H2 for something like PostgreSQL and provide connection details, but the rest of your code stays the same.
Creating an entity
An entity is a Java class that maps to a table. You mark it with @Entity, and you mark one field as the primary key with @Id. Here is a Book entity:
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
protected Book() { } // required by JPA
public Book(String title, String author) {
this.title = title;
this.author = author;
}
// getters and setters
}A few details matter here. The @Id marks the primary key, the unique identifier for each row. The @GeneratedValue with IDENTITY tells the database to assign the id automatically as rows are inserted, so you never set it yourself. JPA also requires a no-argument constructor, which is why there is an empty protected one; it is used internally when JPA rebuilds objects from rows.
You do not need to write a CREATE TABLE statement. In development, Spring Boot can create the table from your entity automatically. In production you would manage the schema with a migration tool, but for now the automatic creation lets you move fast.
Creating a repository
This is where Spring Data JPA saves you the most work. Instead of writing methods to save and fetch books, you declare an interface that extends JpaRepository, and Spring provides the implementation at runtime:
public interface BookRepository
extends JpaRepository {
} The two types in the angle brackets say: this repository manages Book entities, whose id is of type Long. That single empty interface gives you a surprising number of methods for free:
- save to insert or update a book.
- findById to load one book by its id.
- findAll to load every book.
- deleteById to remove a book.
- count to count the rows.
You never write the bodies of these methods. Spring builds them for you when the app starts. This is the heart of Spring Data: you describe what you want, and it supplies the how.
Using the repository
To use the repository, you ask Spring to give you one. The clean way is constructor injection, where you accept the repository as a constructor parameter and Spring passes it in:
@Service
public class BookService {
private final BookRepository repo;
public BookService(BookRepository repo) {
this.repo = repo;
}
public Book add(String title, String author) {
return repo.save(new Book(title, author));
}
public List all() {
return repo.findAll();
}
} When you call save, Spring Data writes a row and, because the id is generated, fills in the new id on the returned object. When you call findAll, it reads every row and turns each into a Book object. No SQL appears anywhere in your code.
Query methods from names
Sooner or later you need to find data by something other than the id, for example all books by a certain author. Spring Data has a clever feature: it can build a query from the method name. You simply declare the method in your repository interface:
public interface BookRepository
extends JpaRepository {
List findByAuthor(String author);
List findByTitleContaining(String text);
} Spring reads the method name and works out the query. findByAuthor searches the author column for an exact match. findByTitleContaining searches for titles that include the given text, useful for a search box. You wrote no query at all; the name was enough.
This works for many patterns: findBy, combined with And and Or, with OrderBy for sorting, and with keywords like GreaterThan or Between for ranges. When a name would get too complicated, you can fall back to writing a query yourself with the @Query annotation, but you will be surprised how far the naming approach takes you.
Understanding transactions
A transaction is a group of database operations that must all succeed together or not at all. Imagine transferring money: you subtract from one account and add to another. If the second step fails, you must undo the first, or money vanishes. Spring manages this for you with the @Transactional annotation:
@Service
public class TransferService {
@Transactional
public void transfer(Long from, Long to, double amount) {
// withdraw from one, deposit to the other
// if anything throws, both are rolled back
}
}If the method finishes normally, all changes are saved together. If it throws an exception, Spring rolls everything back, leaving the database as it was. For simple single saves you rarely need to think about this, but for multi-step operations it prevents half-finished states.
A common pitfall: the N+1 problem
As your data grows, one performance trap catches almost everyone. Suppose each book has a list of reviews, and you load all books and then loop through printing each book's reviews. Without care, the app runs one query to load the books, then one extra query per book to load its reviews. With a hundred books that is a hundred and one queries, and the page crawls.
The fix is to tell JPA to load the related data in one go, using a fetch join or an entity graph. You do not need the details yet, but it helps to know the name of the problem. If a page that loads a list is mysteriously slow, the N plus one problem is the first thing to check.
Putting it together
You now have the full loop: an entity that maps to a table, a repository that gives you save and query methods for free, and a service that uses them. Combine this with the REST API tutorial and you can build an endpoint that stores real data:
@RestController
@RequestMapping("/books")
public class BookController {
private final BookRepository repo;
public BookController(BookRepository repo) {
this.repo = repo;
}
@GetMapping
public List all() { return repo.findAll(); }
@PostMapping
public Book add(@RequestBody Book book) {
return repo.save(book);
}
} Now a POST to /books saves a book to the database, and a GET returns everything stored. Restart the app with a real database and the data is still there.
Where to go next
From here, three steps will round out your database skills. First, move from H2 to a real database like PostgreSQL by changing only the configuration. Second, add validation so bad data never reaches the database. Third, learn about relationships, such as an author having many books, which JPA models with annotations like @OneToMany. Each builds on the foundation you have here: objects in, rows out, with Spring Data doing the heavy lifting.