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.
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:
| Method | Path | Does |
|---|---|---|
GET | /api/notes | list, paginated |
GET | /api/notes/{id} | fetch one, 404 if absent |
POST | /api/notes | create, 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.
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@Validon 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.IDENTITYmaps to MySQLAUTO_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 = trueon@JsonIgnorePropertiesmeans the audit fields are serialised in responses but ignored in request bodies, so a client cannot forge acreatedAt.Instant, notDate. Withwrite-dates-as-timestamps=falseit serialises as ISO-8601.- The
protectedno-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.