1. Дати в query-параметрах
Межу тут важливо тримати дуже чітко: ми все ще на боці web-зв’язування. Нас не цікавлять application.yaml і запуск застосунку — лише момент, коли рядок із URL має стати LocalDate в параметрі контролера.
Щойно сервіс стає трохи живішим, ніж «Hello, world», у ньому зʼявляються фільтри. І серед фільтрів майже завжди десь поруч стоїть дата: «покажіть курси, які запустилися після…», «покажіть події від…», «віддайте список замовлень за…». У цей момент початківець-розробник часто потрапляє в класичну пастку: у коді в нас нормальні Java-типи (LocalDate), а в запиті все надходить рядком, і цей рядок раптом починає жити за власними законами.
Давайте подивимося на типову картину на рівні користувача API. Він відкриває браузер і пише:
/api/catalog/courses?launchedAfter=01.04.2026
Бо люди саме так і пишуть дати, ну що ви. А ви, як Java-розробник, бачите LocalDate і думаєте: «Окей, зараз якось розпарсимо». І тут починається маленька трагікомедія.
Можна, звісно, зробити ось так — вручну:
import java.time.LocalDate;
import org.springframework.web.bind.annotation.RequestParam;
public LocalDate parseLaunchedAfter(@RequestParam(required = false) String launchedAfter) {
// Якщо параметр не надійшов — це нормальна ситуація для фільтра: повертаємо null
// LocalDate.parse(...) за замовчуванням очікує ISO: 2026-04-01
return launchedAfter == null ? null : LocalDate.parse(launchedAfter);
}
Формально це навіть працює, доки клієнт надсилає ISO (2026-04-01). Але щойно хтось прислав 01.04.2026, ви ловите DateTimeParseException, а ваш контролер перетворюється на місце, де «чомусь» треба писати обробку помилок, пояснення форматів і ще мільйон дрібних умов. Контролер у цей момент починає нагадувати не web-шар, а охоронця на вході до клубу: «у такому форматі не можна», «а в такому можна», «а де довідка?».
У нормальному Spring Boot-застосунку хочеться іншого відчуття: щоб контролер був коротким і типізованим, щоб він приймав LocalDate, а не String, і щоб формат дати був єдиним і передбачуваним для всіх кінцевих точок, а не «як вийде на цій машині сьогодні».
2. Як працює spring.mvc.format.*
Коли ми говоримо про форматування дат у MVC, важливо не відлітати в абстракції. Нам не потрібно знати всі внутрішні механізми Spring MVC, але потрібно чітко розуміти, що відбувається на практиці: рядок із URL має перетворитися на Java-тип параметра методу контролера. Це перетворення виконує MVC-механізм конвертації й форматування, який ми вже називали MVC ConversionService.
Зручно уявляти це як маленький конвеєр:
flowchart TD
A["HTTP-запит
... ?launchedAfter=2026-04-01"] --> B["Рядкове значення з query-параметра"]
B --> C["MVC ConversionService
(форматери/конвертери)"]
C --> D["LocalDate launchedAfter"]
D --> E["Ваш метод контролера"]
Властивості spring.mvc.format.* — це зручний для Boot спосіб сказати MVC: «Ось тобі глобальний формат для дат і часу, використовуй його під час web-зв’язування». Тобто ці налаштування впливають на:
- query-параметри (@RequestParam)
- path-змінні (@PathVariable)
- загалом на те, як MVC зв’язує рядкові значення запиту з Java-типами
І тут дуже важливо зрозуміти, що вони не впливають на інші «схожі» речі. Наприклад, вони не є налаштуванням для зв’язування конфігурації застосунку — це взагалі інша підсистема. Вони також не є «універсальним налаштуванням усього світу дат» у застосунку. Сьогодні ми налаштовуємо саме поведінку web-шару, яка вмикається тоді, коли до нас приходить HTTP-запит.
Тут достатньо втримати одну межу: spring.mvc.format.* налаштовує саме цей web-конвеєр. Зв’язування конфігурації застосунку живе окремо і до дат у query-параметрах стосунку не має. Нас зараз цікавить лише шлях «рядок із URL -> тип параметра контролера», і для temporal-типів у Boot є штатний механізм.
Отже, задача проста: домовитися, як launchedAfter виглядає зовні, і дати контролеру коректний LocalDate, а не змушувати його парсити рядки вручну.
3. ISO-формат дат через application.yaml
Для стандартних temporal-типів найспокійніший шлях — вибрати ISO-формат і один раз зафіксувати його в MVC-налаштуванні. Він виглядає машинно, зате стабільний, однозначний і майже не викликає суперечок — хіба що у тих, хто вважає, що 01.04.2026 — єдина істина. Для API це зазвичай хороший вибір: 2026-04-01. А ще він ідеально лягає на LocalDate без годин, хвилин і думок про часові пояси.
Додаємо налаштування spring.mvc.format.date
Додамо в src/main/resources/application.yaml (або в той YAML, який ви зараз використовуєте як базовий конфіг) такий блок:
spring:
mvc:
format:
# Глобально задаємо ISO-формат дат для MVC-зв’язування (query/path -> параметри контролера)
date: iso
Spring Boot враховує це налаштування під час збирання MVC-інфраструктури й впливає на те, як парситимуться дати в web-шарі. Найприємніше тут — нам не потрібно писати жодного Java-коду, жодного LocalDate.parse(...) у контролерах і жодної локальної самодіяльності. Ми просто один раз домовилися: «для дат — ISO», і сервіс став передбачуваним.
Робимо launchedAfter типізованим у контролері
Тепер контролер може чесно попросити LocalDate, а MVC сам виконає перетворення з рядка:
import java.time.LocalDate;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@GetMapping("/api/catalog/courses")
public List<CourseCard> findCourses(
// Фільтр необов’язковий: якщо параметр не передали, Spring покладе сюди null
@RequestParam(required = false) LocalDate launchedAfter) {
// У контролері не парсимо рядки — віддаємо типізоване значення далі в сервіс
return service.findCourses(launchedAfter);
}
Зверніть увагу на важливу дрібницю, яка береже нерви: required = false. Параметр фільтра — річ необов’язкова. Коли його немає, Spring передасть null, і це нормально. Ненормально — коли ми за замовчуванням очікуємо дату завжди, а потім дивуємося, чому запит без фільтра падає.
Фільтрація за датою в сервісі
У сервісі ми можемо зробити максимально читабельну фільтрацію. Оскільки проєкт навчальний і read-only, нам достатньо простої логіки в пам’яті. Головне — не тягнути це в контролер.
import java.time.LocalDate;
import java.util.List;
public List<CourseCard> findCourses(LocalDate launchedAfter) {
return repository.findAll().stream()
// launchedAfter == null => фільтр не задано, пропускаємо всі елементи
// isAfter(...) => курс має стартувати строго пізніше за вказану дату
.filter(c -> launchedAfter == null || c.launchDate().isAfter(launchedAfter))
.toList();
}
Тут немає нічого магічного: або фільтр не задано (launchedAfter == null), або курс має бути строго пізніше за вказану дату. Для LocalDate це читається природно. А найважливіше ось що: ця логіка працює лише тому, що контролер отримав уже готовий LocalDate, а не рядок, який ми парсимо «як вийде».
Перевіряємо запит
Тепер запит має вигляд:
GET /api/catalog/courses?launchedAfter=2026-04-01
І це хороший формат для API: його легко згенерувати програмно, легко прочитати очима, легко порівняти як рядок, і він майже не залежить від локалі комп’ютера, на якому працює клієнт.
Коли клієнт випадково надішле щось на кшталт launchedAfter=01.04.2026, MVC не зможе розпарсити дату й поверне помилку. Насправді це здорова поведінка: сервіс явно вимагає формат (контракт), і коли формат порушено — це помилка клієнта, а не привід для контролера перетворюватися на «вгадайку».
4. Формати часу в URL
Навіть якщо сьогодні ви використовуєте тільки LocalDate, корисно розуміти, що в Spring MVC є ще дві сусідні категорії: час і дата-час. У реальних сервісах вони зʼявляються дуже швидко, бо «після дати» зазвичай хочеться точніше: «після такого-то часу» або «після такого-то моменту». І коли ви заздалегідь знаєте, де це налаштовується, ви не шукатимете відповідь у логах, ніби це квест.
Spring Boot дає три ключі:
- spring.mvc.format.date — для дат (наприклад, LocalDate)
- spring.mvc.format.time — для часу (наприклад, LocalTime)
- spring.mvc.format.date-time — для дати-часу (наприклад, LocalDateTime)
Можна задати їх одразу за єдиним ISO-правилом:
spring:
mvc:
format:
# ISO для LocalDate
date: iso
# ISO для LocalTime
time: iso
# ISO для LocalDateTime
date-time: iso
ISO-формат для date-time
ISO для date-time виглядає приблизно так:
2026-04-01T10:15:30
І T тут — не забаганка Spring і не таємний знак масонів. Це частина ISO 8601, яка розділяє дату й час. Для URL це дуже зручно, бо там немає пробілу.
Чому пробіл — проблема? Бо в URL він може перетворюватися на + або на %20. А коли ви задаєте формат yyyy-MM-dd HH:mm, вам потрібно пам’ятати про URL-encoding, інакше MVC отримає не те, що ви очікуєте, і почнеться «а чому воно не парситься, я ж усе правильно написав».
Якщо вам справді потрібен кастомний формат, краще обирати той, що дружить із URL, наприклад із 'T':
spring:
mvc:
format:
# Кастомний шаблон для LocalDateTime: використовуємо 'T', щоб у URL не було пробілу
date-time: "yyyy-MM-dd'T'HH:mm"
Тут дві тонкощі. По-перше, лапки в YAML корисні, бо формат — це рядок із символами, які YAML може трактувати по-своєму. По-друге, 'T' у шаблоні означає «буквально літера T», а не якусь «магічну змінну часу».
Параметр LocalDateTime
Уявіть параметр, який приходить як «після такого-то моменту»:
import java.time.LocalDateTime;
import org.springframework.web.bind.annotation.RequestParam;
public void example(@RequestParam LocalDateTime changedAfter) {
// changedAfter вже типізований: парсинг і валідація формату відбуваються до входу в метод
}
І знову ж таки: головний сенс не в тому, що ми прямо зараз зобов’язані вводити LocalDateTime у проєкт. Сенс у тому, що у вас є єдиний і передбачуваний спосіб зробити web-зв’язування нормальним, без ручних парсерів і розмаїття форматів.
5. Точковий формат через @DateTimeFormat
Глобальне правило зручне, доки ви контролюєте контракт API і хочете, щоб сервіс був єдиним і зрозумілим. Але інколи життя підкидає цікаві умови: наприклад, ви інтегруєтеся з клієнтом, який уже історично шле дати у форматі dd.MM.yyyy, або ви робите сумісність зі старим UI. У таких випадках змінювати глобальне правило під один endpoint може бути занадто грубо — і тоді допомагає точкова анотація.
У Spring MVC для цього є @DateTimeFormat. Вона ставиться прямо на параметр і каже: «для цього конкретного параметра використовуй ось цей формат»:
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.RequestParam;
public void example(
@RequestParam(required = false)
// Точково задаємо формат лише для цього параметра (глобальне налаштування MVC не змінюється)
@DateTimeFormat(pattern = "dd.MM.yyyy")
LocalDate launchedAfter) {
}
Практична перевага тут у тому, що перетворення все одно робить MVC, а не ваш ручний код. Це означає, що поведінка буде єдиною: помилки формуватимуться стандартно, конвертація відбуватиметься до входу в метод, і ви не перетворите контролер на колекцію try/catch.
Але важливо не зловживати цим підходом. Коли кожна кінцева точка почне приймати дату у своєму форматі, ви отримаєте «зоопарк контрактів», і API стане складно використовувати й тестувати. Зазвичай ви або тримаєте глобальний ISO-формат, або дуже свідомо робите виняток в одному місці, а не розмазуєте його по проєкту.
6. Типові помилки під час налаштування spring.mvc.format.*
Дати й час у програмуванні — це як кіт: коли ставитися до них легковажно, вони обов’язково знайдуть спосіб вас подряпати. У MVC-шарі більшість проблем навіть не складні, а просто неочевидні: здається, що ви домовилися про формат, а насправді — домовилися лише у своїй голові. Нижче — помилки, які трапляються найчастіше і які особливо легко зробити на початку шляху.
Помилка №1: очікувати, що spring.mvc.format.* впливає на все без винятку, включно з будь-якими даними в застосунку.
Ці налаштування належать до MVC web-зв’язування, тобто до того, як значення з URL (query/path) перетворюються на параметри контролера. Вони не є універсальним налаштуванням усього, де є дата. Коли ви намагаєтеся лікувати ними, наприклад, поведінку іншого шару, ви просто будете крутити не ту ручку.
Помилка №2: продовжувати приймати дати як String і парсити їх вручну в контролері.
Це здається швидким рішенням, але воно розмазує формат по коду, ускладнює обробку помилок і перетворює контролер на шар ручного зв’язування. У нормальній MVC-моделі контролер просить LocalDate, а формат задається централізовано через конфігурацію. Так легше читати, легше підтримувати й важче випадково зламати.
Помилка №3: вибрати формат із пробілом для date-time і забути про URL-encoding.
Формат на кшталт yyyy-MM-dd HH:mm виглядає людяно, але для URL він підступний. Пробіл може стати +, може стати %20, а може бути оброблений по-різному різними клієнтами. Якщо вже потрібен кастомний формат, обирайте URL-friendly варіант (... 'T' ...) або використовуйте ISO.
Помилка №4: налаштувати лише spring.mvc.format.date, а потім дивуватися поведінці LocalTime і LocalDateTime.
Це часта психологічна пастка: «ми ж налаштували форматування». Насправді ви налаштували лише дати. Час і дата-час — окремі ключі, і коли ви починаєте приймати такі типи в параметрах контролера, краще явно зафіксувати й їхній формат теж, щоб не отримати сюрпризів на рівні локалі чи значень за замовчуванням.
Помилка №5: переплутати MVC binding і binding конфігурації застосунку.
Ми вже розділяли ці світи: MVC зв’язує запит із параметрами контролера, а зв’язування конфігурації — налаштування застосунку з Java-об’єктами. Коли ви чекаєте, що MVC-налаштування формату якимось чином «налаштує формат» для конфігурації застосунку, ви потрапляєте в режим «ніби схоже, але не працює». Це різні механіки.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ