1. Хардкод как скрытая конфигурация
Хардкод обычно появляется тихо и ласково: «давайте пока просто положим файлы в /tmp, потом вынесем». Потом проходит неделя, вы добавляете ещё пару фич, и внезапно понимаете, что “потом” — это уже маленькая легенда. Проблема хардкода не в том, что он “плохой стиль”, а в том, что он превращает параметры окружения в часть исходников.
Представьте самый невинный пример — локальное файловое хранилище вложений. Очень хочется написать так:
import java.nio.file.Path;
public class LocalAttachmentStorage {
// Хардкод: путь зашит в код и будет одинаковым во всех окружениях
private final Path root = Path.of("/tmp/task-tracker/uploads");
}
В этот момент вы, сами того не замечая, сделали три предположения. Первое — что приложение будет запускаться на системе, где есть /tmp (Windows смотрит на это с лёгкой растерянностью). Второе — что у процесса будет доступ на запись именно туда. Третье — что вам всегда подходит именно эта директория, даже если завтра вы захотите хранить файлы рядом с приложением, а не в системном temp.
Хардкод ещё и подрывает воспроизводимость. Если вы поменяли путь — вы меняете код и собираете новый артефакт (jar). Это значит, что поведение “приложение A в окружении B” зависит от того, какую ветку вы собрали и какие строки там вшиты. То есть конфигурация расползается по git-истории, и в какой-то момент вы начинаете отлаживать… не бизнес-логику, а археологию коммитов.
Есть отдельная боль с лимитами. Допустим, вы захардкодили “максимальный размер файла” как константу. На вашем ноутбуке это выглядит разумно. А потом коллега запускает проект в другом окружении, где reverse proxy ограничивает тело запроса, и вы ловите странные ошибки “иногда 413, иногда 400”. И это снова не про «плохой код», а про то, что лимиты — часть окружения.
2. Внешняя конфигурация Spring Boot
Слово “externalized configuration” в Spring Boot звучит как что-то из мира enterprise-магии, но смысл довольно приземлённый. Это способ сказать: «один и тот же код должен запускаться в разных условиях, а условия меняются без перекомпиляции». И вместо того чтобы править Java-классы, вы меняете значения в конфиге или окружении.
В Spring Boot конфигурация живёт в виде свойств (properties). У свойства есть ключ (например, app.attachments.storage-dir) и значение (например, ./data/attachments). Дальше эти значения могут приходить из нескольких источников: из application.yml, из профильных конфигов, из переменных окружения, из параметров запуска. Сегодня мы не будем углубляться в полный список источников (это легко превратить в отдельный курс), но нам важно понять идею: значение может быть “снаружи”, а код просто подхватывает его.
Ключевой эффект: если вы правильно отделили “параметры окружения” от “правил системы”, то ваш jar становится почти универсальным. Вы запускаете один и тот же артефакт с разными настройками, и он ведёт себя корректно для конкретной среды. Это как иметь один и тот же велосипед, но менять высоту сиденья под конкретного человека — велосипед остаётся велосипедом, а не превращается в другой транспорт.
3. Критерий: зависимость от окружения
Самый полезный навык дня — не запомнить «какие ключи есть у Spring», а научиться задавать правильный вопрос: это значение меняется из‑за окружения запуска или из‑за изменений в контракте/логике? Если меняется из‑за окружения — это очень вероятный кандидат в конфиг. Если из‑за логики — оставляем в коде.
Начнём с очевидного: путь к директории вложений. Он зависит от того, где вы запускаете приложение, какие у процесса права, что удобнее в локальной разработке, и как устроено окружение. В Task Tracker API это прямо прописано как обязательное свойство: app.attachments.storage-dir. Такое значение почти никогда не должно быть захардкожено. Даже если вам кажется, что «всем подходит /tmp», — это ловушка: вы просто ещё не запускали проект на достаточно “не вашей” машине.
Второй типичный кандидат — multipart limits. В Spring Boot они настраиваются через spring.servlet.multipart.max-file-size и spring.servlet.multipart.max-request-size. Почему это конфигурация, а не код? Потому что ограничения на размер запроса — это компромисс между UX, безопасностью и возможностями инфраструктуры. В dev‑окружении вы можете поставить лимиты побольше, чтобы было удобно тестировать. В более строгом окружении лимиты могут быть ниже. И вы точно не хотите “обновлять код”, чтобы дать возможность загрузить файл на 2MB вместо 10MB.
Третий пример из нашего курса — включение Problem Details в MVC. В проекте зафиксировано свойство spring.mvc.problemdetails.enabled=true. Это framework-level настройка: она управляет поведением Spring MVC в плане формирования problem responses. И это тоже конфигурация окружения/поведения платформы, а не предметная логика нашего Task Tracker’а.
Чтобы почувствовать разницу, полезно увидеть “канонический минимум” конфигурации, который относится к окружению:
spring:
mvc:
problemdetails:
# Включаем Problem Details на уровне Spring MVC (это настройка фреймворка)
enabled: true
servlet:
multipart:
# Ограничения на multipart — это параметры окружения/инфраструктуры
max-file-size: 10MB
max-request-size: 12MB
app:
attachments:
# Прикладная настройка: куда именно складывать вложения в этом окружении
storage-dir: ./data/attachments
Здесь spring.* — “управление фреймворком”, а app.* — “управление нашим приложением”. И да, если вы замечаете, что сейчас читаете YAML как книгу — это хороший знак: значит конфиг читаемый.
Небольшая таблица для интуиции
Иногда проще закрепить мысль визуально. Вот мини-таблица “кандидаты в конфиг”:
| Что меняется | Пример | Почти всегда конфиг? | Почему |
|---|---|---|---|
| Путь/директория | storage-dir | да | зависит от ОС, прав, структуры окружения |
| Лимиты размера | multipart max sizes | да | зависит от инфраструктуры и политики окружения |
| Включение/режим фреймворка | Problem Details enabled | да | это настройка MVC, не предметная логика |
| “Режим разработки” | logging.level | да | в dev хочется больше логов, чем в других средах |
4. Критерий: контракт API и доменные правила
На этом месте обычно происходит “перегиб маятника”. Студент слышит “выносим в конфиг”, вдохновляется и начинает выносить всё: длины строк, allowed values, переходы статусов… а потом удивляется, почему проект становится хрупким и непредсказуемым. Важная мысль: конфигурация не должна подменять собой дизайн контракта.
Посмотрим на правила длины title задачи: 3..120. Это часть входного контракта API. Клиент отправляет TaskCreateRequest, и мы обязаны одинаково валидировать его сегодня, завтра и через неделю. Если вы вынесете эти числа в YAML, то вы сделаете контракт “случайно изменяемым”: кто-то поменяет 120 на 200, потому что «так удобнее», и внешние клиенты внезапно получат другое поведение. Это уже не настройка окружения — это изменение API.
Вот пример того, что должно оставаться в коде (request DTO + validation):
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record TaskCreateRequest(
// Контракт API: поле обязательно и валидируется всегда одинаково (в любых окружениях)
@NotBlank
// Контракт API: ограничения длины — часть публичного поведения, а не “настройка”
@Size(min = 3, max = 120)
String title
) {}
То же самое касается структуры DTO (какие поля есть, какие типы, какие названия) — это ваш публичный контракт. Его нельзя “чуть-чуть поправить конфигом”. Контракт меняется через код, ревью, версионирование и осознанное решение. Иначе вы получаете ситуацию, когда приложение “ведёт себя по-разному” просто потому, что где-то лежит другой YAML. Для API это почти всегда плохо.
Отдельная зона — бизнес-правила, например переходы статуса задачи (TODO -> IN_PROGRESS, но не TODO -> ARCHIVED). Это не “настройки окружения”. Это логика предметной области, которой вы учите сервис. Если вы вынесете её в конфиг, вы получите две проблемы: во‑первых, теряется читаемость и типобезопасность; во‑вторых, это превращает доменную модель в “скрипт, который подгружается при старте”, и любой конфиг может случайно превратить Task Tracker в другой продукт.
Даже в простом виде видно, что такие правила живут именно в коде:
import java.util.Set;
public class TaskStatusRules {
public boolean canTransition(String from, String to) {
// Упрощённая модель: храним разрешённые переходы как строки
// (в реальном проекте лучше enum и явная модель переходов)
return Set.of("TODO->IN_PROGRESS", "DONE->ARCHIVED")
// Склеиваем "из" и "в" в ключ перехода и проверяем наличие в белом списке
.contains(from + "->" + to);
}
}
Этот пример нарочито упрощённый (в реальном проекте вы бы использовали enum и более аккуратную модель), но идея важнее: правило — это код, а не YAML. Если вы захотите изменить переходы — это изменение логики. Его нужно обсуждать так же, как изменение endpoint’а или DTO, а не “подкрутить конфиг”.
Ещё один контур, который лучше держать в коде, — набор error codes. Да, они “похожи на список строк”, но они часть внешней договорённости. Если сегодня вы возвращаете TASK_NOT_FOUND, а завтра в другом окружении из-за YAML вернёте TASK_MISSING — клиенту будет очень весело. То есть error codes — это не параметр окружения, а часть API.
5. Разделение spring.* и app.*
Когда проект маленький, возникает соблазн: «да какая разница, где лежит ключ, всё равно YAML один». Разница появляется ровно на том этапе, где мы сейчас: у проекта есть и framework-настройки (MVC, multipart), и прикладные настройки (наш storage-dir). Если всё смешать, YAML превращается в “мусорный ящик”, где сложно отличить «что настраивает Spring», а что «настраивает наше приложение».
Практика разделения проста: всё, что касается Spring Boot / Spring MVC / встроенных механизмов, остаётся под spring.*. Всё, что относится к нашему приложению (Task Tracker API) — под одним понятным префиксом, например app.*. Это делает конфигурацию читабельной даже для человека, который впервые открыл репозиторий: он видит “часть платформы” и “часть приложения”.
Ещё один плюс: когда вы позже будете искать “где у нас хранится путь до вложений”, вы не будете прыгать по YAML в поисках случайного ключа. Он будет там, где ему логически место: в app.attachments.*. Это экономит время на поддержку и снижает риск дублирования. А дублирование — это такой тихий враг, который сначала выглядит как “ну просто скопировал”, а потом превращается в “почему в одном месте 10MB, а в другом 12MB”.
6. Примеры: конфиг vs код в Task Tracker API
Сейчас сделаем самое полезное упражнение для мозга: посмотрим на несколько конкретных кусочков и скажем “конфиг или код”. Не потому что так “правильно по учебнику”, а потому что иначе проект начнёт вести себя странно.
Пример A: путь к директории вложений — конфигурация
Хардкод:
import java.nio.file.Path;
public class LocalAttachmentStorage {
// Хардкод: вшитый путь делает артефакт непереносимым
private final Path root = Path.of("/tmp/task-tracker/uploads");
}
Это кандидат в конфиг. Путь зависит от окружения.
Минимальная идея “вынести наружу” (пока без @ConfigurationProperties, это будет следующими лекциями дня):
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.file.Path;
@Component
public class LocalAttachmentStorage {
private final Path root;
public LocalAttachmentStorage(@Value("${app.attachments.storage-dir}") String storageDir) {
// Значение пришло “снаружи” (из application.yml/env/аргументов запуска)
this.root = Path.of(storageDir);
}
}
Смысл здесь не в том, что @Value — идеальная техника (она не идеальная, мы это позже поправим). Смысл в том, что код перестал принимать решение за окружение: теперь окружение говорит приложению, где хранить файлы.
Пример B: multipart limits — конфигурация
Лимиты запроса не должны жить в контроллере:
// Плохая идея: "проверим размер и вернём 400"
// 1) Это дублирует/конфликтует с тем, как Spring уже режет multipart на входе
// 2) Это превращает инфраструктурный лимит в часть кода и требует пересборки jar
if (file.getSize() > 10_000_000) {
throw new IllegalArgumentException("File is too big");
}
Во‑первых, это будет конфликтовать с тем, как Spring сам ограничивает multipart. Во‑вторых, это сделает лимит частью кода, хотя это параметр окружения. Правильнее управлять этим так:
spring:
servlet:
multipart:
# Лимит на один файл
max-file-size: 10MB
# Лимит на весь запрос целиком (файлы + поля формы)
max-request-size: 12MB
И важный момент: здесь удобно думать “парой”. Если max-request-size меньше max-file-size, вы создаёте логическое противоречие — файл “можно”, но весь запрос “нельзя”. Такое может работать странно и непредсказуемо (и точно будет плохо читаться человеком).
Пример C: правило длины title — часть контракта, остаётся в коде
Вот это не конфигурация окружения:
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record TaskCreateRequest(
// Контракт API: это поле не может быть пустым
@NotBlank
// Контракт API: диапазон длины зафиксирован как часть публичного поведения
@Size(min = 3, max = 120)
String title
) {}
Да, числа “меняются”, и теоретически вы могли бы вынести 3 и 120 в YAML. Но тогда вы сделаете контракт плавающим. А контракт должен быть стабильным и проверяемым на уровне кода, тестов и документации.
Пример D: включение Problem Details — конфигурация платформы
У нас error contract строится вокруг ProblemDetail, а Spring MVC умеет с ним работать “из коробки”, если включить соответствующую настройку:
spring:
mvc:
problemdetails:
# Настройка поведения Spring MVC, не “логика нашего приложения”
enabled: true
Это не “логика приложения”, это переключатель поведения MVC. Поэтому это живёт в конфигурации.
Небольшая блок-схема для принятия решения
Иногда удобно иметь “детектор” прямо в голове:
flowchart TD
%% Быстрая ментальная проверка: значение — это “окружение” или “контракт/логика”?
A["Есть значение, которое хочется 'положить куда-то'"] --> B{"Зависит от окружения запуска?"}
B -->|Да| C["Конфигурация (application.yml / env var)"]
B -->|Нет| D{"Это часть API-контракта или доменных правил?"}
D -->|Да| E["Код (DTO/validation/service rules)"]
D -->|Нет| F["Скорее всего константа в коде или refactor"]
Не идеальный детектор, но он спасает от двух крайностей: “всё хардкодим” и “всё выносим в YAML”.
7. Типичные ошибки при выборе: конфиг или код
В этой теме ошибки особенно коварны: они не всегда ломают проект сразу. Иногда всё работает, пока вы не попробуете запустить приложение на другой машине или пока не появится второй разработчик (то есть почти сразу после первой победы). Поэтому ошибки здесь полезно знать заранее, как дорожные знаки: чтобы не учиться на собственных шишках, которые потом сложно “откатить”.
Ошибка №1: выносить в конфиг правила API-контракта и домена.
Самый частый перегиб — “раз значения могут меняться, вынесем всё”. В итоге ограничения валидации, allowed status transitions или даже формат response начинают зависеть от YAML. Это превращает API в объект, который случайно меняет поведение между окружениями. Правильный вопрос перед выносом: это про окружение (путь, лимит, включатель фреймворка) или про смысл/контракт (валидация, DTO, доменные запреты)?
Ошибка №2: оставлять абсолютные пути в коде или в base-конфиге.
Абсолютный путь вроде /home/alex/uploads выглядит удобно ровно одному человеку — Алексу. В учебном проекте базовый конфиг должен быть переносимым, поэтому лучше относительный путь (./data/attachments) и возможность переопределить его в окружении. Абсолютные пути допустимы как override для конкретной машины, но не как “истина по умолчанию”.
Ошибка №3: путать framework settings и project settings, складывая всё под spring.*.
Иногда из лени хочется написать spring.attachments.storage-dir. Это выглядит логично секунду, а потом начинается хаос: вы больше не отличаете “ключи Spring” от “ключей приложения”, а поиском по проекту находите десять похожих мест. Держать свои настройки под app.* — простая дисциплина, которая сильно экономит время позже.
Ошибка №4: дублировать одно и то же значение в нескольких местах.
Например, вы захардкодили путь в LocalAttachmentStorage, но ещё и добавили его в YAML “на будущее”. И теперь два источника истины спорят, кто главный. Рано или поздно вы поменяете только один, и получите загадку: «почему конфиг изменил значение, но приложение продолжает писать в старую папку?» После ввода внешней конфигурации старый хардкод нужно убирать, иначе вы сами себе устраиваете баг-лотерею.
Ошибка №5: воспринимать конфиг как “скрытый способ менять смысл API”.
Профили и overrides (мы будем говорить о них дальше) — это способ менять поведение под окружение, а не “включать другой контракт”. Если профиль начинает менять, например, ограничения валидации или набор полей DTO — вы делаете API непредсказуемым. В таком проекте сложно писать тесты, сложно документировать контракт и невозможно объяснить клиенту, “как правильно”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ