Relationships between entities
A post's author is still stored as the string "anna". If Anna changes her name, all her posts need editing. Databases solve this with relationships: the posts table stores a user id (a foreign key), and the user lives in their own table. JPA describes relationships with annotations, and Hibernate joins the tables itself.
The user
// src/main/java/com/example/blog/User.java
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
protected User() {
}
public User(String username) {
this.username = username;
}
public Long getId() {
return id;
}
public String getUsername() {
return username;
}
}The table is called users because user is a reserved word in many databases. Build registration via POST /api/users with a username field the same way as creating a post: a NewUserRequest record, a UserRepository and a controller.
A post refers to its author
In Post the author field changes type from a string to User, and a list of comments appears:
// src/main/java/com/example/blog/Post.java
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private User author;
@OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE)
private List<Comment> comments = new ArrayList<>();| Annotation | Meaning |
|---|---|
@ManyToOne | Many posts per author; the posts table gets an author_id column |
fetch = FetchType.LAZY | The author is loaded from the database only when accessed |
@OneToMany(mappedBy = "post") | The other side: a list of comments; the key lives in comments.post_id |
cascade = CascadeType.REMOVE | Deleting a post deletes its comments too |
The comment
// src/main/java/com/example/blog/Comment.java
@Entity
@Table(name = "comments")
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 1000)
private String text;
@Column(nullable = false)
private Instant createdAt;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Post post;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private User author;
protected Comment() {
}
public Comment(String text, Post post, User author, Instant createdAt) {
this.text = text;
this.post = post;
this.author = author;
this.createdAt = createdAt;
}
// getters getId(), getText(), getCreatedAt(), getPost(), getAuthor()
}Hibernate creates tables with foreign keys:
Hibernate: create table comments (id bigint generated by default as identity, created_at timestamp(6) with time zone not null, text varchar(1000) not null, author_id bigint not null, post_id bigint not null, primary key (id))
Hibernate: alter table if exists comments add constraint FKh4c7lvsc298whoyd4w9ta25cr foreign key (post_id) references postsQueries through a relationship
A method name can walk through a relationship: findByAuthorUsernameOrderByCreatedAtDesc(String username) finds posts by the author's name — Spring joins posts and users itself. For a post's comments:
// src/main/java/com/example/blog/CommentRepository.java
public interface CommentRepository extends JpaRepository<Comment, Long> {
List<Comment> findByPostIdOrderByCreatedAt(Long postId);
}In NewPostRequest the author string becomes @NotNull Long authorId, and PostResponse.from takes the name via post.getAuthor().getUsername().
The lazy author is read from the database on first access, so the connection must stay open while the service method runs. Put the @Transactional(readOnly = true) annotation from the org.springframework.transaction.annotation package above the PostService and CommentService classes, and plain @Transactional above the methods that change data (create, update, delete, add). The transactions lesson explains this in detail.
A nested address
Comments belong to a post, so their address starts with the post's address:
// src/main/java/com/example/blog/CommentController.java
@RestController
@RequestMapping("/api/posts/{postId}/comments")
public class CommentController {
private final CommentService commentService;
public CommentController(CommentService commentService) {
this.commentService = commentService;
}
@GetMapping
public List<CommentResponse> list(@PathVariable Long postId) {
return commentService.findByPost(postId);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CommentResponse add(@PathVariable Long postId, @Valid @RequestBody NewCommentRequest request) {
return commentService.add(postId, request);
}
}CommentService.add finds the post and the author through the repositories, creates a Comment and saves it. Checking:
curl -X POST http://localhost:8080/api/posts/1/comments \
-H "Content-Type: application/json" -d '{"text":"Great post!","authorId":2}'
curl http://localhost:8080/api/posts/1/comments{"id":1,"text":"Great post!","author":"oleg","createdAt":"2026-10-04T08:59:23.244858700Z"}
[{"id":1,"text":"Great post!","author":"oleg","createdAt":"2026-10-04T08:59:23.244859Z"}]Common mistake
If a controller returns the Post entity itself, Jackson tries to read the lazy author after the database connection is already closed:
Could not write JSON: Could not initialize proxy [com.example.blog.User#1] - no sessionThe client gets a 500. That is why responses are built into the PostResponse and CommentResponse records inside the service, while the connection is still open.