1. Матрица режимов в ContextFlow
Профили удобнее всего воспринимать не как абстрактную “магическую настройку”, а как контракт сборки: в каком режиме мы запускаем приложение, какие реализации портов попадают в контейнер и какие побочные эффекты вообще допустимы. Если такого контракта нет, профили превращаются в хаотичный набор переключателей, и уже через пару дней никто не помнит, чем demo2 отличается от demo-old.
В ContextFlow нам достаточно трёх простых режимов, которые реально встречаются в жизни. dev — для ежедневной разработки: максимум наблюдаемости и минимум инфраструктурных хлопот. demo — для демонстрации поведения: хочется показать “похоже на настоящее”, но без внешних систем. test — для предсказуемости: никаких случайных ID и никаких побочных эффектов, которые мешают проверять сценарий. В каждом запуске активен один из этих режимов.
Ниже — “матрица вариативности”, то есть какие интерфейсы мы будем по-разному реализовывать в разных профилях. Обратите внимание: мы меняем инфраструктурные порты (id generation, audit, notifications), а не сервисы use-case уровня.
| Контракт (интерфейс) | dev | demo | test | Зачем так |
|---|---|---|---|---|
| OrderIdGenerator | UuidOrderIdGenerator | UuidOrderIdGenerator | DeterministicOrder IdGenerator | в test нужен повторяемый результат |
| AuditWriter | ConsoleAuditWriter | FileAuditWriter | ConsoleAuditWriter | в demo хочется “как будто серьёзно”: файл в build/ |
| NotificationSender | ConsoleNotification Sender | ConsoleNotification Sender | NoOpNotificationSender | в test убираем шум и побочные эффекты |
Эта матрица и есть рабочий контракт сборки: use-case-сервисы остаются теми же, а контейнер переключает только инфраструктурные реализации портов. Для верхнеуровневых режимов этого достаточно; более общий @Conditional нужен там, где правило уже не помещается в человеческое имя режима.
Для наглядности можно держать в голове такую схему:
flowchart TD
P["Активный профиль: dev/demo/test"] --> B["@Profile решает, какие beans регистрируются"]
B --> C["ApplicationContext (готовый граф объектов)"]
C --> R["ScenarioRunner запускает сценарий"]
R --> O["Разное поведение без изменений в сервисах"]
2. Профили vs бизнес-логика
Самый частый способ “сломать” профили — заставить бизнес-код знать про них напрямую. Сначала кажется, что это мелочь: ну подумаешь, один if. Потом добавляется второй, третий, появляется “временно захардкоженный” режим, и вы внезапно обнаруживаете, что у вас есть несколько приложений, которые притворяются одним. Профили нужны ровно для того, чтобы этого не случилось.
Поэтому в ContextFlow мы держим правило: сервисы application-слоя зависят только от интерфейсов. Они не читают Environment, не смотрят на spring.profiles.active, не выбирают реализацию “по настроению”. Если вы сможете открыть конструктор сервиса и понять все зависимости — значит вы всё сделали правильно, и Spring действительно помогает, а не маскирует сложность.
Вот короткий пример “правильного” сервиса: он получает NotificationSender как зависимость и вообще не знает, что в test это будет no-op.
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
private final NotificationSender sender;
public NotificationDispatchService(NotificationSender sender) {
// Зависимость приходит снаружи: контейнер сам подставит реализацию по профилю
this.sender = sender;
}
public void notifyOrderCreated(String message) {
// Сервис ничего не знает про dev/demo/test — он просто вызывает порт
sender.send(message);
}
}
Если сейчас вы подумали “а где тут dev/demo/test?” — отлично. Значит, мы попали в правильную архитектурную точку: режим выбирает контейнер, а бизнес-сервис остаётся одинаковым.
То же самое касается AuditService и OrderPlacementService: они зависят от AuditWriter и OrderIdGenerator как от портов. В зависимости от профиля мы просто подставим другие реализации. Сервисный слой при этом не будет переписываться под каждый режим, и это очень важно: иначе профили превращаются в три параллельных приложения, которые случайно компилируются в одном проекте.
3. Конфиги по ответственности
Когда вы впервые начинаете использовать профили, очень легко сделать “AppConfig на стероидах”: один огромный класс на 300 строк, в котором вперемешку идут @Bean, @Profile, @Value, а в конце ещё пара “временных” решений, которые никто не удалил. Формально оно даже работает. Но читать такое потом — всё равно что пытаться понять сериал на 10 сезонов, начав с 7 серии 8 сезона.
Гораздо спокойнее (и для мозга, и для будущего вас) держать конфигурацию по ответственности. Для нашей сегодняшней цели достаточно трёх маленьких конфигов: один отвечает за генерацию идентификаторов, второй за аудит, третий за уведомления. Да, это “чуть больше файлов”, но зато каждый файл легко объяснить, и Spring-контейнер перестаёт быть “магическим мешком”.
Базовый AppConfig пусть занимается component-based частью приложения, а @Configuration-классы с профильной вариативностью подключим явно в launcher. Тогда composition root видно глазами, и не возникает вопроса, кто именно регистрирует профильные бины.
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
@Configuration
@ComponentScan(
basePackages = "com.example.contextflow",
// Сервисы и раннеры приезжают через scanning,
// а @Configuration-классы подключаем явно в launcher
excludeFilters = @ComponentScan.Filter(
type = FilterType.ANNOTATION,
classes = Configuration.class
)
)
public class AppConfig {
}
А дальше мы подключаем “профильную инфраструктуру” отдельными @Configuration-классами. В чистом Spring (без Boot) это нормально делать через context.register(...) в точке входа, чтобы было видно, какие именно конфигурации участвуют в сборке. Это остаётся честным composition root, только уже на стороне контейнера.
4. Генераторы ID по профилям
Генерация идентификаторов — прекрасный кандидат для профилей, потому что она одновременно простая и очень показательная. В “живом” режиме (dev, demo) нам нормально иметь UUID: он уникальный, случайный, и нам всё равно. А вот в test-режиме случайность — враг. Не потому что “UUID плохой”, а потому что от случайных значений тяжело проверять сценарии и сравнивать результаты запусков. Вы запускаете одно и то же — а получаете разные ID, и отладка превращается в гадание.
Контракт у нас простой:
public interface OrderIdGenerator {
// Возвращаем ID в виде строки, чтобы пример не упирался в конкретный тип (UUID/long/и т.д.)
String nextId();
}
Реализация для test — детерминированная. В учебном проекте мы сделаем её максимально простой: счётчик, который выдаёт TEST-1000, TEST-1001 и так далее.
import java.util.concurrent.atomic.AtomicInteger;
public class DeterministicOrderIdGenerator implements OrderIdGenerator {
// AtomicInteger здесь просто чтобы пример был безопасен при возможной многопоточности
private final AtomicInteger seq = new AtomicInteger(1000);
@Override
public String nextId() {
// В test важно, чтобы значения были воспроизводимыми между запусками
return "TEST-" + seq.getAndIncrement();
}
}
Теперь самое главное — конфигурация, которая по профилю решает, какой генератор будет зарегистрирован как bean. Мы делаем это аккуратно: в test один bean, в dev/demo другой. И мы следим, чтобы в каждом профиле был ровно один OrderIdGenerator, иначе Spring честно упадёт на старте (и будет прав).
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
class IdGenerationProfileConfig {
@Bean
@Profile("test") // В тестах хотим детерминированные ID
OrderIdGenerator deterministicOrderIdGenerator() {
return new DeterministicOrderIdGenerator();
}
@Bean
@Profile({"dev", "demo"}) // В dev/demo достаточно уникальности, пусть будет UUID
OrderIdGenerator uuidOrderIdGenerator() {
return new UuidOrderIdGenerator();
}
}
Обратите внимание на важный нюанс: если вы забыли выставить профиль вообще, то активным станет “профиль по умолчанию”, и ваши dev/demo/test-бины могут не зарегистрироваться. Поэтому в точке входа разумно задать dev как default profile. Тогда приложение будет дружелюбно запускаться “просто так”, как нормальная консольная программа, а не требовать шаманский бубен уже на первом main().
5. Аудит по профилям
Аудит — это классическая вещь, которая в реальных проектах очень быстро становится “инфраструктурой со вкусом бизнеса”. Сегодня нам не нужен полноценный аудит-движок, но нам нужна демонстрация того, что в разных режимах окружения одна и та же система может писать аудит по-разному. В dev хочется видеть всё в консоли прямо сейчас. В demo хочется показать, что “сервис умеет писать файл” (и да, это часто впечатляет менеджера больше, чем правильная модель зависимостей). В test мы не хотим непредсказуемости и лишних side effects; поэтому оставим аудит консольным, но при желании позже его легко заменить.
Контракт простой:
public interface AuditWriter {
// Пишем сообщение об аудите туда, куда решит профиль (консоль/файл/и т.д.)
void write(String message);
}
Реализация для консоли максимально честная: просто System.out.println(...). Она идеально подходит для dev и вполне годится для test, потому что консоль — это хотя бы не файловая система, которая может “не туда писать”.
public class ConsoleAuditWriter implements AuditWriter {
@Override
public void write(String message) {
// В dev/test хотим видеть аудит "здесь и сейчас" — в stdout
System.out.println("AUDIT: " + message); // AUDIT: ...
}
}
А вот для demo мы хотим FileAuditWriter, который пишет в файл внутри build/. Не потому что файл — это священная корова, а потому что это хороший учебный пример: путь не должен быть абсолютным, артефакты — должны быть в build/, и поведение — должно быть наблюдаемым.
В конфигурации это выглядит так: в demo создаём FileAuditWriter, в dev/test — ConsoleAuditWriter. Обратите внимание, что путь к файлу лучше хранить в properties, чтобы не хардкодить его в Java-коде.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.*;
import java.nio.file.Path;
@Configuration
class AuditProfileConfig {
@Bean
@Profile({"dev", "test"}) // В dev/test пишем аудит в консоль
AuditWriter consoleAuditWriter() {
return new ConsoleAuditWriter();
}
@Bean
@Profile("demo") // В demo хотим "серьёзный" эффект — пишем в файл
AuditWriter fileAuditWriter(@Value("${contextflow.audit.file}") String file) {
// Путь задаётся снаружи через property, а не хардкодится в коде
return new FileAuditWriter(Path.of(file));
}
}
Если вы увидели знакомую границу “profile выбирает bean, property настраивает bean” — это она и есть. Профиль решает: “в demo аудит в файл”. А property решает: “в какой именно файл писать”. Это куда чище, чем пытаться внутрь AuditService засунуть условие “если demo — пиши в файл”.
6. Уведомления по профилям
Уведомления — ещё один идеальный кандидат для профилей, потому что они почти всегда завязаны на внешние штуки. В реальной жизни это может быть SMS-шлюз, email-провайдер, Telegram-бот… и почти всегда вы не хотите дёргать это в тестовом режиме и даже иногда в демо-режиме. В учебном проекте мы не подключаем внешние сервисы, но принцип сохраняется: в dev/demo отправка уведомления должна быть наблюдаемой, а в test — лучше быть no-op.
Контракт:
public interface NotificationSender {
// Отправить уведомление (куда именно — решает реализация/профиль)
void send(String message);
}
Реализация для консоли:
public class ConsoleNotificationSender implements NotificationSender {
@Override
public void send(String message) {
// В dev/demo нам достаточно "симуляции" — просто выводим в stdout
System.out.println("NOTIFY: " + message); // NOTIFY: ...
}
}
И реализация “тишины” для test:
public class NoOpNotificationSender implements NotificationSender {
@Override
public void send(String message) {
// ничего не делаем — в test режиме так и задумано
}
}
Теперь профильная конфигурация. Мы регистрируем разные beans одного и того же интерфейса в разных профилях. Именно в этом месте профили дают “чистую магию без магии”: сервис NotificationDispatchService не меняется, а поведение приложения — меняется.
import org.springframework.context.annotation.*;
@Configuration
class NotificationProfileConfig {
@Bean
@Profile({"dev", "demo"}) // В dev/demo хотим видеть отправку (хотя бы в консоли)
NotificationSender consoleNotificationSender() {
return new ConsoleNotificationSender();
}
@Bean
@Profile("test") // В test никаких побочных эффектов
NotificationSender noOpNotificationSender() {
return new NoOpNotificationSender();
}
}
Здесь есть важная дисциплина: в каждом профиле должен быть ровно один NotificationSender, иначе при constructor injection вы поймаете NoUniqueBeanDefinitionException. И это не “проблема Spring”, это сигнал, что вы сделали неоднозначную сборку.
Ещё один нюанс: иногда хочется зарегистрировать “настоящие” реализации (EmailNotificationSender, SmsNotificationSender) как компоненты и потом выбирать. Это нормально, но тогда профили нужно применять на уровне этих компонентов или использовать @Primary/@Qualifier. Сегодня мы оставляем пример максимально прозрачным: один профиль — один активный sender.
7. Запуск и проверка профиля
Самое приятное в профилях — их легко проверить: достаточно поднять ApplicationContext в разных режимах и посмотреть, какие реальные классы стоят за интерфейсами. Это тот момент, когда Spring перестаёт быть “теорией” и становится инженерным инструментом: вы буквально видите, как меняется сборка приложения. Главное — помнить две вещи: профиль выбирается до refresh(), и для ContextFlow на один запуск мы ждём одно значение dev, demo или test.
Ниже — пример простого “лаунчера” для консольного приложения. Он делает три вещи: задаёт default profile dev, читает активный профиль из system property, регистрирует конфигурации и стартует контекст.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
// Ожидаем одно верхнеуровневое значение: -Dspring.profiles.active=demo
String profile = System.getProperty("spring.profiles.active");
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
// Если профиль не задан — считаем, что это dev (дружелюбный режим по умолчанию)
context.getEnvironment().setDefaultProfiles("dev");
// Если профиль задан явно — активируем его
if (profile != null && !profile.isBlank()) {
context.getEnvironment().setActiveProfiles(profile);
}
// Явно регистрируем конфиги: видно, из каких "кирпичиков" собран контекст
context.register(
AppConfig.class,
IdGenerationProfileConfig.class,
AuditProfileConfig.class,
NotificationProfileConfig.class
);
// Важно: refresh() запускает создание бинов и собирает граф зависимостей
context.refresh();
// Запускаем сценарий приложения
context.getBean(ScenarioRunner.class).run();
}
}
}
Теперь “быстрая проверка” сборки — это буквально пара строчек. Вставьте их временно в main() (или в какой-нибудь диагностический runner), запустите с разными профилями и посмотрите, что реально подставилось.
// Смотрим, какая реализация порта реально оказалась в контейнере
OrderIdGenerator idGen = context.getBean(OrderIdGenerator.class);
System.out.println("OrderIdGenerator = " + idGen.getClass().getSimpleName());
// OrderIdGenerator = DeterministicOrderIdGenerator (в test)
NotificationSender sender = context.getBean(NotificationSender.class);
System.out.println("NotificationSender = " + sender.getClass().getSimpleName());
// NotificationSender = NoOpNotificationSender (в test)
Если вы запустите приложение с -Dspring.profiles.active=test, то увидите детерминированный генератор и no-op sender. Если запустите с demo, то увидите UUID-генератор, console sender и file audit writer, а файл появится в build/ по вашему contextflow.audit.file. В dev будет всё максимально “говорливое”: консольный аудит и консольные уведомления.
Самое важное, что мы получили: бизнес-сервисы не менялись. Мы не добавляли в них if-else, не прокидывали туда Environment, не делали “режим работы” частью доменной модели. Мы всего лишь собрали контейнер по-разному — и получили разные runtime-варианты одного приложения.
8. Типичные ошибки при работе с профилями
Ошибка №1: профили “просачиваются” в business code.
Обычно это начинается с невинного: “ну я просто проверю spring.profiles.active и чуть-чуть поменяю поведение”. Проблема в том, что это мгновенно ломает идею DI: сервис перестаёт быть просто сервисом и превращается в смесь use-case логики и логики сборки. Правильное место для профилей — @Configuration и registration beans, а не OrderPlacementService.
Ошибка №2: в одном профиле нет полного набора beans.
Новички часто добавляют @Profile("demo") на один bean и забывают, что в demo он теперь единственный кандидат, а в dev его нет. Или наоборот: в test включили DeterministicOrderIdGenerator, но забыли, что NotificationSender теперь тоже должен быть определён, пусть даже как no-op. В результате приложение падает на старте, и это не “сложность Spring”, а честный сигнал: сборка не завершена.
Ошибка №3: случайно активируют два взаимоисключающих профиля и получают неоднозначность.
Если запустить приложение с активными профилями dev,test одновременно, а у вас есть @Profile({"dev", "test"}) на одном bean и отдельный @Profile("test") на другом, можно легко получить два кандидата одного интерфейса. Spring не обязан угадывать, какой вам больше нравится. Лучше держать модель простой: в нашем ContextFlow предполагается ровно один активный профиль из трёх.
Ошибка №4: не задают dev как default profile и удивляются, что “без параметров не работает”.
Если вы описали beans только под dev/demo/test, а профиль не выставили, Spring будет считать активным default и не зарегистрирует ваши dev beans. В результате вы получите NoSuchBeanDefinitionException. Лечится просто: либо делайте dev default profile через setDefaultProfiles("dev"), как мы сделали, либо явно добавляйте default в @Profile там, где хотите поведение “по умолчанию”.
Ошибка №5: клонируют большой конфиг под каждый профиль.
Очень соблазнительно: “сделаю DevAppConfig, DemoAppConfig, TestAppConfig и буду счастлив”. Потом вы меняете одну зависимость и правите три файла; потом добавляете четвёртый профиль; потом начинает расходиться логика. Гораздо спокойнее держать общую базу — component scanning, общие бины — и маленькие profile-specific куски по ответственности, как IdGenerationProfileConfig, AuditProfileConfig, NotificationProfileConfig.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ