Java Архитектура прод-приложений: слои, модули, конфигурация, устойчивость
0%

Архитектура прод-приложений: слои, модули, конфигурация, устойчивость

Архитектура прод-приложений: слои, модули, конфигурация, устойчивость

Мы умеем писать классы, коллекции, потоки, тесты, Spring-бины и запросы к базе. Этого достаточно, чтобы сервис заработал. Недостаточно — чтобы он прожил три года в руках меняющейся команды и не упал вместе с чужой платёжкой в пятницу вечером.

Архитектура прод-приложения отвечает ровно на два вопроса, и оба — про будущее:

  1. Куда класть новый код? Если ответ «зависит от того, кто пишет» — через год у вас будет не система, а археологический слой. Хорошая архитектура делает правильное место очевидным, а неправильное — невозможным.
  2. Что произойдёт, когда сломается то, чем вы не управляете? Сеть, база, соседний сервис, диск, ваш же деплой. Устойчивость — это не «мы обработали исключение», это заранее заданные границы: сколько ждём, сколько повторяем, сколько параллельных вызовов пропускаем, что отвечаем, когда всё плохо.

У 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 из упорядоченного набора источников, и порядок надо знать наизусть — половина вопросов «почему на проде другое значение» отвечается им.

Типизированный конфиг и 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, и он превращает деградацию в отказ.

Условия, при которых повтор допустим, сформулированы жёстко:

  1. Операция идемпотентна — или на стороне вызываемого (ключ идемпотентности), или по природе (GET). Повтор POST /charges без ключа — это второе списание с карты.
  2. Ошибка транзиентна: таймаут, обрыв соединения, 502/503/429. Повторять 400 и 422 бессмысленно, повторять 401 вредно.
  3. Экспоненциальная задержка с джиттером. Без джиттера все клиенты возвращаются синхронно и создают вторую волну ровно той же формы.
  4. Есть бюджет. Повторяем, пока в дедлайне запроса остаётся время, и не больше 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 — то, что делает публикатор безопасным при нескольких репликах: строки разбираются без конфликтов и без внешнего лока. Если писать 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 — мешок геттеров. Признак: чтобы понять правило, надо читать сервис, а не доменный класс.
  • Абстракция ради абстракции. IOrderRepositoryOrderRepositoryAbstractRepository с единственной реализацией на каждом уровне. Порт оправдан там, где есть вторая реализация или её нужен тестовый двойник, — а не по умолчанию.
  • Логирование как поиск. 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, SmartLifecycleIHostedService, 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 наружу.

Источники

Что дальше

Архитектура задаёт форму системы, но ничего не говорит о том, сколько она стоит в миллисекундах и мегабайтах. Дальше — измерения: как профилировать живой процесс через JFR и async-profiler, как писать микробенчмарки на JMH и не обмануться на мёртвом коде, как читать GC-логи и выбирать сборщик под задачу, где искать утечки, которые в языке со сборкой мусора всё-таки бывают.

Производительность: профилирование, JMH, настройка GC, типичные утечки

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов