Intro
Before we start with the tutorial, here are the versions of everything used, as of time of writing. If you’re working with a different version of spring boot and/or java, things might’ve changed. Please check the relevant docs first:
Java: 17
Spring boot parent: 3.2.5
Lombok: 1.18.24 (not necessary, but great for speedy prototyping)
We’ll setup a super basic library application. For the models we’ll have: books, authors and libraries. For the sake of keeping this short and sweet, lombok annotations will be used throughout to keep the code-base lean. If you don’t like using them, feel free to write everything out.
In terms of relations, we’ll define: authors have many books (Many to one) and libraries have many books (Many to one). More complex examples will be included in a separate tutorial, I really want to keep this one short, like a reference point.
If you find this reference is not enough, I’ll include all relevant links I can think of in the end paragraph.
Base setup
We’ll need to setup a basic spring boot application. Nothing fancy here, just use your favourite initialiser, or your IDE (IntelliJ can generate bootstrapped versions out of the box). The complete project will be shared on GitHub, so click here if you want to see the whole thing.
We’ll use an H2 in-memory database, to keep this as easy and fast as possible. Note that it is volatile, so a data.sql
seeder/migrator will be defined as well.
Although I usually create services in a DDD manner, this time the project structure will be as simple as:
├── HELP.md
├── mvnw
├── mvnw.cmd
├── pom.xml
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com
│ │ │ └── sudorambles
│ │ │ └── relationshipdemo
│ │ │ ├── RelationshipDemoApplication.java
│ │ │ ├── controllers
│ │ │ │ └── LibraryController.java
│ │ │ └── repository
│ │ │ ├── AuthorRepository.java
│ │ │ ├── BookRepository.java
│ │ │ ├── LibraryRepository.java
│ │ │ └── models
│ │ │ ├── Author.java
│ │ │ ├── Book.java
│ │ │ └── Library.java
│ │ └── resources
│ │ ├── application.yml
│ │ ├── data.sql
│ │ ├── static
│ │ └── templates
Here’s an overview of the pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.sudorambles</groupId>
<artifactId>relationshipDemo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>relationshipDemo</name>
<description>relationshipDemo</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Let’s get to the code
Tables and data
We need to create the schema.sql
file that will generate our tables on startup. It will contain three simple tables, see below:
CREATE SCHEMA rel_demo;
CREATE TABLE rel_demo.books
(
id INT NOT NULL PRIMARY KEY,
title VARCHAR(128) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE rel_demo.authors
(
id INT NOT NULL PRIMARY KEY,
first_name VARCHAR(128) NOT NULL,
last_name VARCHAR(128) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE rel_demo.libraries
(
id INT NOT NULL PRIMARY KEY,
city_name VARCHAR(128) NOT NULL,
is_open BIT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
Once you add the file, you can check if everything was applied correctly here -> http://localhost:8080/h2-console
We’ll skip adding dummy seed data for now, let’s setup our entities first.
Entities definition
The main flag here @Entity
, that goes straight to the persistence module and flags the class as a data-driven entity. All other annotations are lombok ones, for the sake of keeping this short.
Let’s have a look at the base entities without relationships, think basic spring boot stuff. I want us to have a solid starting point first.
Book
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "books", schema = "relDemo")
public class Book {
@Id
@Column(name = "id")
private int id;
@Column(name = "title")
private String title;
@Column(name = "created_at")
private Date createdAt;
@Column(name = "updated_at")
private Date updatedAt;
}
Author
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "authors", schema = "relDemo")
public class Author {
@Id
@Column(name = "id")
private int id;
@Column(name = "first_name")
private String firstName;
@Column(name = "last_name")
private String lastName;
@Column(name = "created_at")
private Date createdAt;
@Column(name = "updated_at")
private Date updatedAt;
}
Library
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "libraries", schema = "relDemo")
public class Library {
@Id
@Column(name = "id")
private int id;
@Column(name = "city_name")
private String cityName;
@Column(name = "is_open")
private boolean isOpen;
@Column(name = "created_at")
private Date createdAt;
@Column(name = "updated_at")
private Date updatedAt;
}
Note that we haven’t defined the relationships yet, and most importantly – there are no FK columns here. No book ids, no author ids etc. All of this will be handled with annotation processing in a bit.
Another note here: we need to add schema = "relDemo"
to instruct Jakarta where the entity is. That flag can be substituted with a generic declaration in application.yml
if you’re doing this in a real env. For the sake of the demo it’s sufficient as an additional entry here. If you don’t instruct your service which schema to use you’ll most likely get duplicated tables – one plain set (as seen in schema.sql
) and one set with the auto-generated magic.
Let’s add the relations. Only the newly added fields are included here:
Book
(...)
@ManyToOne
private Author author;
@ManyToOne
private Library library;
(...)
Author
(...)
@OneToMany(mappedBy = "author")
private List<Book> books;
(...)
Library
(...)
@OneToMany(mappedBy = "library")
private List<Book> books;
(...)
As you can see, the Book
class is the central link, as it both is created by an author and is contained in a library. Authors can have many books, and a library can have many books. But a single Book can only have one author (ignore the IRL implications for now), and can only exist in a single library. Think of the book as a book instance, not an actual single tangible object. We’re only tracking copies of the books here, not a single original for example.
Now re-run your application and check the DB console. DO NOT modify any of the original migrations in schema.sql
. You’ll see that the FK constraints and two new fields have been added to the books table. This is the thing that took me the longest to figure out. Jakarta takes care of the hard work of setting this up, I was used to writing out the migrations manually with all the FK constraints, and let me tell you that it’s not fun to debug this with the annotations.
Repositories
One last component we’ll need are repositories, this is a fairly standard spring boot approach. This is essential, as we’ll need to hook into JPA’s source. Create three repos like so:
public interface BookRepository extends JpaRepository<Book, Integer> {
}
// -----
public interface LibraryRepository extends JpaRepository<Library, Integer> {
}
// -----
public interface AuthorRepository extends JpaRepository<Author, Integer> {
}
Creating data
To test this application, we’ll need data. As I like to work with REST applications, let’s define a controller:
@RestController
@RequiredArgsConstructor
public class LibraryController {
private final BookRepository bookRepository;
private final AuthorRepository authorRepository;
private final LibraryRepository libraryRepository;
@PostMapping("/books")
public Book createBook(
@RequestBody Book book
) {
return bookRepository.save(book);
}
@GetMapping("/books")
public List<Book> getBooks() {
return bookRepository.findAll();
}
@PostMapping("/authors")
public Author createAuthor(
@RequestBody Author author
) {
return authorRepository.save(author);
}
@GetMapping("/authors")
public List<Author> getAuthors() {
return authorRepository.findAll();
}
@PostMapping("/libraries")
public Library createLibrary(
@RequestBody Library library
) {
return libraryRepository.save(library);
}
@GetMapping("/libraries")
public List<Library> getLibraries() {
return libraryRepository.findAll();
}
}
As you can see – super simple stuff here. A GET and a POST per entity. All POST requests expect a JSON body to be sent, to create the entry from. All GET requests return everything that the database contains. Ignore validation for now, this tutorial is not about that. Also ignore the direct use of repos in the controller, that’s a massive no-no, but again – this is not a “how to create a proper project”, we’re simply working with relations here.
Let’s call the endpoints. I’ve included example curl calls, for your convenience.
First, let’s test everything separately. Create an author:
curl --location 'localhost:8080/authors' --header 'Content-Type: application/json' --data '{
"id": 1,
"firstName": "Stephen",
"lastName": "King",
"createdAt": "2024-04-22T15:48:19Z",
"updatedAt": "2024-04-22T15:48:19Z"
}'
Next, create a library:
curl --location 'localhost:8080/libraries' --header 'Content-Type: application/json' --data '{
"id": 1,
"cityName": "New York",
"isOpen": true,
"createdAt": "2024-04-22T15:48:19Z",
"updatedAt": "2024-04-22T15:48:19Z"
}'
Lastly, create a book and set it’s author and library:
curl --location 'localhost:8080/books' --header 'Content-Type: application/json' --data '{
"id": 1,
"title": "The shining",
"author": {
"id": 1
},
"library": {
"id": 1
},
"createdAt": "2024-04-22T15:48:19.000+00:00",
"updatedAt": "2024-04-22T15:48:19.000+00:00"
}'
Nice, we have everything up and running. Erm, almost everything. Although the relationships work, if you call any of the three GET endpoints you’ll see the great wall of recursion which is absolutely hideous and application killing. Example:
GET http://localhost:8080/authors
[
{
"id": 1,
"firstName": "Stephen",
"lastName": "King",
"createdAt": "2024-04-22T15:48:19.000+00:00",
"updatedAt": "2024-04-22T15:48:19.000+00:00",
"books": [
{
"id": 1,
"title": "The shining",
"createdAt": "2024-04-22T15:48:19.000+00:00",
"updatedAt": "2024-04-22T15:48:19.000+00:00",
"author": {
"id": 1,
"firstName": "Stephen",
"lastName": "King",
"createdAt": "2024-04-22T15:48:19.000+00:00",
"updatedAt": "2024-04-22T15:48:19.000+00:00",
"books": [
{
"id": 1,
"title": "The shining",
"createdAt": "2024-04-22T15:48:19.000+00:00",
"updatedAt": "2024-04-22T15:48:19.000+00:00",
"author": {
(...)
So, what the hell is going on here? Recursion baby, a book has an author, which has a book, which has an author…
To solve this we need to introduce DTOs in our application. This is a standard abstraction layer that is used in loads of high-level applications. Without going into too much detail, a DTO is a transfer object. Your original entity mirrors the database/table structure, while the DTO mirrors the request/response layer of the application. Same thing, different layers of the application.
DTOs and fixing the recursion
The hard way
The hard way first, as always – let’s do this manually. Create three new DTO classes:
@Data
@Builder
public class AuthorDTO {
private int id;
private String firstName;
private String lastName;
private List<BookDTO> books;
private String createdAt;
private String updatedAt;
}
@Data
@Builder
public class BookDTO {
private int id;
private String title;
private Date createdAt;
private Date updatedAt;
}
@Data
@Builder
public class LibraryDTO {
private int id;
private String cityName;
private List<BookDTO> books;
private String createdAt;
private String updatedAt;
}
As you can see both the Library and Author objects have a list of books, as that’s exactly where we need it. When you call the GET /authors
endpoint, you want to see which books an author has written. Similarly, we need to be able to see which books a library has available. Inversely, we don’t really care to see which books are stored where, or written by whom. I’m aware that this is a design decision, and you might very well need that info. It’s out of scope for this tutorial and it can be solved in a different way, just ignore it for now.
Let’s remap the response from the repositories to DTOs. Change the following methods in the controller to:
(...)
@GetMapping("/books")
public List<BookDTO> getBooks() {
List<BookDTO> response = new ArrayList<>();
List<Book> books = bookRepository.findAll();
books.forEach(book -> {
BookDTO bookDTO = BookDTO.builder()
.id(book.getId())
.title(book.getTitle())
.createdAt(book.getCreatedAt())
.updatedAt(book.getUpdatedAt())
.build();
response.add(bookDTO);
});
return response;
}
(...)
@GetMapping("/authors")
public List<AuthorDTO> getAuthors() {
List<AuthorDTO> response = new ArrayList<>();
List<Author> authors = authorRepository.findAll();
authors.forEach(author -> {
AuthorDTO authorDTO = AuthorDTO.builder()
.id(author.getId())
.firstName(author.getFirstName())
.lastName(author.getLastName())
.createdAt(String.valueOf(author.getCreatedAt()))
.updatedAt(String.valueOf(author.getUpdatedAt()))
.build();
List<BookDTO> books = new ArrayList<>();
author.getBooks().forEach(book -> {
BookDTO bookDTO = BookDTO.builder()
.id(book.getId())
.title(book.getTitle())
.createdAt(book.getCreatedAt())
.updatedAt(book.getUpdatedAt())
.build();
books.add(bookDTO);
});
authorDTO.setBooks(books);
response.add(authorDTO);
});
return response;
}
(...)
@GetMapping("/libraries")
public List<LibraryDTO> getLibraries() {
List<LibraryDTO> response = new ArrayList<>();
List<Library> libraries = libraryRepository.findAll();
libraries.forEach(library -> {
LibraryDTO libraryDTO = LibraryDTO.builder()
.id(library.getId())
.cityName(library.getCityName())
.createdAt(String.valueOf(library.getCreatedAt()))
.updatedAt(String.valueOf(library.getUpdatedAt()))
.build();
List<BookDTO> books = new ArrayList<>();
library.getBooks().forEach(book -> {
BookDTO bookDTO = BookDTO.builder()
.id(book.getId())
.title(book.getTitle())
.createdAt(book.getCreatedAt())
.updatedAt(book.getUpdatedAt())
.build();
books.add(bookDTO);
});
libraryDTO.setBooks(books);
response.add(libraryDTO);
});
return response;
}
Now when you call the two endpoints you’ll get:
GET /authors
[
{
"id": 1,
"firstName": "Stephen",
"lastName": "King",
"books": [
{
"id": 1,
"title": "The shining",
"createdAt": "2024-04-22T15:48:19.000+00:00",
"updatedAt": "2024-04-22T15:48:19.000+00:00"
}
],
"createdAt": "2024-04-22 18:48:19.0",
"updatedAt": "2024-04-22 18:48:19.0"
}
]
GET /libraries
[
{
"id": 1,
"cityName": "New York",
"books": [
{
"id": 1,
"title": "The shining",
"createdAt": "2024-04-22T15:48:19.000+00:00",
"updatedAt": "2024-04-22T15:48:19.000+00:00"
}
],
"createdAt": "2024-04-22 18:48:19.0",
"updatedAt": "2024-04-22 18:48:19.0"
}
]
Much better, cleaner and functional. Don’t underestimate the impact of recurring relationship fetching – it can and will result in a stack overflow exception. I’ve made it happen, it’s not fun to debug, and good luck with your google search for “java stack overflow”.
The easier/cleaner way
We’ll use mapstruct to do the heavy lifting, and to hide the complexity. Remember, we’re not removing the mapping that we did by hand above, we’re just delegating it to a separate package.
Include mapstruct in your pom.xml
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
To make things work on startup we need to hook into maven’s build cycle. Add the following to the <build>
:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven.compiler.version}</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok.mapstruct.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
Add the following versions to the <properties>
<java.version>17</java.version>
<maven.compiler.version>3.8.1</maven.compiler.version>
<lombok.version>1.18.24</lombok.version>
<lombok.mapstruct.version>0.2.0</lombok.mapstruct.version>
<mapstruct.version>1.5.3.Final</mapstruct.version>
Let’s create the mappers. We’ll need one for each entity
@Mapper(
componentModel = "spring",
uses = {BookMapper.class}
)
public interface AuthorMapper {
@Mapping(target = "createdAt", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(target = "updatedAt", dateFormat = "yyyy-MM-dd HH:mm:ss")
AuthorDTO toDTO(Author author);
Author fromDTO(AuthorDTO authorDTO);
}
@Mapper(componentModel = "spring")
public interface BookMapper {
@Mapping(target = "createdAt", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(target = "updatedAt", dateFormat = "yyyy-MM-dd HH:mm:ss")
BookDTO toDTO(Book book);
@Mapping(target = "createdAt", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(target = "updatedAt", dateFormat = "yyyy-MM-dd HH:mm:ss")
Book fromDTO(BookDTO bookDTO);
}
@Mapper(
componentModel = "spring",
uses = {BookMapper.class}
)
public interface LibraryMapper {
@Mapping(target = "createdAt", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(target = "updatedAt", dateFormat = "yyyy-MM-dd HH:mm:ss")
LibraryDTO toDTO(Library library);
Library fromDTO(LibraryDTO libraryDTO);
}
And then update the methods in the controller to:
@PostMapping("/books")
public BookDTO createBook(
@RequestBody Book book
) {
return bookMapper.toDTO(bookRepository.save(book));
}
@GetMapping("/books")
public List<BookDTO> getBooks() {
return bookRepository.findAll()
.stream()
.map(bookMapper::toDTO)
.toList();
}
@PostMapping("/authors")
public AuthorDTO createAuthor(
@RequestBody Author author
) {
return authorMapper.toDTO(authorRepository.save(author));
}
@GetMapping("/authors")
public List<AuthorDTO> getAuthors() {
return authorRepository.findAll()
.stream()
.map(authorMapper::toDTO)
.toList();
}
@PostMapping("/libraries")
public LibraryDTO createLibrary(
@RequestBody Library library
) {
return libraryMapper.toDTO(libraryRepository.save(library));
}
@GetMapping("/libraries")
public List<LibraryDTO> getLibraries() {
return libraryRepository.findAll()
.stream()
.map(libraryMapper::toDTO)
.toList();
}
That’s all, run the application and marvel at the exact same response we saw above with our manual mapping
GET /authors
[
{
"id": 1,
"firstName": "Stephen",
"lastName": "King",
"books": [
{
"id": 1,
"title": "The shining",
"createdAt": "2024-04-22 18:48:19",
"updatedAt": "2024-04-22 18:48:19"
}
],
"createdAt": "2024-04-22 18:48:19",
"updatedAt": "2024-04-22 18:48:19"
}
]
Conclusion and links
That’s all, you now have a working application with ManyToOne relationships and automagic mapping done by mapstruct. The complete project is available here: https://github.com/MSarandev/spring-boot-rel-demo
Links to official documentation:
H2 docs
H2 connection modes
Spring relationships
Mapstruct
Lombok