Связи между сущностями
Автор статьи пока хранится строкой "anna". Если Анна сменит имя, придётся править все её статьи. В базах данных такие вещи решают связями: в таблице статей хранится номер пользователя (внешний ключ), а сам пользователь лежит в своей таблице. JPA описывает связи аннотациями, и Hibernate сам делает нужные соединения таблиц.
Пользователь
// 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;
}
}Таблица называется users, потому что user — зарезервированное слово во многих базах. Регистрацию через POST /api/users с полем username сделайте так же, как создание статьи: запись NewUserRequest, UserRepository и контроллер.
Статья ссылается на автора
В Post поле author меняет тип со строки на User, и появляется список комментариев:
// 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<>();| Аннотация | Смысл |
|---|---|
@ManyToOne | Много статей у одного автора; в таблице posts появится столбец author_id |
fetch = FetchType.LAZY | Автор загружается из базы, только когда к нему обратились |
@OneToMany(mappedBy = "post") | Обратная сторона: список комментариев; ключ хранится в comments.post_id |
cascade = CascadeType.REMOVE | При удалении статьи удаляются и её комментарии |
Комментарий
// 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;
}
// геттеры getId(), getText(), getCreatedAt(), getPost(), getAuthor()
}Hibernate создаст таблицы с внешними ключами:
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 postsЗапросы через связь
В имени метода можно пройти по связи: findByAuthorUsernameOrderByCreatedAtDesc(String username) найдёт статьи по имени автора — Spring сам соединит posts и users. Для комментариев к статье:
// src/main/java/com/example/blog/CommentRepository.java
public interface CommentRepository extends JpaRepository<Comment, Long> {
List<Comment> findByPostIdOrderByCreatedAt(Long postId);
}В NewPostRequest строка author заменяется на @NotNull Long authorId, а PostResponse.from берёт имя через post.getAuthor().getUsername().
Ленивый автор читается из базы при первом обращении, поэтому соединение должно быть открыто, пока работает метод сервиса. Добавьте над классами PostService и CommentService аннотацию @Transactional(readOnly = true) из пакета org.springframework.transaction.annotation, а над методами, которые меняют данные (create, update, delete, add), — просто @Transactional. Подробно разберём это в уроке о транзакциях.
Вложенный адрес
Комментарии принадлежат статье, поэтому их адрес начинается с адреса статьи:
// 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 находит статью и автора через репозитории, создаёт Comment и сохраняет его. Проверка:
curl -X POST http://localhost:8080/api/posts/1/comments \
-H "Content-Type: application/json" -d '{"text":"Отличная статья!","authorId":2}'
curl http://localhost:8080/api/posts/1/comments{"id":1,"text":"Отличная статья!","author":"oleg","createdAt":"2026-10-04T08:59:23.244858700Z"}
[{"id":1,"text":"Отличная статья!","author":"oleg","createdAt":"2026-10-04T08:59:23.244859Z"}]Частая ошибка
Если вернуть из контроллера саму сущность Post, Jackson попробует прочитать ленивого автора уже после закрытия соединения с базой:
Could not write JSON: Could not initialize proxy [com.example.blog.User#1] - no sessionКлиент получит 500. Поэтому ответы собираются в записи PostResponse и CommentResponse внутри сервиса, пока соединение открыто.