JAVA · STEP 4 · MODELS
Java · Spring Boot: models and migrations
Point Spring Boot 4.1 at Postgres, write a Jakarta Persistence entity and a Spring Data JPA repository, let Hibernate create the table, and expose full CRUD from a controller.
What you will learn#
- How to set the database settings
- How to create models and migrations
- How to use models to add, update, and delete records
Setup#
- We will keep using the
demoproject from this morning's lesson. - Add two dependencies to
pom.xml, inside<dependencies>: Spring Data JPA (the ORM layer) and the Postgres JDBC driver. No versions needed — the Spring Boot parent manages them.
pom.xml
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId></dependency><dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope></dependency>(Gradle: implementation 'org.springframework.boot:spring-boot-starter-data-jpa' and runtimeOnly 'org.postgresql:postgresql'. Or tick Spring Data JPA and PostgreSQL Driver on start.spring.io and copy the generated block.)
- Your editor will offer to reload the Maven project; accept, or run
./mvnw dependency:resolve.
WHY two dependencies: JPA (Jakarta Persistence) is the standard API for mapping classes to tables, Hibernate is the implementation Spring Boot ships, Spring Data JPA writes the repository code on top, and the driver is what actually speaks to Postgres.
Setting Up the Database Settings#
- Create a local Postgres database:
createdb javaspringtest
Open src/main/resources/application.properties and add the following. Use the username and password of your local Postgres.
src/main/resources/application.properties
# Database settings (Spring Boot picks the driver from the jdbc: URL)spring.datasource.url=jdbc:postgresql://localhost:5432/javaspringtestspring.datasource.username=test5spring.datasource.password=test5 # Hibernate schema management: none (default for Postgres), validate, update, create, create-drop.# update creates missing tables and columns on startup, which is what we want while learning.spring.jpa.hibernate.ddl-auto=update # Log the SQL Hibernate runs (handy today, noisy in production)spring.jpa.show-sql=trueStart the app. If the credentials are wrong it fails fast with a connection error; if they are right, nothing visible happens yet — Hibernate has nothing to create.
WHY no migrations today: with ddl-auto=update, Hibernate compares your entities to the database on every start and adds what is missing. That is enough for a lesson. Real projects set it to validate and manage schema changes with versioned SQL files through Flyway or Liquibase (spring-boot-starter-flyway), which Spring Boot runs automatically on startup — the equivalent of rails db:migrate.
Models and Migrations#
An entity is a class mapped to a table. Create an entities package in the code folder and inside it:
src/main/java/learning/java/demo/entities/Turtle.java
package learning.java.demo.entities; import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id; @Entity // maps this class to a table named turtlepublic class Turtle { // **************************************** // // Entity properties // **************************************** // @Id // this field is the primary key @GeneratedValue(strategy = GenerationType.IDENTITY) // Postgres identity column, assigned on insert private Long id; private String name; private Integer age; // **************************************** // // Constructors // **************************************** // // JPA needs a no-argument constructor to build instances from rows protected Turtle() { } public Turtle(String name, Integer age) { this.name = name; this.age = age; } // **************************************** // // Getters and setters (JPA, Jackson and you use these) // **************************************** // public Long getId() { return this.id; } public String getName() { return this.name; } public void setName(String name) { this.name = name; } public Integer getAge() { return this.age; } public void setAge(Integer age) { this.age = age; }}The imports are jakarta.persistence.* — the old javax.persistence names you'll see in pre-2022 tutorials do not work with Spring Boot 3 or 4. The table and column names are derived from the class and field names (turtle, id, name, age); @Table(name = "…") and @Column(name = "…") override that when you need to. Restart the app and look at the log: Hibernate issues create table turtle (…) for you.
Creating Our Repository#
The entity defines the shape of the data, like a Mongoose schema; the repository is what you call to query and save, like a Mongoose model — except you only declare an interface and Spring Data JPA generates the implementation at startup. Create a repositories package and inside it:
src/main/java/learning/java/demo/repositories/TurtleRepository.java
package learning.java.demo.repositories; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import learning.java.demo.entities.Turtle; // The two types are the entity and the type of its idpublic interface TurtleRepository extends JpaRepository<Turtle, Long> { // A derived query: Spring Data parses the method name and writes the SQL List<Turtle> findByName(String name); }JpaRepository gives you findAll(), findById(id) (returns an Optional), save(entity), existsById(id), deleteById(id), count() and paging/sorting overloads. Method names like findByAgeGreaterThan(int age) or findByNameContainingIgnoreCase(String part) are turned into queries for you.
That's it — time to make another controller and put this repository to work.
Using the Model#
In the controllers package create TurtleController.java.
To use the repository we receive it through constructor injection: declare a field, add a constructor parameter of the repository type, and Spring passes the implementation in when it creates the controller.
src/main/java/learning/java/demo/controllers/TurtleController.java
package learning.java.demo.controllers; import java.util.List; import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController; import learning.java.demo.entities.Turtle;import learning.java.demo.repositories.TurtleRepository; @RestController@RequestMapping("/turtles") // every route in this class starts with /turtlespublic class TurtleController { // Field to hold the repository; final because it never changes after construction private final TurtleRepository turtles; // Spring calls this constructor and supplies the repository (dependency injection) public TurtleController(TurtleRepository turtles) { this.turtles = turtles; } // GET /turtles @GetMapping public List<Turtle> index() { return turtles.findAll(); }}Start the app and GET localhost:8080/turtles — an empty array. Now the rest of the routes; each one needs its imports (PathVariable, PostMapping, PutMapping, DeleteMapping, RequestBody, ResponseStatus from org.springframework.web.bind.annotation; HttpStatus and ResponseEntity from org.springframework.http).
Show#
- GET
/turtles/:idreturns one turtle, or 404 if the id doesn't exist
// GET /turtles/:id @GetMapping("/{id}") public ResponseEntity<Turtle> show(@PathVariable Long id) { // findById returns Optional<Turtle>; ResponseEntity.of maps empty to 404 return ResponseEntity.of(turtles.findById(id)); }Create#
- POST
/turtleswith a JSON body creates a turtle and returns it with its new id
// POST /turtles @PostMapping @ResponseStatus(HttpStatus.CREATED) // 201 instead of the default 200 public Turtle create(@RequestBody Turtle newTurtle) { return turtles.save(newTurtle); // save returns the persisted entity, id filled in }Spring builds the Turtle from the JSON using the no-argument constructor and the setters. Leave id out of the body — the database assigns it, and save hands back the entity with the value filled in.
Update#
- PUT
/turtles/:idwith a JSON body changes a turtle's name and age
// PUT /turtles/:id @PutMapping("/{id}") public ResponseEntity<Turtle> update(@PathVariable Long id, @RequestBody Turtle fixTurtle) { // Find the turtle, copy the new values onto it, save, and wrap in a 200; // if the Optional is empty, answer 404 instead return turtles.findById(id) .map(turtle -> { turtle.setName(fixTurtle.getName()); turtle.setAge(fixTurtle.getAge()); return ResponseEntity.ok(turtles.save(turtle)); }) .orElseGet(() -> ResponseEntity.notFound().build()); }The map / orElseGet pair is how you work with an Optional without if (x.isPresent()); it is the same idea as ?. plus ?? in JavaScript.
Destroy#
- DELETE
/turtles/:idremoves a turtle
// DELETE /turtles/:id @DeleteMapping("/{id}") public ResponseEntity<Void> destroy(@PathVariable Long id) { if (!turtles.existsById(id)) { return ResponseEntity.notFound().build(); // 404 } turtles.deleteById(id); return ResponseEntity.noContent().build(); // 204, no body }Test your API: create a few turtles with POST ({"name": "Wilbert", "age": 5} and the header Content-Type: application/json), list them, fetch one, update one, delete one, and check that a made-up id gives 404. Then let's head into lab!
Resources to Learn More#
- Spring Boot: SQL databases —
spring.datasource.*, connection pools,spring.jpa.* - Database initialization —
ddl-auto,schema.sql, Flyway and Liquibase - Spring Data JPA: core concepts and query methods — what
JpaRepositorygives you and howfindBy…names become queries - Jakarta Persistence 3.2 — the specification behind
@Entity,@Id,@GeneratedValueand friends ResponseEntity— setting status codes and headers from a controller
See also