1. Коментарі як підресурс задачі
Коли ви вперше додаєте коментарі в трекер задач, рука новачка часто тягнеться до найпростішого варіанта: «а давайте просто додамо поле comment у задачу». Це нормально — мозок прагне зекономити енергію. Але саме в цей момент API починає непомітно розповзатися: з’являються списки коментарів, автори, час, видалення… і все це вже не схоже на одне поле.
Із статусом ми вже побачили перший патерн, що не вкладається в CRUD: поведінку можна виразити як зміну стану самої Task. Із коментарями так не вийде. Тут у даних з’являється свій маленький життєвий цикл — створити, показати списком, видалити — і тому чесніше виділити залежний підресурс, а не роздувати PATCH /tasks/{taskId}.
У нашому проєкті коментар — це допоміжний підресурс (supporting subresource) задачі. Він залежить від задачі й не існує «сам по собі» в межах публічного контракту. Водночас коментар не можна звести до рядка, тому що в нього є власна ідентичність (commentId), свій невеликий життєвий цикл (створити, показати списком, видалити) і власні поля, які керуються сервером (id, createdAt). Якщо ви спробуєте «впхнути» це всередину TaskDetailsResponse, отримаєте важку детальну відповідь, яка росте неконтрольовано й змушує клієнта постійно тягнути за собою коментарі, навіть коли він просто хотів переглянути статус і заголовок задачі.
Є ще одна практична причина: окремий endpoint для коментарів допомагає зберегти чисті межі відповідальності. PATCH /tasks/{taskId} відповідає за зміну стану самої задачі, зокрема й статусу, а коментарі живуть поруч, але окремо. У підсумку клієнту простіше зрозуміти контракт: щоб змінити задачу — PATCH задачі, щоб додати коментар — POST у колекцію коментарів.
Якщо провести зовсім побутову аналогію, то задача — це «картка справи», а коментарі — це «гілка обговорення під карткою». У гілки є повідомлення, у повідомлень — автори, час, іноді й видалення. І ніхто не хоче щоразу друкувати всю гілку на першій сторінці картки, коли треба лише перевірити дедлайн.
2. Контракт Comments API
Перед тим як писати код, корисно зробити паузу й проговорити контракт як договір. Саме тут багато помилок з’являються не через Java, а через неясні правила: де зберігається taskId, що повертаємо, коли коментарів немає, і як відрізнити «задачі немає» від «коментарів немає». Чим раніше ми це зафіксуємо, тим менше буде «а чому тут 404, а тут 200» у майбутньому.
Набір endpointʼів
Нам потрібен компактний набір операцій, який демонструє підресурсний підхід, але не перетворює коментарі на окремий світ. У канонічній версії Task Tracker API ми залишаємо три endpointʼи: список, створення, видалення. Ми свідомо не додаємо редагування коментаря і не робимо окремий ресурс верхнього рівня /api/v1/comments.
| Операція | HTTP | URI | Успіх | Якщо задачі немає |
|---|---|---|---|---|
| Список коментарів задачі | GET | /api/v1/tasks/{taskId}/comments | 200 OK + список | 404 Not Found |
| Створення коментаря | POST | /api/v1/tasks/{taskId}/comments | 201 Created + CommentResponse | 404 Not Found |
| Видалення коментаря | DELETE | /api/v1/tasks/{taskId}/comments/{commentId} | 204 No Content | 404 Not Found |
І тут є важливий нюанс: список коментарів повертає 200 OK навіть якщо коментарів нуль, але повертає 404 Not Found, якщо немає самої задачі. Це різні ситуації, і клієнту важливо вміти їх розрізняти.
Є ще одна технічна тонкість, яку легко пропустити в in-memory-сховищі. Репозиторій коментарів може повернути порожній список просто тому, що за taskId ще немає запису. За контрактом цього недостатньо: спочатку сервіс має переконатися, що сама задача існує, і лише потім трактувати [] як «коментарів поки що немає». Якщо батьківської задачі немає, назовні має піти 404 Not Found.
І ще одна домовленість про структуру відповіді. Для коментарів тут залишаємо звичайний JSON-масив: це невелика колекція, прив’язана до задачі, без власної метаінформації, а клієнт уже знає контекст за taskId в URI. Коли колекція перетворюється на глобальний пошук і, ймовірно, потребує метаданих, обгортка зазвичай вигідніша.
Щоб візуально закріпити ресурсну структуру, можна уявити її так:
flowchart TD
A["/api/v1/tasks"] --> B["/api/v1/tasks/{taskId}"]
B --> C["/api/v1/tasks/{taskId}/comments"]
C --> D["/api/v1/tasks/{taskId}/comments/{commentId} (DELETE)"]
taskId у path, а не в body
У запиті на створення коментаря ми не передаємо taskId у JSON. Причина проста: taskId уже є в URI, і якщо ви дозволите клієнту передавати taskId ще й у body, виникне конфлікт джерел істини. Що важливіше: шлях чи JSON? А якщо вони різні — що робити? Найкраща відповідь — взагалі не допускати такої ситуації.
Тобто наш контракт виглядає так: taskId — це адреса ресурсу (у path), а дані коментаря — це payload (у body).
Відповідь на створення
Тут можна зробити трохи «по-дорослому» і повернути Location на створений коментар. Але є нюанс: ми свідомо не робимо окремий GET /comments/{commentId}, тому посилання на детальний ресурс було б дивним. Тому для навчального проєкту ми обираємо простий і чесний варіант: 201 Created і тіло CommentResponse у відповіді.
Це виглядає передбачувано для клієнта, а контракт залишається компактним.
3. DTO і валідація коментарів
Коментарі здаються простими: «ну текст же». Але щойно ви робите API публічним контрактом, одразу спливають дві речі. Перша — вхід треба валідовувати, інакше до вас прилетять порожні рядки, гігабайтні есе й «просто пробіли». Друга — вихід має бути стабільним: клієнту потрібно бачити id, автора і час, інакше коментар не можна нормально відобразити, а потім видалити.
Request DTO
Клієнт надсилає лише те, чим він справді керує: authorName і text. Усе інше керується сервером. І так, «автор» у нас поки що просто рядок, без користувачів і security — ми в межах курсу тримаємо обсяг під контролем.
package com.example.tasktracker.api.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CommentCreateRequest(
// Імʼя автора задає клієнт (поки що без користувачів/аутентифікації)
@NotBlank
@Size(max = 80)
String authorName,
// Текст коментаря задає клієнт; обмежуємо розмір, щоб не прилітали «романи»
@NotBlank
@Size(max = 1000)
String text
) {
}
Зверніть увагу на @NotBlank: це не «не null», а саме «не порожньо і не пробіли». Для коментарів це важливіше, ніж здається.
Response DTO
Відповідь ми робимо зручною для UI. Зокрема, createdAt краще віддавати як Instant і серіалізувати в ISO-8601, щоб клієнт міг коректно показати час без «магії локалі».
package com.example.tasktracker.api.dto.response;
import java.time.Instant;
public record CommentResponse(
// id коментаря: генерується сервером
String id,
// id задачі: зручно для UI/клієнта, навіть якщо він «забув» початковий URL
String taskId,
// імʼя автора: зберігаємо у відповіді, щоб клієнт міг одразу показати в UI
String authorName,
// текст коментаря
String text,
// час створення: серверний, в UTC (ISO-8601)
Instant createdAt
) {
}
taskId у відповіді виглядає трохи надлишковим, адже він уже є в URL, але на практиці це часто спрощує клієнтський код, особливо якщо коментарі відображаються в UI-компонентах, які не «пам’ятають», звідки прийшов список.
4. Модель і in-memory-сховище
Ззовні API виглядає так, ніби «коментарі належать задачі». Отже, і зберігати їх зручно так, щоб операції «за задачею» були природними. Ми не використовуємо БД і JPA, тому наша мета — проста, читабельна in-memory-структура, яка підтримує потрібні операції без зайвої складності.
Domain model: Comment
Оскільки редагування коментаря ми не робимо, коментар можна вважати незмінним об’єктом. record тут виглядає доречно: він компактний і одразу показує структуру даних.
package com.example.tasktracker.domain.model;
import java.time.Instant;
public record Comment(
// id коментаря: генерується сервером
String id,
// батьківська задача: коментар живе лише в контексті taskId
String taskId,
// автор (поки що рядок)
String authorName,
// текст коментаря
String text,
// коли коментар створено (серверний час)
Instant createdAt
) {
}
Repository interface
Нам не потрібен «універсальний репозиторій на всі випадки життя». Потрібні три операції: отримати список за задачею, зберегти новий коментар, видалити коментар у контексті задачі.
package com.example.tasktracker.domain.repository;
import com.example.tasktracker.domain.model.Comment;
import java.util.List;
public interface CommentRepository {
// Повернути всі коментарі за задачею (порядок важливий для UI: зазвичай "у порядку додавання")
List
findAllByTaskId(String taskId); // Зберегти коментар, створений сервісом, і повернути збережений об'єкт Comment save(Comment comment); // Видалити коментар строго в контексті taskId: відповідає URI /tasks/{taskId}/comments/{commentId} boolean deleteByTaskIdAndId(String taskId, String commentId); }
Повертаємо boolean у delete — це простий спосіб зрозуміти, чи справді було видалено коментар. Якщо false, сервіс перетворить це на CommentNotFoundException.
In-memory реалізація
Для початківця важлива читабельність. Тому використовуємо структуру «за задачею зберігається мапа коментарів» і LinkedHashMap, щоб порядок був стабільним, зазвичай у порядку додавання.
package com.example.tasktracker.infrastructure.repository.inmemory;
import com.example.tasktracker.domain.model.Comment;
import java.util.LinkedHashMap;
import java.util.Map;
public class InMemoryCommentStore {
// taskId -> (commentId -> Comment); LinkedHashMap дає стабільний порядок обходу
final Map<String, Map<String, Comment>> commentsByTaskId = new LinkedHashMap<>();
}
Збереження коментаря робимо через computeIfAbsent, щоб не писати зайвий if і не боятися null.
import com.example.tasktracker.domain.model.Comment;
import java.util.LinkedHashMap;
public Comment save(Comment comment) {
commentsByTaskId
// Якщо для taskId ще немає "контейнера коментарів" — створюємо його один раз
.computeIfAbsent(comment.taskId(), id -> new LinkedHashMap<>())
// Кладемо/замінюємо коментар за commentId (у нашому сценарії це саме "створення")
.put(comment.id(), comment);
// Повертаємо збережений об'єкт: зручно для сервісу/контролера
return comment;
}
А отримання списку — це просто «взяти значення мапи», якщо вони є, інакше повернути порожній список. Порожній список важливий: він відповідає 200 із порожньою відповіддю, коли задача існує, але коментарів ще немає.
import com.example.tasktracker.domain.model.Comment;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public List<Comment> findAllByTaskId(String taskId) {
Map<String, Comment> taskComments = commentsByTaskId.get(taskId);
// Якщо під задачею ще не створювали коментарі — повертаємо порожній список, а не null
return taskComments == null ? List.of() : new ArrayList<>(taskComments.values());
}
5. CommentService: правила і перевірки
Сервісний шар — це місце, де ми поєднуємо «ресурсний контракт» і «предметні правила» проєкту. Тут не повинно бути HTTP, ResponseEntity та інших web-деталей. Зате тут мають бути перевірки, які роблять поведінку передбачуваною: задача має існувати, а видалити коментар можна лише всередині правильної задачі.
Ключовий інваріант тут простий: спочатку перевіряємо parent task, потім працюємо з коментарями. Без цього відсутня задача тихо перетвориться або на порожній список, або на COMMENT_NOT_FOUND, хоча за контрактом першим має спрацювати TASK_NOT_FOUND.
Винятки
Ми вже вміємо віддавати 404 як ProblemDetail. Для коментарів потрібно розрізняти дві ситуації: відсутня задача — тоді TASK_NOT_FOUND, і відсутній коментар у цій задачі — тоді COMMENT_NOT_FOUND. Клієнту це важливо: наприклад, UI може по-різному реагувати («задачу видалено» vs «хтось уже видалив коментар»).
package com.example.tasktracker.domain.exception;
public class CommentNotFoundException extends RuntimeException {
public CommentNotFoundException(String taskId, String commentId) {
super("""
Коментар '%s' не знайдено для задачі '%s'
""".formatted(commentId, taskId));
}
}
І одразу поруч корисно тримати маленький helper, який підтверджує існування батьківської задачі.
import com.example.tasktracker.domain.exception.TaskNotFoundException;
private void requireTask(String taskId) {
taskRepository.findById(taskId)
.orElseThrow(() -> new TaskNotFoundException(taskId));
}
З ним список коментарів починає точно відповідати обіцяному контракту:
import java.util.List;
public List<Comment> getComments(String taskId) {
requireTask(taskId);
return commentRepository.findAllByTaskId(taskId);
}
Тепер [] дійсно означає «у наявної задачі поки що немає коментарів», а не «ми взагалі не знайшли батьківський ресурс».
Створення коментаря
Коментар створюється лише після того, як ми переконалися, що задача існує. Інакше вийде «коментар до примари» — технічно він може лежати в пам’яті, але в API він не повинен існувати.
public Comment addComment(String taskId, String authorName, String text) {
requireTask(taskId);
return commentRepository.save(buildNew(taskId, authorName, text));
}
import com.example.tasktracker.domain.model.Comment;
import java.time.Instant;
import java.util.UUID;
public Comment buildNew(String taskId, String authorName, String text) {
return new Comment(
UUID.randomUUID().toString(),
taskId,
authorName,
text,
Instant.now()
);
}
Видалення коментаря
Наша операція видалення адресована як /tasks/{taskId}/comments/{commentId}. Отже, ми маємо видаляти саме «коментар усередині цієї задачі». Якщо commentId існує десь іще, це неважливо, навіть якщо це UUID і шанс дуже малий. У контексті цієї задачі — не знайдено, отже 404.
import com.example.tasktracker.domain.exception.CommentNotFoundException;
public void deleteComment(String taskId, String commentId) {
requireTask(taskId);
boolean deleted = commentRepository.deleteByTaskIdAndId(taskId, commentId);
if (!deleted) {
throw new CommentNotFoundException(taskId, commentId);
}
}
Порядок тут принциповий: якщо задачі немає, назовні має піти TaskNotFoundException. Лише коли батьківська задача існує, відсутність конкретного commentId перетворюється на CommentNotFoundException.
6. CommentController: тонкий web-рівень
Контролер — це «перекладач» між HTTP і сервісом. Він приймає path variables і request body, запускає валідацію (@Valid), викликає сервіс, а результат перетворює на response DTO. Секрет хорошого контролера в тому, що він ніколи не намагається стати «головним мозком» застосунку. Інакше у вас буде не Task Tracker API, а «контролери, які знають занадто багато».
Базовий mapping
Зверніть увагу, як читається @RequestMapping: він буквально показує, що коментарі — частина задачі. Це дуже допомагає і людині, і документації.
package com.example.tasktracker.api.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/tasks/{taskId}/comments")
public class CommentController {
}
List endpoint
Валідації body тут немає, бо body немає. Ми беремо taskId із path, викликаємо сервіс, мапимо доменну модель у CommentResponse. Сервіс спочатку підтверджує існування задачі, а вже потім повертає список коментарів. Інакше контролер чесно віддав би [] навіть для неіснуючого taskId, просто тому що in-memory store нічого не знайшов.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import java.util.List;
@GetMapping
public List<CommentResponse> list(@PathVariable String taskId) {
return commentService.getComments(taskId).stream()
.map(commentMapper::toResponse)
.toList();
}
Create endpoint
Тут ми запускаємо validation, а потім створюємо коментар. Статус — 201, бо ми створили новий ресурс, хай і supporting.
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@PostMapping
public ResponseEntity<CommentResponse> create(@PathVariable String taskId,
@Valid @RequestBody CommentCreateRequest request) {
Comment created = commentService.addComment(taskId, request.authorName(), request.text());
return ResponseEntity.status(201).body(commentMapper.toResponse(created));
}
Delete endpoint
Видалення не повертає тіло. Це нормальна й звична семантика: «зробили — і мовчки пішли». Якщо задачі немає або коментаря немає, знову отримаємо 404 через наш глобальний шар обробки помилок.
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
@DeleteMapping("/{commentId}")
public ResponseEntity<Void> delete(@PathVariable String taskId,
@PathVariable String commentId) {
commentService.deleteComment(taskId, commentId);
return ResponseEntity.noContent().build();
}
7. Перевірка через .http
Коли ви тільки навчаєтеся, дуже корисно руками «помацати» API: не лише happy path, а й негативні випадки. Це знімає половину магії. Нижче — мінімальні запити у форматі .http, які можна покласти в requests/comments.http і запускати прямо з IDE.
Створити коментар
### Create comment
POST http://localhost:8080/api/v1/tasks/{{taskId}}/comments
Content-Type: application/json
Accept: application/json
{
"authorName": "Alice",
"text": "Схоже, цю задачу варто робити до релізу, а не після."
}
Якщо все добре, очікуйте 201 Created і JSON-відповідь приблизно такого вигляду:
{
"id": "7fef6d2b-9a0e-4a7b-8f3f-0c1cc2d6d7c1",
"taskId": "3d6b0f8d-6b8a-4e38-9a8e-6f4f7c9a1c2b",
"authorName": "Alice",
"text": "Схоже, цю задачу варто робити до релізу, а не після.",
"createdAt": "2026-03-21T10:25:30Z"
}
Отримати список коментарів
### List comments
GET http://localhost:8080/api/v1/tasks/{{taskId}}/comments
Accept: application/json
Якщо коментарів немає, це все одно 200 OK, просто []. Це не помилка: це чесний «порожній список».
Видалити коментар
### Delete comment
DELETE http://localhost:8080/api/v1/tasks/{{taskId}}/comments/{{commentId}}
У разі успіху очікуйте 204 No Content без тіла відповіді.
Негативний сценарій: невалідний коментар
### Invalid comment (blank author)
POST http://localhost:8080/api/v1/tasks/{{taskId}}/comments
Content-Type: application/json
Accept: application/problem+json
{
"authorName": " ",
"text": ""
}
Очікувано буде 400 Bad Request із ProblemDetail і вашими fieldErrors — у тому форматі, який ви вже зафіксували в проєкті.
8. Типові помилки під час роботи з коментарями
Помилка №1: робити коментарі просто полем усередині TaskDetailsResponse і намагатися керувати ними через PATCH /tasks/{taskId}.
Спочатку це виглядає зручно: «один endpoint, один DTO». Але дуже швидко виходить комбайн, який і статус змінює, і заголовок патчить, і коментарі додає, і список коментарів тягне в детальну відповідь. Клієнту складно, серверу складно, тестувати боляче. Якщо в даних є невеликий життєвий цикл і власна ідентичність — підресурс зазвичай чесніший.
Помилка №2: вводити плоский /api/v1/comments без батьківського контексту.
Формально це теж варіант, але він ламає читання контракту: коментарі в нашому домені не живуть самі по собі. Плюс ви втрачаєте природну перевірку належності коментаря задачі. А ще ви змушуєте клієнта постійно носити taskId десь поруч і вручну підтримувати зв’язок.
Помилка №3: повертати 404 Not Found, коли задача існує, але коментарів немає.
Це дуже часта логічна помилка: «нічого не знайдено» плутають із «ресурсу не існує». Колекція коментарів існує як частина контракту задачі завжди, просто іноді вона порожня. Тому 200 і [] — це правильна семантика.
Помилка №4: приймати taskId і в path, і в body.
Так ви створюєте двозначність, яку потім доведеться розрулювати. У кращому разі ви постійно порівнюватимете значення й повертатимете 400. У гіршому — почнете випадково зберігати коментарі «не туди». У підресурсних endpointʼах taskId має жити в URI, а body — містити лише дані коментаря.
Помилка №5: не перевіряти належність commentId до taskId під час видалення.
Якщо ви всередині сервісу видаляєте «просто за commentId», ви порушуєте сенс URI. Клієнт викликав видалення коментаря всередині конкретної задачі, отже ви зобов’язані інтерпретувати запит саме так. У нашому проєкті правильна модель — deleteByTaskIdAndId(taskId, commentId) і 404, якщо в межах цієї задачі коментар не знайдено.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ