Архитектура прод-приложений: слои, модули, конфигурация, устойчивость
Мы умеем писать классы, коллекции, потоки, тесты, Spring-бины и запросы к базе. Этого достаточно, чтобы сервис заработал. Недостаточно — чтобы он прожил три года в руках меняющейся команды и не упал вместе с чужой платёжкой в пятницу вечером.
Архитектура прод-приложения отвечает ровно на два вопроса, и оба — про будущее:
- Куда класть новый код? Если ответ «зависит от того, кто пишет» — через год у вас будет не система, а археологический слой. Хорошая архитектура делает правильное место очевидным, а неправильное — невозможным.
- Что произойдёт, когда сломается то, чем вы не управляете? Сеть, база, соседний сервис, диск, ваш же деплой. Устойчивость — это не «мы обработали исключение», это заранее заданные границы: сколько ждём, сколько повторяем, сколько параллельных вызовов пропускаем, что отвечаем, когда всё плохо.
У Java здесь редкое преимущество: значительную часть архитектурных правил можно превратить
из договорённости в ошибку сборки. Модули Maven, package-private, ArchUnit, JPMS —
это инструменты, которые заставляют компилятор охранять границы, пока люди спорят в ревью.
Именно с этого и начнём.
Правило зависимостей: почему в Java его можно скомпилировать
Все популярные схемы — layered, hexagonal (ports & adapters), onion, clean — сводятся к одному правилу: зависимости направлены внутрь, к тому, что меняется медленнее всего. Внутри — правила предметной области. Снаружи — то, что подвержено моде и чужим решениям: HTTP, JSON, брокер, ORM, конкретная база. Подробный разбор самих схем — в архитектурных паттернах; здесь нас интересует Java-исполнение.
Ключевая механика — инверсия объявления интерфейса. Порт (interface Payments) объявлен
внутри, в модуле сценариев; адаптер (HttpPayments) реализован снаружи, в инфраструктуре.
Стрелка компиляции идёт от адаптера к порту, то есть снаружи внутрь, хотя данные в рантайме
текут в обе стороны. Если интерфейс Payments лежит рядом с реализацией в пакете
infrastructure.http — вы не инвертировали ничего, вы просто добавили лишний файл.
Пакеты по фиче, а не по слою
Классическая раскладка controller/ service/ repository/ dto/ кажется аккуратной, но
маскирует главное: связность внутри фичи выше, чем внутри слоя. Меняя «отмену заказа», вы
правите четыре пакета из четырёх; изменение никогда не локально. Раскладка по фиче меняет
это:
shop/
orders/ <- граница модуля; всё про заказы здесь
Order.java (public: часть контракта модуля)
OrderId.java
PlaceOrder.java (public: сценарий, входная точка)
Orders.java (порт, package-private если адаптер рядом)
internal/
JpaOrders.java (реализация, наружу не видна)
OrderEntity.java
payments/
shipping/
package-private — недооценённый инструмент
В Java есть уровень доступа по умолчанию, действующий в пределах пакета. Это буквально
единственная встроенная в язык граница модуля без JPMS — и почти никто ей не пользуется,
потому что привычка ставить public везде сильнее. Между тем:
// shop/orders/PlaceOrder.java — публичная точка входа модуля
public final class PlaceOrder { /* ... */ }
// shop/orders/OrderStateMachine.java — деталь реализации,
// класс без модификатора: за пределы пакета shop.orders не видно
final class OrderStateMachine { /* ... */ }
Тот, кто в shop.shipping попробует import shop.orders.OrderStateMachine, получит ошибку
компиляции, а не замечание в ревью через полгода. В C#/.NET похожую роль играет internal,
но там граница — сборка (assembly), то есть заметно крупнее; в Java граница мельче — пакет,
и это удобнее для модульного монолита, хотя и не защищает от «просто положу класс в тот же
пакет». Подробнее о связности и зацеплении —
в треке принципов.
Домен, который не знает о фреймворке
Ядро — это код, который можно скомпилировать и протестировать без Spring, Jackson, JPA и
базы. В современной Java для него есть ровно те инструменты, которых не хватало в эпоху
JavaBeans: record для значений, sealed для закрытых иерархий, pattern matching для
разбора (см. ООП в Java).
// orders-domain/src/main/java/shop/orders/domain/Order.java
package shop.orders.domain;
import java.time.Instant;
import java.util.List;
// Агрегат «заказ». Ни одной аннотации фреймворка: этот модуль собирается,
// даже если в classpath нет ни Spring, ни Hibernate.
public record Order(OrderId id, CustomerId customer, List<OrderLine> lines,
OrderStatus status, Instant createdAt) {
public Order {
// Компактный конструктор — единственное место, где проверяются инварианты.
// Объекта в невалидном состоянии в системе просто не существует.
if (lines == null || lines.isEmpty()) {
throw new IllegalArgumentException("заказ без позиций");
}
lines = List.copyOf(lines); // защитная копия: агрегат нельзя испортить снаружи
}
public Money total() {
return lines.stream().map(OrderLine::amount).reduce(Money.ZERO, Money::plus);
}
// Переход состояния возвращает новый объект, а не мутирует поле.
// Проверка «можно ли» живёт в домене, а не в контроллере и не в SQL.
public Order markPaid() {
if (status != OrderStatus.NEW) {
throw new IllegalStateException("оплатить можно только новый заказ, а он " + status);
}
return new Order(id, customer, lines, OrderStatus.PAID, createdAt);
}
}
Обратите внимание: OrderId — не long и не UUID, а отдельный тип
(public record OrderId(UUID value) {}). Это стоит одной строки, а спасает от целого класса
багов «передали customerId туда, где ждали orderId» — компилятор их ловит. Тема раскрыта в
тактических блоках DDD.
Идея «отдельный неизменяемый доменный объект и отдельная JPA-сущность» стоит дублирования
полей и маппинга. Плата честная: OrderEntity с @Entity, @Id, сеттерами и ленивыми
коллекциями живёт в инфраструктуре и никогда не покидает её. Иначе Hibernate начинает
диктовать домену: нужен конструктор без аргументов, поля не могут быть final, equals
ломается на прокси, а record вообще нельзя сделать сущностью. О ловушках маппинга —
в статье про персистентность.
Сценарий и граница транзакции
Между контроллером и доменом лежит слой сценариев (use case, application service). Его
задача — оркестрация: загрузить агрегат, вызвать доменную операцию, сохранить, опубликовать
событие. Никакой бизнес-логики здесь быть не должно: если в сценарии появились if про
скидки — они уехали не туда.
// orders-application/src/main/java/shop/orders/application/PlaceOrder.java
package shop.orders.application;
public final class PlaceOrder {
private final Orders orders; // порт: хранилище
private final Payments payments; // порт: внешняя платёжная система
private final OutboxEvents outbox; // порт: надёжная публикация событий
private final Clock clock; // порт: время (иначе тесты недетерминированы)
public PlaceOrder(Orders orders, Payments payments, OutboxEvents outbox, Clock clock) {
this.orders = orders;
this.payments = payments;
this.outbox = outbox;
this.clock = clock;
}
// Результат — sealed-тип, а не исключение: отказ платежа это нормальный
// исход сценария, а не сбой. Разбор — в статье про идиоматику и ошибки.
public sealed interface Result {
record Placed(OrderId id) implements Result {}
record Declined(String reason) implements Result {}
}
public Result handle(PlaceOrderCommand cmd) {
Order order = new Order(OrderId.next(), cmd.customer(), cmd.lines(),
OrderStatus.NEW, clock.instant());
// Внешний вызов — ВНЕ транзакции БД. Держать соединение из пула,
// пока чужой сервис думает 700 мс, — классический способ положить себя.
PaymentResult payment = payments.charge(order.id(), order.total(), cmd.idempotencyKey());
if (payment instanceof PaymentResult.Declined d) {
return new Result.Declined(d.reason());
}
// Короткая транзакция: только запись в свою БД.
transactional(() -> {
orders.save(order.markPaid());
outbox.append(new OrderPaid(order.id(), order.total(), clock.instant()));
});
return new Result.Placed(order.id());
}
}
Здесь спрятаны три решения, каждое из которых стоит инцидента, если сделать наоборот:
- Транзакция открывается как можно позже и закрывается как можно раньше. Соединение Hikari — дефицитный ресурс: их 16, а запросов 500 в секунду. Транзакция длиной в HTTP-вызов превращает пул в узкое горлышко за секунды.
- Внешние эффекты не участвуют в транзакции БД. Отправить сообщение в Kafka внутри
@Transactional— значит гарантированно однажды получить «в базе нет, в топике есть» или наоборот. Лечение — outbox, о нём ниже. - Границей транзакции владеет сценарий, а не контроллер и не репозиторий.
@Transactionalна контроллере растягивает её на сериализацию ответа; на репозитории — дробит один бизнес-шаг на несколько атомарных, и половинчатое состояние становится нормой.
Прагматичный компромисс насчёт чистоты: @Transactional из jakarta.transaction — крошечная
стандартная аннотация, и большинство команд спокойно допускают её в модуле сценариев, чтобы
не изобретать TransactionRunner. Полный запрет любых аннотаций в application-слое — это
позиция, а не закон; главное, чтобы домен остался чистым.
Модули сборки: архитектура, которую проверяет компилятор
Пакеты — соглашение. Модули Maven/Gradle — физика: если orders-domain не объявляет
зависимость на orders-infrastructure, никакой import оттуда не скомпилируется. Разметка
из статьи про инструментарий обретает архитектурный
смысл:
<!-- orders-application/pom.xml: зависимость ровно одна -->
<dependencies>
<dependency>
<groupId>shop</groupId><artifactId>orders-domain</artifactId><version>${project.version}</version>
</dependency>
</dependencies>
Дальше — maven-enforcer-plugin с правилом bannedDependencies, чтобы в домен случайно не
приехал Spring транзитивно. Плюс mvn dependency:analyze в CI: он находит и «объявлено, но
не используется», и «используется, но не объявлено» — второе особенно коварно, потому что
работает до первого обновления версии.
ArchUnit: правила как тесты
Не всё выражается графом модулей: внутри одного модуля тоже нужны границы. ArchUnit проверяет байткод и превращает архитектурное соглашение в обычный JUnit-тест, падающий в CI.
@AnalyzeClasses(packages = "shop", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
@ArchTest
static final ArchRule домен_не_знает_о_фреймворках =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..", "jakarta.persistence..", "com.fasterxml.jackson..");
@ArchTest
static final ArchRule слои_не_перепрыгиваются =
layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("Web").definedBy("..web..")
.layer("Application").definedBy("..application..")
.layer("Domain").definedBy("..domain..")
.layer("Infrastructure").definedBy("..infrastructure..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Web", "Infrastructure")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure");
@ArchTest // модули верхнего уровня общаются только через публичный API друг друга
static final ArchRule модули_не_лезут_внутрь =
noClasses().that().resideOutsideOfPackage("shop.orders..")
.should().accessClassesThat().resideInAPackage("shop.orders.internal..");
@ArchTest // никто не печатает в консоль в проде
static final ArchRule никаких_println =
noClasses().should(ACCESS_STANDARD_STREAMS);
}
Такой тест стоит десяти страниц вики: он не устаревает молча.
Spring Modulith и модульный монолит
Разбивать сервис на микросервисы ради «модульности» — дорогая ошибка: вы платите сетью, сериализацией и распределёнными транзакциями за то, что решается пакетами. Разумный дефолт для большинства продуктов — модульный монолит. Spring Modulith делает его наблюдаемым: объявляет модули по пакетам верхнего уровня, проверяет их изоляцию тестом, генерирует документацию и даёт транзакционный outbox для событий между модулями.
// shop/orders/package-info.java
@org.springframework.modulith.ApplicationModule(
displayName = "Заказы",
allowedDependencies = {"payments :: api", "shared"})
package shop.orders;
@Test
void модули_изолированы() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
modules.verify(); // падает при запрещённой зависимости
new Documenter(modules).writeDocumentation(); // PlantUML-схемы в target/
}
Событие между модулями объявляется как обычный Spring-эвент, но с @ApplicationModuleListener
слушатель выполняется после коммита и в своей транзакции, а Modulith пишет событие в
таблицу — то есть даёт ровно те гарантии, ради которых обычно вручную городят outbox.
Сколько модульности брать
Практическое правило: граница деплоя должна следовать за границей команды и за разными требованиями к масштабированию, а не за схемой на доске. Внутри одного артефакта модульность почти бесплатна, между артефактами — стоит сети, версионирования API и распределённых сбоев.
Конфигурация: значения, а не глобальные переменные
Конфигурация — это вход программы, такой же, как аргументы функции. Всё, что отличает stage от prod, приходит извне (12 факторов), а внутри превращается в типизированный неизменяемый объект, который передан явно.
Spring Boot собирает Environment из упорядоченного набора источников, и порядок надо знать
наизусть — половина вопросов «почему на проде другое значение» отвечается им.
--payments.base-uri=..."] --> M B["Переменные окружения
PAYMENTS_BASEURI"] --> M C["application-prod.yaml
рядом с jar, /config"] --> M D["application.yaml
внутри jar"] --> M E["spring.config.import
Vault, Config Server, файлы секретов"] --> M M["Environment: источники
в порядке приоритета"] --> B1 B1["Привязка к @ConfigurationProperties
relaxed binding, конвертация типов"] --> V V{"Bean Validation
прошла?"} V -- "нет" --> F["Приложение НЕ стартует:
ошибка с именем свойства"] V -- "да" --> R["Неизменяемый record в контексте"] R --> S["Дополнительная проверка на старте:
доступность БД, наличие ключей"] S --> OK["Готов принимать трафик"]
Типизированный конфиг и fail-fast
@ConfigurationProperties("payments")
@Validated
public record PaymentsProperties(
@NotNull URI baseUri,
@NotNull @DurationMin(millis = 50) @DurationMax(seconds = 2) Duration connectTimeout,
@NotNull @DurationMin(millis = 100) @DurationMax(seconds = 5) Duration requestTimeout,
@Min(1) @Max(64) int maxConcurrentCalls,
@NotBlank String apiKey) {
// Дефолты задаются здесь, а не размазаны по @Value в пяти классах.
public PaymentsProperties {
if (connectTimeout == null) connectTimeout = Duration.ofMillis(300);
if (requestTimeout == null) requestTimeout = Duration.ofMillis(700);
}
}
# application.yaml — общие значения и дефолты
payments:
base-uri: https://sandbox.pay.example
connect-timeout: 300ms
request-timeout: 700ms
max-concurrent-calls: 16
api-key: ${PAYMENTS_API_KEY} # значение приходит из окружения, не из репозитория
Что здесь важнее синтаксиса:
recordвместо класса с сеттерами. Конфиг неизменяем, его нельзя «подкрутить» в рантайме из случайного бина. Инъекция — конструктором, а не@Valueпо строковому ключу.- Валидация валит старт. Опечатка в URL или пустой ключ обнаруживаются на 400-й миллисекунде запуска пода, а не на первом платеже в 3 часа ночи. Это и есть fail-fast, и это дешевле любого мониторинга.
- Никаких
System.getenvв глубине кода. Чтение окружения из статического инициализатора делает класс непроверяемым и добавляет невидимую зависимость.
Отдельная проверка стартовой готовности — обычный ApplicationRunner, который дёргает
миграции, проверяет наличие обязательных ключей и падает с внятным сообщением. Лучше не
подняться, чем подняться наполовину.
Профили, секреты и конфиг-дрейф
Профили (spring.profiles.active=prod) удобны, но их легко превратить в оружие против себя.
Три правила из практики:
- Профиль переключает поведение, а не значения.
prodможет включать другой бинCacheManager; конкретные URL, пароли и лимиты приходят из окружения. Иначеgit grepпо проду становится единственным способом узнать, что там за таймаут. - Секретов в репозитории нет никогда — ни в
application-prod.yaml, ни в шифрованном виде «на время». Sealed Secrets, Vault, SOPS черезspring.config.import, монтирование файлов; разбор — в треке безопасности. - Конфиг-дрейф лечится диффом.
/actuator/env(закрытый авторизацией!) иspring.config.activate.on-profileдают сравнимую картину; полезно на каждом деплое логировать хеш эффективной конфигурации — расхождение между репликами видно сразу.
В .NET та же идея живёт как Options pattern: IOptions<T> плюс services.Configure<T>() и
ValidateOnStart(). Разница косметическая: в Java relaxed binding переводит
PAYMENTS_MAX_CONCURRENT_CALLS в payments.max-concurrent-calls автоматически, в .NET
разделителем секций служит двойное подчёркивание. Механика мышления одинаковая — см.
архитектуру прод-приложений на .NET.
Устойчивость: сервис как система с бюджетом
Дальше — вторая половина статьи и вторая половина работы. Всё, что мы обсуждали, отвечало на вопрос «куда класть код». Теперь — что делать, когда чужой код не отвечает.
Таймауты: сначала они, потом всё остальное
Главный факт про Java-стек: по умолчанию почти все таймауты бесконечны. HttpClient без
.timeout() ждёт вечно. JDBC-драйвер без socketTimeout ждёт вечно. RestClient наследует
поведение фабрики соединений. Один зависший вызов держит поток, поток держит соединение,
соединение держит транзакцию — и через минуту пул исчерпан, а сервис «упал», хотя ни одного
исключения не было.
// Клиент собирается один раз и живёт в контексте: он потокобезопасен и держит пул соединений.
HttpClient http = HttpClient.newBuilder()
.connectTimeout(props.connectTimeout()) // ТОЛЬКО установка TCP+TLS
.version(HttpClient.Version.HTTP_2)
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
HttpRequest request = HttpRequest.newBuilder(props.baseUri().resolve("/charges"))
.timeout(props.requestTimeout()) // весь обмен; без этой строки — бесконечность
.header("Idempotency-Key", idempotencyKey)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
spring:
datasource:
hikari:
connection-timeout: 250 # ожидание соединения ИЗ ПУЛА, а не установки TCP
validation-timeout: 250
maximum-pool-size: 16 # = столько параллельных запросов выдержит БД, а не «побольше»
max-lifetime: 1740000 # меньше, чем таймаут на стороне БД и файрвола
keepalive-time: 120000
data-source-properties:
socketTimeout: 2 # секунды: страховка на случай молчащего сервера
options: "-c statement_timeout=400ms" # PostgreSQL сам оборвёт долгий запрос
Тонкость, на которой спотыкаются регулярно: connection-timeout у Hikari — это время
ожидания свободного соединения из пула, а не сетевой таймаут. Сетевой задаётся свойствами
драйвера (socketTimeout, connectTimeout у PostgreSQL JDBC). Их путают, ставят
connection-timeout: 30s и получают тридцатисекундные зависания на пустом месте.
Правило вложенности из картинки формулируется в одну строку: T(внутренний) < T(внешний) −
(время одного повтора). И бюджет надо явно передавать вниз по цепочке: @Transactional(timeout = 2),
SET LOCAL statement_timeout, Deadline в gRPC, заголовок с оставшимся временем в своих API.
Retry, который не убивает соседа
Повтор — самая опасная из «простых» техник. Три отказавших запроса из трёх реплик, каждый с тремя попытками, дают девятикратную нагрузку ровно в тот момент, когда сервис-жертва и так задыхается. Это retry storm, и он превращает деградацию в отказ.
Условия, при которых повтор допустим, сформулированы жёстко:
- Операция идемпотентна — или на стороне вызываемого (ключ идемпотентности), или по
природе (
GET). ПовторPOST /chargesбез ключа — это второе списание с карты. - Ошибка транзиентна: таймаут, обрыв соединения, 502/503/429. Повторять 400 и 422 бессмысленно, повторять 401 вредно.
- Экспоненциальная задержка с джиттером. Без джиттера все клиенты возвращаются синхронно и создают вторую волну ровно той же формы.
- Есть бюджет. Повторяем, пока в дедлайне запроса остаётся время, и не больше N раз.
resilience4j:
retry:
instances:
payments:
max-attempts: 3
wait-duration: 150ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
enable-randomized-wait: true # джиттер: без него — синхронная вторая волна
randomized-wait-factor: 0.5
retry-exceptions:
- java.io.IOException
- java.net.http.HttpTimeoutException
ignore-exceptions:
- shop.payments.PaymentDeclined # бизнес-отказ повторять бессмысленно
Circuit breaker: перестать стучаться в закрытую дверь
Когда сосед стабильно отвечает ошибкой или медленно, продолжать звонить — значит тратить свои потоки и соединения на заведомо провальные вызовы. Предохранитель размыкает цепь и быстро отвечает отказом, освобождая ресурсы для остального трафика.
@Component
class ResilientPayments implements Payments {
private final Payments delegate; // «голый» HTTP-адаптер
@Retry(name = "payments")
@CircuitBreaker(name = "payments", fallbackMethod = "fallback")
@Bulkhead(name = "payments")
@TimeLimiter(name = "payments")
@Override
public PaymentResult charge(OrderId id, Money amount, String key) {
return delegate.charge(id, amount, key);
}
// Фолбэк — не «проглотить ошибку», а осознанный деградированный ответ.
// Сигнатура повторяет исходную плюс Throwable последним параметром.
private PaymentResult fallback(OrderId id, Money amount, String key, Throwable t) {
if (t instanceof CallNotPermittedException) {
return new PaymentResult.Deferred(id); // поставим в очередь, ответим клиенту 202
}
throw new PaymentUnavailable(t);
}
}
Порядок декораторов в Spring Boot по умолчанию таков:
Retry(CircuitBreaker(RateLimiter(TimeLimiter(Bulkhead(вызов))))). Читается снаружи внутрь:
повтор оборачивает предохранитель, а не наоборот, — иначе три попытки внутри одного «вызова»
считались бы предохранителем за один отказ, и он бы никогда не размыкался.
Ключевой параметр, который забывают, — slow-call-duration-threshold. Самый вредный сосед не
тот, кто отвечает ошибкой (это быстро и дёшево), а тот, кто отвечает за 3 секунды: он
выпивает ваши потоки, формально оставаясь «живым».
Ограничение нагрузки: bulkhead и мир виртуальных потоков
Переборка (bulkhead) — это отдельный лимит параллельных вызовов на каждую внешнюю зависимость. Смысл: сбой одного соседа не должен съесть все потоки приложения. В классической Java лимит возникал сам собой — пул на 32 потока физически не мог сделать больше 32 одновременных вызовов.
С виртуальными потоками (см. конкурентность) эта
случайная защита исчезает: newVirtualThreadPerTaskExecutor() не имеет очереди и радостно
создаст 50 000 потоков, каждый со своим соединением. Дальше падает не ваш сервис, а сосед.
Поэтому в мире Java 21+ ограничение нагрузки становится явным решением:
// Семафор — простейший bulkhead. Он же документирует: «мы обещали соседу не больше 32».
private final Semaphore limit = new Semaphore(32);
PaymentResult charge(OrderId id, Money amount, String key) throws InterruptedException {
if (!limit.tryAcquire(50, TimeUnit.MILLISECONDS)) {
// Быстрый отказ лучше растущей очереди: очередь превращает деградацию в отказ.
throw new OverloadedException("payments");
}
try {
return delegate.charge(id, amount, key);
} finally {
limit.release();
}
}
Общее правило: очередь без предела — это отложенный отказ. Ограничивайте
на входе (rate limiter, server.tomcat.threads.max или явный семафор для виртуальных потоков)
и на каждом выходе (bulkhead). Каталог этих приёмов —
в паттернах устойчивости.
Идемпотентность: единственная настоящая защита от повторов
Retry на стороне клиента гарантирует, что запрос придёт дважды. Единственный способ пережить это без двойного списания — сделать операцию идемпотентной на стороне сервера. Теория — в распределённых системах, а на практике в Java это выглядит так:
create table processed_request (
idempotency_key text primary key, -- ключ приходит от клиента в заголовке
request_hash text not null, -- защита от «тот же ключ, другое тело»
response_status int not null,
response_body jsonb not null,
created_at timestamptz not null default now()
);
@Transactional
public ResponseEntity<?> handle(String key, PlaceOrderCommand cmd) {
String hash = sha256(cmd);
Optional<Processed> known = processed.find(key);
if (known.isPresent()) {
Processed p = known.get();
if (!p.requestHash().equals(hash)) {
// Тот же ключ с другим телом — почти всегда баг клиента. 409, а не тихое согласие.
return ResponseEntity.status(HttpStatus.CONFLICT).body(problem("idempotency-key-reused"));
}
return ResponseEntity.status(p.responseStatus()).body(p.responseBody()); // повтор ответа
}
var result = placeOrder.handle(cmd);
try {
processed.insert(key, hash, result); // уникальный индекс — арбитр гонки
} catch (DataIntegrityViolationException race) {
// Два параллельных повтора: проиграли гонку — читаем и отдаём чужой результат.
return replay(processed.find(key).orElseThrow());
}
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
Суть — арбитром гонки выступает уникальный индекс базы, а не synchronized, ConcurrentHashMap
или распределённый лок. Это работает при любом числе реплик и переживает рестарт.
Outbox: атомарность «записал и опубликовал»
Записать в БД и отправить в Kafka атомарно нельзя (двухфазный коммит в 2026-м — лечение хуже болезни). Значит, событие пишется в ту же транзакцию, что и данные, а публикуется отдельным процессом.
FOR UPDATE SKIP LOCKED LIMIT 100 P->>K: publish(OrderPaid) K-->>P: ack P->>DB: UPDATE outbox SET published_at = now() end Note over P,K: Сбой между publish и UPDATE даёт дубль:
гарантия at-least-once, потребитель обязан
быть идемпотентным по event_id C->>S: POST /orders (повтор, тот же k1) S->>DB: SELECT processed_request WHERE key = k1 S-->>C: 201 Created (сохранённый ответ, второго заказа нет)
FOR UPDATE SKIP LOCKED — то, что делает публикатор безопасным при нескольких репликах: строки
разбираются без конфликтов и без внешнего лока. Если писать outbox руками не хочется, Spring
Modulith и Debezium (CDC по WAL) закрывают ту же задачу; выбор — вопрос того, готовы ли вы
эксплуатировать Kafka Connect.
Graceful shutdown и корректные health-check
Деплой — самый частый «сбой» в жизни сервиса, и происходит он несколько раз в день. Правильное завершение стоит примерно четырёх строк конфигурации и экономит десятки ошибок у клиентов.
server:
shutdown: graceful # перестаём принимать новые, доигрываем текущие
spring:
lifecycle:
timeout-per-shutdown-phase: 25s
management:
endpoint:
health:
probes:
enabled: true # /actuator/health/liveness и /readiness
@Component
class OutboxPublisher implements SmartLifecycle {
private final ExecutorService pool = Executors.newSingleThreadExecutor();
private volatile boolean running;
@Override public void start() { running = true; pool.submit(this::loop); }
@Override public void stop() {
running = false;
pool.shutdown(); // новые задачи не принимаем
try {
// Ждём завершения текущей пачки: иначе потеряем ack и получим дубли.
if (!pool.awaitTermination(20, TimeUnit.SECONDS)) pool.shutdownNow();
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
@Override public boolean isRunning() { return running; }
@Override public int getPhase() { return Integer.MAX_VALUE; } // останавливаемся раньше БД
}
Разница между пробами существеннее, чем кажется, и путаница здесь роняет кластеры целиком:
- liveness отвечает на вопрос «процесс жив или его надо перезапустить». Он не должен зависеть от БД и соседей. Если в liveness проверять PostgreSQL, то при недоступности базы Kubernetes перезапустит все поды сразу, превратив частичную деградацию в полный отказ.
- readiness отвечает «можно ли слать трафик сейчас». Вот он зависит от прогретого пула, доступной БД и завершённых миграций. При потере зависимости под уходит из балансировки, но живёт.
И к этому — preStop с паузой в 5–10 секунд: endpoint успевает разъехаться по kube-proxy
раньше, чем процесс перестанет принимать соединения. terminationGracePeriodSeconds должен
быть больше timeout-per-shutdown-phase, иначе SIGKILL прилетит посреди корректного
завершения. Подробности механики — в треке DevOps,
сборка образов — в статье про деплой.
Ошибки как часть контракта API
Наружу не должны утекать ни stacktrace, ни NullPointerException, ни имя таблицы. Начиная со
Spring 6 в платформе есть ProblemDetail — реализация RFC 9457 (бывший 7807), и это
достаточный стандарт для 95% сервисов.
@RestControllerAdvice
class ApiErrors {
@ExceptionHandler(OrderNotFound.class)
ProblemDetail notFound(OrderNotFound e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "Заказ не найден");
pd.setType(URI.create("https://errors.shop.example/order-not-found"));
pd.setProperty("orderId", e.id().value()); // машиночитаемая деталь
return pd;
}
@ExceptionHandler(OverloadedException.class)
ResponseEntity<ProblemDetail> overloaded(OverloadedException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.SERVICE_UNAVAILABLE, "Сервис временно перегружен");
// Retry-After — способ управлять чужими ретраями. Без него клиент придёт сразу.
return ResponseEntity.status(503).header("Retry-After", "2").body(pd);
}
@ExceptionHandler(Exception.class)
ProblemDetail unexpected(Exception e) {
String traceId = Span.current().getSpanContext().getTraceId();
log.error("необработанная ошибка, traceId={}", traceId, e); // подробности — в лог
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка");
pd.setProperty("traceId", traceId); // наружу — только идентификатор для поддержки
return pd;
}
}
Про выбор между исключением и типом-результатом внутри приложения — в идиоматике и обработке ошибок. На границе HTTP правило простое: исключения не пересекают границу процесса, наружу выходит документированный код ошибки.
Прод-готовность одним взглядом
Типичные ошибки именно в Java-проде
- Бесконечные таймауты по умолчанию.
RestTemplate,HttpClientбез.timeout(), JDBC безsocketTimeout, Kafka-консьюмер сmax.poll.interval.msпо умолчанию. Первое, что стоит сделать в новом проекте, — выписать все таймауты явно. @Transactional, обёрнутый вокруг сети. Транзакция открыта, пока чужой API думает. Пул выедается за минуту. Внешние вызовы — вне транзакции, всегда.- Self-invocation. Вызов
this.method()внутри бина не проходит через прокси, поэтому@Transactional,@Retry,@Cacheableна нём молча не работают. Это самая частая «мистика» Spring: аннотация есть, эффекта нет. Лечится вынесением метода в другой бин. - Open Session in View включён по умолчанию.
spring.jpa.open-in-view=trueдержит соединение до конца рендеринга ответа и прячетLazyInitializationExceptionценой выеденного пула. Выключайте осознанно и сразу. - Ретрай поверх ретрая. Повтор в HTTP-клиенте, повтор в Resilience4j, повтор в шине, повтор у клиента — умножаются. Держите повтор ровно на одном уровне и документируйте где.
ThreadLocal-контекст и виртуальные потоки. MDC,SecurityContextHolder, tenant-id теряются при переходе в другой executor и ведут себя иначе на виртуальных потоках. Пробрасывайте контекст явно (ScopedValueв новых версиях,TaskDecoratorв Spring).- Health-check, роняющий кластер. Проверка внешней зависимости в liveness = массовый рестарт при её сбое. Внешние зависимости — только в readiness, и то не все.
- Конфигурация через
@Valueроссыпью. Тридцать строковых ключей по всему коду, опечатка находится в рантайме, тесты требуют половины Spring. Одинrecordна подсистему решает всё. - Анемичный домен плюс «сервис на 2000 строк». Формально слои есть, но вся логика в
OrderServiceImpl, аOrder— мешок геттеров. Признак: чтобы понять правило, надо читать сервис, а не доменный класс. - Абстракция ради абстракции.
IOrderRepository→OrderRepository→AbstractRepositoryс единственной реализацией на каждом уровне. Порт оправдан там, где есть вторая реализация или её нужен тестовый двойник, — а не по умолчанию. - Логирование как поиск.
log.infoна каждый шаг вместо структурированного лога с traceId. В 3 часа ночи полезен один коррелированный запрос, а не 200 строк текста (наблюдаемость).
Честно о месте Java
Где выигрывает. Долгоживущий серверный процесс с высокой нагрузкой и сложным доменом — родная ниша. Компилятор охраняет границы модулей; экосистема устойчивости (Resilience4j, Micrometer, OpenTelemetry, Testcontainers) зрелая и совместимая; JFR и heap dump дают уровень интроспекции живого процесса, которого нет почти нигде; JIT после прогрева даёт пропускную способность, сравнимую с нативными языками. Двадцать лет эксплуатации сформировали внятные дефолты почти для каждой инфраструктурной задачи.
Где проигрывает. Всё, что живёт коротко: CLI, лямбды, скрипты, задачи с холодным стартом. Секунда до готовности и 200–400 МБ RSS на инстанс — плохая цена для функции, вызываемой раз в минуту (GraalVM native-image и CRaC частично лечат, но добавляют свои ограничения). Много фреймворочной магии на прокси и рефлексии: она экономит код, но её нужно понимать, иначе отладка превращается в гадание. Для маленького сервиса «три ручки и запись в базу» Go даёт тот же результат заметно проще и дешевле по памяти.
Рядом с .NET. Ниши почти совпадают, и решения изоморфны: @ConfigurationProperties ↔
Options pattern, Resilience4j ↔ Polly, SmartLifecycle ↔ IHostedService, Actuator ↔ Health
Checks. Java выигрывает шириной OSS-экосистемы и инструментами интроспекции JVM; .NET —
единообразием (один вендор, один способ), более быстрым стартом и обычно более простой
конфигурацией. Выбор между ними на архитектурном уровне почти никогда не про язык, а про
команду и инфраструктуру.
Куда не тащить. Устойчивость в Java — это библиотеки и дисциплина, а не свойство рантайма. В Erlang/Elixir дерево супервизоров и изоляция процессов делают «упало — перезапустилось» частью платформы (конкурентность в Elixir). Если вам нужна именно такая модель отказоустойчивости, честнее взять BEAM, чем строить её поверх JVM руками.
Мини-итог
- Архитектура отвечает на два вопроса: куда класть код и что будет при чужом отказе. Всё остальное — детали реализации.
- Зависимости направлены внутрь. В Java это не соглашение, а компилируемое свойство: модули
Maven,
package-private, ArchUnit в CI. - Домен —
recordиsealed, без аннотаций и без фреймворка. Сценарий — оркестрация и граница транзакции. Инфраструктура реализует порты, объявленные внутри. - Модульный монолит — разумный дефолт; отдельный сервис заводится под границу команды или разные требования к масштабированию, а не под схему на доске.
- Конфигурация — типизированный неизменяемый
recordс валидацией, валящей старт. Секреты только из окружения, профили переключают поведение, а не значения. - Таймауты выставлены явно все и вложены по бюджету. По умолчанию в Java они бесконечны.
- Retry — только для идемпотентных операций, с джиттером, лимитом и бюджетом. Идемпотентность обеспечивает уникальный индекс в БД, а не лок в памяти.
- Circuit breaker считает не только ошибки, но и медленные вызовы. Bulkhead обязателен — особенно с виртуальными потоками, где пул больше не ограничивает нагрузку.
- Публикация событий — через outbox в той же транзакции. At-least-once, потребитель идемпотентен.
- Graceful shutdown, liveness без внешних зависимостей, readiness с ними,
ProblemDetailнаружу.
Источники
- Spring Boot: Externalized Configuration —
порядок источников свойств, relaxed binding,
spring.config.import. - Spring Boot: Graceful Shutdown и Kubernetes Probes — корректное завершение и пробы.
- Resilience4j documentation — точная семантика circuit breaker, retry, bulkhead, time limiter и порядок декораторов.
- Spring Modulith Reference — модули, проверка изоляции, событийный outbox из коробки.
- ArchUnit User Guide — архитектурные правила как тесты.
- RFC 9457: Problem Details for HTTP APIs —
стандарт машиночитаемых ошибок, который реализует
ProblemDetail. - HikariCP: About Pool Sizing — почему «побольше соединений» ухудшает латентность.
- Michael Nygard, Release It! — первоисточник circuit breaker, bulkhead и «стабилизирующих» паттернов. Обязательна к чтению.
- Tom Hombergs, Get Your Hands Dirty on Clean Architecture — гексагональная архитектура именно в Java-коде, с модулями и ArchUnit.
- Vaughn Vernon, Implementing Domain-Driven Design — агрегаты, порты и границы контекстов.
- Marc Brooker, Timeouts, Retries and Backoff with Jitter — инженерная библиотека AWS: почему джиттер обязателен и как считать бюджет.
- Chris Richardson, Pattern: Transactional outbox — каноническое описание паттерна и его вариантов.
Что дальше
Архитектура задаёт форму системы, но ничего не говорит о том, сколько она стоит в миллисекундах и мегабайтах. Дальше — измерения: как профилировать живой процесс через JFR и async-profiler, как писать микробенчмарки на JMH и не обмануться на мёртвом коде, как читать GC-логи и выбирать сборщик под задачу, где искать утечки, которые в языке со сборкой мусора всё-таки бывают.
Производительность: профилирование, JMH, настройка GC, типичные утечки