Skip to content
CalliCoder

Spring Boot REST API Tutorial: MySQL, JPA, Hibernate

Published Updated Spring Boot 20 min read

Build a complete CRUD service from an empty project: MySQL configuration, a JPA entity with auditing, a repository, a controller, proper 404 handling, and the schema Hibernate generates underneath it.

Cross-section of four stacked layers, one request path threading down through them and back.

This is a Spring Boot REST API tutorial that starts from an empty directory and ends with a running CRUD service backed by MySQL. Every file is shown in full, so the Spring Boot REST API example here can be reconstructed exactly rather than inferred from fragments.

Written against Spring Boot 3.2, Java 17 and MySQL 8. The Jakarta package names below matter: Spring Boot 3 moved from javax.persistence to jakarta.persistence, and mixing the two is the single most common reason a project from an older tutorial will not compile.

What this Spring Boot REST API tutorial builds

A notes service with five endpoints:

MethodPathDoes
GET/api/noteslist, paginated
GET/api/notes/{id}fetch one, 404 if absent
POST/api/notescreate, 201 with Location
PUT/api/notes/{id}update
DELETE/api/notes/{id}delete, 204

Four layers, one class each: entity, repository, controller, exception handler. Spring Data supplies the persistence implementation, so there is no DAO to write.

Six layers of a Spring Boot GET request and the conversion each one performs.
What each layer converts on the way down. The response comes back through the same stack in reverse.

Nothing in the diagram is code you write: the whole middle of it is supplied, which is what makes the four classes below sufficient.

Creating the project

curl https://start.spring.io/starter.zip \
  -d dependencies=web,data-jpa,mysql,validation \
  -d javaVersion=17 -d bootVersion=3.2.0 \
  -d groupId=com.example -d artifactId=notes -d name=notes \
  -d packageName=com.example.notes -d type=maven-project \
  -o notes.zip && unzip notes.zip -d notes && cd notes

Four starters, and it is worth knowing what each drags in:

  • web — Spring MVC and an embedded Tomcat.
  • data-jpa — Spring Data JPA with Hibernate as the provider, plus HikariCP for pooling.
  • mysql — the MySQL Connector/J driver.
  • validation — Jakarta Bean Validation, so @Valid on a request body actually does something. Without it the annotations are silently inert, which is a genuinely confusing failure.
notes/
├── pom.xml
└── src/main/
    ├── java/com/example/notes/
    │   ├── NotesApplication.java
    │   ├── model/Note.java
    │   ├── repository/NoteRepository.java
    │   ├── controller/NoteController.java
    │   └── exception/
    │       ├── ResourceNotFoundException.java
    │       └── ApiExceptionHandler.java
    └── resources/application.properties

Configuring MySQL

CREATE DATABASE notes_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

utf8mb4 rather than utf8. MySQL’s utf8 is a three-byte subset that cannot store emoji or many CJK characters, and the failure shows up much later as truncated rows.

The same service in Kotlin is four compiler-plugin decisions away — worth reading first if that is the target language, because the JPA defaults fight Kotlin’s.

# src/main/resources/application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/notes_db?useSSL=false&serverTimezone=UTC
spring.datasource.username=${DB_USER:notes}
spring.datasource.password=${DB_PASSWORD:}

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jackson.serialization.write-dates-as-timestamps=false

Two of those deserve a warning rather than a shrug.

The ${DB_USER:notes} form reads an environment variable with a fallback, which is the minimum needed to keep credentials out of the repository; binding a whole group of settings to a typed object is covered under configuration properties.

ddl-auto=update is a development convenience and nothing more. Hibernate will add tables and columns to match your entities, but it never drops or narrows anything, so the schema drifts quietly. In production use validate and manage the schema with Flyway or Liquibase.

Never hardcode the password. The ${DB_PASSWORD:} syntax reads an environment variable and falls back to empty, which keeps the credential out of the repository.

The Note entity

package com.example.notes.model;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.Instant;

@Entity
@Table(name = "notes")
@EntityListeners(AuditingEntityListener.class)
@JsonIgnoreProperties(value = {"createdAt", "updatedAt"}, allowGetters = true)
public class Note {

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

    @NotBlank
    @Size(max = 200)
    private String title;

    @NotBlank
    @Column(columnDefinition = "TEXT")
    private String content;

    @CreatedDate
    @Column(nullable = false, updatable = false)
    private Instant createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private Instant updatedAt;

    protected Note() { }   // required by JPA, not for you to call

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

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
}

Points worth pausing on:

  • GenerationType.IDENTITY maps to MySQL AUTO_INCREMENT. It is the right choice here, though it does disable JDBC batch inserts, because Hibernate must round-trip to learn each generated id.
  • allowGetters = true on @JsonIgnoreProperties means the audit fields are serialised in responses but ignored in request bodies, so a client cannot forge a createdAt.
  • Instant, not Date. With write-dates-as-timestamps=false it serialises as ISO-8601.
  • The protected no-arg constructor is a JPA requirement. Keeping it non-public stops it being called by accident.

Auditing only runs if you switch it on:

@SpringBootApplication
@EnableJpaAuditing
public class NotesApplication {
    public static void main(String[] args) {
        SpringApplication.run(NotesApplication.class, args);
    }
}

Forget @EnableJpaAuditing and both timestamp columns stay null, then fail the nullable = false constraint on insert. The stack trace points at the column, not at the missing annotation.

The repository

package com.example.notes.repository;

import com.example.notes.model.Note;
import org.springframework.data.jpa.repository.JpaRepository;

public interface NoteRepository extends JpaRepository<Note, Long> { }

That is the entire persistence layer. Spring Data generates an implementation at startup with findAll, findById, save, deleteById, paging and sorting. Query methods derived from names — findByTitleContainingIgnoreCase — need no body either.

One entity is the simple case. The moment a second one references it, the mapping decisions start mattering — one-to-many covers which side owns the foreign key and the N+1 that follows from getting it wrong.

The controller

package com.example.notes.controller;

import com.example.notes.exception.ResourceNotFoundException;
import com.example.notes.model.Note;
import com.example.notes.repository.NoteRepository;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;

@RestController
@RequestMapping("/api/notes")
public class NoteController {

    private final NoteRepository repository;

    // Constructor injection — no @Autowired needed on a single constructor.
    public NoteController(NoteRepository repository) {
        this.repository = repository;
    }

    @GetMapping
    public Page<Note> list(Pageable pageable) {
        return repository.findAll(pageable);
    }

    @GetMapping("/{id}")
    public Note getOne(@PathVariable Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Note " + id + " not found"));
    }

    @PostMapping
    public ResponseEntity<Note> create(@Valid @RequestBody Note note, UriComponentsBuilder uri) {
        Note saved = repository.save(note);
        URI location = uri.path("/api/notes/{id}").buildAndExpand(saved.getId()).toUri();
        return ResponseEntity.created(location).body(saved);   // 201 + Location header
    }

    @PutMapping("/{id}")
    public Note update(@PathVariable Long id, @Valid @RequestBody Note incoming) {
        Note existing = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Note " + id + " not found"));
        existing.setTitle(incoming.getTitle());
        existing.setContent(incoming.getContent());
        return repository.save(existing);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        Note existing = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Note " + id + " not found"));
        repository.delete(existing);
        return ResponseEntity.noContent().build();            // 204
    }
}

Three habits this Spring Boot REST API example is deliberately showing:

Constructor injection, not @Autowired on a field. The dependency becomes final, the class is constructible in a unit test without a Spring context, and a missing bean fails at startup instead of producing a null at runtime.

Pageable as a method parameter. Spring resolves ?page=0&size=20&sort=createdAt,desc from the query string with no extra code. Returning every row from findAll() is fine with fifty notes and a problem with fifty thousand.

Correct status codes. 201 with a Location header on create, 204 on delete. Returning 200 with a body for everything is the most common flaw in a hand-written CRUD layer.

Returning a real 404

Without this, a missing id produces a 500 and a stack trace.

package com.example.notes.exception;

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) { super(message); }
}
package com.example.notes.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.stream.Collectors;

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    ProblemDetail notFound(ResourceNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail invalid(MethodArgumentNotValidException ex) {
        String detail = ex.getBindingResult().getFieldErrors().stream()
                .map(f -> f.getField() + " " + f.getDefaultMessage())
                .collect(Collectors.joining("; "));
        return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, detail);
    }
}

ProblemDetail is the RFC 7807 representation built into Spring 6, so the error body has a standard shape instead of an ad-hoc map:

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Note 99 not found"
}

Paging the collection endpoint

GET /api/notes as written returns every row. That is fine for a demo table and wrong the moment the table grows, because the cost of the endpoint scales with the data rather than with the request. Spring Data has paging built in, so this is a change of signature rather than a new mechanism.

JpaRepository already extends PagingAndSortingRepository, so findAll(Pageable) exists without you declaring it. Spring’s web support resolves a Pageable straight from the query string, so the controller only has to accept one:

@GetMapping
public Page<Note> list(@PageableDefault(size = 20, sort = "updatedAt",
                                        direction = Sort.Direction.DESC) Pageable pageable) {
    return noteRepository.findAll(pageable);
}
$ curl "http://localhost:8080/api/notes?page=0&size=5&sort=title,asc"

page is zero-based, size caps at 2000 by default, and sort takes a property name and an optional direction, repeated for multiple keys. Sort properties are entity field names, not column names — updatedAt, not updated_at.

Two things worth knowing before this reaches production. A Page runs a second query to compute the total element count, which is what lets a client render “page 3 of 47”; if the client only needs to know whether more rows exist, return a Slice instead and the count query disappears. And serialising Page straight to JSON hands the caller the shape of a Spring Data implementation class rather than a contract you control — newer Spring Boot versions warn about precisely that. Wrapping the result in a small response record of your own costs four lines and means a library upgrade cannot reshape your API.

The schema Hibernate generates

CREATE TABLE notes (
  id         BIGINT       NOT NULL AUTO_INCREMENT,
  title      VARCHAR(200) NOT NULL,
  content    TEXT         NOT NULL,
  created_at DATETIME(6)  NOT NULL,
  updated_at DATETIME(6)  NOT NULL,
  PRIMARY KEY (id)
) ENGINE=InnoDB;

Note the naming. Spring Boot’s default CamelCaseToUnderscoresNamingStrategy turns createdAt into created_at, and @Size(max = 200) became VARCHAR(200) — a validation annotation shaping DDL, which is convenient and occasionally surprising.

Running the example and testing the endpoints

./mvnw spring-boot:run
curl -i -X POST http://localhost:8080/api/notes \
  -H 'Content-Type: application/json' \
  -d '{"title":"First note","content":"Written from curl."}'
HTTP/1.1 201
Location: http://localhost:8080/api/notes/1

{"id":1,"title":"First note","content":"Written from curl.",
 "createdAt":"2026-08-23T09:14:22.481Z","updatedAt":"2026-08-23T09:14:22.481Z"}
curl -s 'http://localhost:8080/api/notes?page=0&size=5&sort=createdAt,desc'
curl -i http://localhost:8080/api/notes/99      # 404 ProblemDetail
curl -i -X POST http://localhost:8080/api/notes \
  -H 'Content-Type: application/json' -d '{"title":"","content":""}'   # 400, both fields

A slice test keeps the controller honest without starting a database:

@WebMvcTest(NoteController.class)
class NoteControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean NoteRepository repository;

    @Test
    void missingNoteReturns404() throws Exception {
        given(repository.findById(99L)).willReturn(Optional.empty());
        mvc.perform(get("/api/notes/99")).andExpect(status().isNotFound());
    }
}

@MockitoBean replaces the deprecated @MockBean from Spring Boot 3.4 onward.

Frequently asked questions

What does this Spring Boot REST API tutorial require?

JDK 17 or later, Maven (the wrapper is included) and a MySQL 8 instance. No IDE-specific setup.

Why will code from an older tutorial not compile?

Spring Boot 3 moved from javax.* to jakarta.*. Every javax.persistence and javax.validation import becomes jakarta.*, and the two cannot be mixed.

Do I need a service layer?

Not for CRUD this thin — it would only forward calls. Add one when there is real business logic, a transaction spanning several repositories, or an external call to coordinate.

Why is my @Valid annotation being ignored?

The validation starter is missing. Bean Validation annotations compile fine without it and simply never run, which makes this hard to spot.

Why are createdAt and updatedAt null?

@EnableJpaAuditing is missing from a configuration class. The listener is registered but never activated.

Is ddl-auto=update safe in production?

No. It only ever adds, so the schema drifts and destructive changes are never applied. Use validate with Flyway or Liquibase.

How do I add pagination?

Accept a Pageable parameter and return Page<Note> — see “Paging the collection endpoint” above. JpaRepository already provides findAll(Pageable).

Why does my paged endpoint run two queries?

Returning a Page triggers an extra count query so the response can carry totalElements and totalPages. Return a Slice instead if the client only needs to know whether another page exists.

Should I return the entity or a DTO?

A DTO, as soon as the entity has anything a client should not see or set. This example returns the entity for brevity and guards the audit fields with allowGetters.

Why do I get a LazyInitializationException?

The session closed before a lazy association was read. Fetch it explicitly with a join query or an entity graph rather than reaching for spring.jpa.open-in-view, which hides the problem and holds connections for the whole request.

How do I use PostgreSQL instead of MySQL?

Swap the driver dependency and the JDBC URL. Nothing in the entity, repository or controller changes — that portability is what JPA buys.

What is the difference between JPA and Hibernate?

JPA is the specification; Hibernate is the implementation Spring Boot ships by default. You program against JPA annotations, and Hibernate executes them.

Why is save() issuing a SELECT before the INSERT?

save() calls merge() when the entity has a non-null id, and merge must load the current row first. On a new entity with a null id it goes straight to persist().

How do I see the SQL Hibernate runs?

spring.jpa.show-sql=true with hibernate.format_sql=true. For production diagnostics prefer a logger on org.hibernate.SQL so the output goes through your logging pipeline.

What happens if a client sends an unknown sort property?

Spring Data cannot resolve it and throws PropertyReferenceException, which surfaces as a 500 rather than the 400 it should be — a caller typing ?sort=titel gets an error page blamed on your server. Either validate the property against an allow-list before building the Pageable, or map the exception to a 400 in the same @RestControllerAdvice that handles the 404 above.

Should the id be Long or long?

Long. A primitive cannot be null, so Hibernate cannot distinguish a new entity from one with id zero.