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.

Step 4 of 5Spring Bootv4.1.2© Arthur Bernier Jrrewritten · verified9 code blocks773 words

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 demo project 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

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

INI
# 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=true

Start 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

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

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

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/:id returns one turtle, or 404 if the id doesn't exist
JAVA
    // 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 /turtles with a JSON body creates a turtle and returns it with its new id
JAVA
    // 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/:id with a JSON body changes a turtle's name and age
JAVA
    // 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/:id removes a turtle
JAVA
    // 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#


See also