
Для кого статья: для техлидов и системных аналитиков (SA), архитекторов ПО.
О чём статья: об использовании некоторых удобных, современных подходов к проектированию ПО в enterprise в условиях большого количества команд и большой неопределенности. Используются современные подходы из мира Java-Spring.
Об авторе: лид стрима в облачном провайдере, в 2024-2025 гг. с коллегами разрабатывавший подходы к архитектуре микросервисов.
Contract first
В условиях современной разработки, когда компании растут, а команды распределены по офисам и странам, классические подходы к проектированию часто становятся узким местом. Хаос в интеграциях, бесконечные согласования форматов данных и конфликты из-за изменений в API — это знакомые боли для многих архитекторов и тимлидов. В такой среде на первый план выходит не только технологическое, но и организационное решение: Contract First в сочетании со слоистой архитектурой. Это не просто про код, это про создание четких правил игры, которые понимают и разработчики, и аналитики, и архитекторы.
На старте проектирования новых сервисов по компании было принято архитектурное решение Contract First (о подобном уже писали на Хабре, и тут также, и еще много много раз).
Также используются сильные стороны слоистого подхода к архитектуре: разбиваем логику на так называемые слои со своими зонами ответственности: доменная логика, операции ввода-вывода, контракты. Слои решают несколько вопросов, самыми важными для нас являются:
разделение ответственности кода. Правка контракта и правка бизнес-логики в некоторых случаях могут быть независимы. Возможно распараллеливать задачи на разных разработчиков, но не увеличивать риски конфликтов слияния в дальнейшем.
разделение ответственности разработчиков. Разработчики корректируют те слои, где есть код. Аналитик или архитектор корректирует контракты. Никто не трогает сгенерированный слой.
более чёткое понимание, что надо покрывать модульными тестами, а что нет. Ясно, что контракты и сгенерированный код в этом не нуждаются.
более чёткий процесс сборки – maven или gradle собирают сервис слой за слоем, мы имеем возможность в каждом слое выстраивать нужный список зависимостей.
возможность скрыть детали реализации между слоями. Например, слой интеграции с брокером не имеет доступа к моделям слоя синхронного взаимодействия. Таким образом, на уровне иерархии модулей gradle выстраивается возможная цепочка вызовов: http API – домен – DAO-слой и http API – домен – асинхронное API.
Слои под микроскопом
OpenAPI – API – web
Один слой микросервиса (модуль gradle) – только папки с текстом, то бишь контракт в openapi-формате. Обычно этот слой создаёт архитектор, правит он же или аналитик (системный). Таким образом разделяются зоны ответственности разработчик-архитектор.
Написанный человеком контракт – основа для генерации цифрового контракта. Состоит из набора DTO (моделей для передачи) и интерфейсов http-методов. За счёт кодогенерации выполняется условие contract first.
Один слой мы генерируем с моделями. Это слой api. Там просто модели, слой собирается в отдельный артефакт, это jar-файл публикуется в хранилище артефактов Nexus. Разработчик из другой команды, при условии, что у него JVM-стек, подтягивает в зависимости этот артефакт и использует готовые модели для интеграции с нашим сервисом.
Asyncapi – messaging
Асинхронный канал (например, через брокер сообщений, Apache Kafka, Rabbit MQ) – такой же канал передачи информации, как HTTP/TCP/SOAP.
Последнее время разрабатывается формат контракта asyncapi (русскоязычное описание тут), разработчики оценили применение того же подхода, что используется в "REST" поверх HTTP для других каналов данных. Позже даже появились визуальные редакторы.
Храните артефакты
Разработчики обычно работают на одном стеке. В случае JVM стека и джавистам и котлинистам удобнее импортировать к себе в проект артефакт с контрактом сервиса, с которым строят интеграцию. Не важно, синхронная интеграция или асинхронная, – неважен протокол. Если в корпоративной системе описаны каналы взаимодействия, то для этих каналов описывают модели данных, которые для разработчика могут быть упакованы набором DTO в виде java-артефактов. При изменении версии стороннего сервиса, разработчики другого сервиса уведомляются, в импорте поднимают версию библиотеки, им из Nexus или другого репозитория артефактов подтягивается байт-код новых DTO. Во многих случаях не нужно будет перечитывать всю документацию, достаточно починить вызов конструкторов или фабрик для этих моделей.
Для автоматизации можно добавить в пайплайны ci/cd запуск задания gradle (или maven) из того слоя микросервиса, артефакт которого требуется публиковать.
Скрипт сборки gradle на kotlin
publishing { publications { create<MavenPublication>("mavenJava") { artifactId = project.name from(components["java"]) versionMapping { usage("java-api") { fromResolutionOf("runtimeClasspath") } usage("java-runtime") { fromResolutionResult() } } pom { name = project.name description = "Интересное описание" properties = mapOf() scm { connection = "scm:git:git://gitlab.company.name/product/project/name" developerConnection = "scm:git:ssh://gitlab.company.name/product/project/name" } } } } repositories { maven { val releasesRepoUrl = uri( System.getenv("MVN_RELEASES") ?: "https://nex.company.name/repository/maven-releases" ) val snapshotsRepoUrl = uri( System.getenv("MVN_SNAPSHOTS") ?: "https://nex.company.name/repository/maven-snapshots" ) url = if (version.toString().endsWith("SNAPSHOT")) snapshotsRepoUrl else releasesRepoUrl credentials { username = System.getenv("MVN_USERNAME") ?: "" password = System.getenv("MVN_PASSWORD") ?: "" } } } }
Разумеется, тюнинг задания ограничен временем и фантазией.
Генерация asyncapi
Наши команды написали gradle-плагин для генерации DTO на языках java и kotlin по спецификации asyncapi.

Connector + api – как склеивается REST
С интеграциями по HTTP (то, что неопытные коллеги могли бы назвать REST) более-менее всё просто. Входящие запросы принимает @RestController, чьи интерфейсы и модели генерируем по контракту openapi, далее реализуем имплементации интерфейсов или делегаты (кому что нравится).
Для синхронных вызовов других сервисов используем слой connector, который не просто вызывает кастомизированный RestTemplate или его аналог из webflux, но использует как минимум модели (DTO), поставляемые тем самым сторонним сервисом. Если второй сервис спроектирован по этому же паттерну, то он поставляет артефакт с бинарниками моделей (вернее было бы назвать наверно байт-кодом), возможно, с набором интерфейсов (если вы умеете генерировать клиентскую часть). Таким образом, контракт реализуется не на словах, а является основой архитектуры. Так, при изменении контракта на стороне второго сервиса:
выпускается релиз сервиса v1.0.2 (предыдущая версия v1.0.1)
в хранилище артефактов при релизе деплоится артефакт с контрактом second-service-api:1.0.2
когда ваша команда готова, вы заменяете импорт 1.0.1 на 1.0.2
при сборке разработчик вынужден подстраиваться под новый контракт, возможно, реализуя новый маппинг полей.
Конечно, такое использование не панацея: контракт здесь не зависит напрямую от изменения логики во втором сервисе. Также может не помочь в ситуации, когда добавляется +1 поле в моделях или опциональный параметр в методах. Но использование "вещественного" контракта позволяет более внимательно относиться к релизам сторонних сервисов. Наш сервис уже не диктует как будет обращаться к другим сервисам: правила игры устанавливают они, гарантируя выполнение своего контракта.
Конечно, идеально, если такие контракты прорабатываются аналитиками с обеих сторон. При этом контракт фиксируется на определённом сервисе.
Генерация коннекторов к сторонним сервисам
При желании и наличии «вещественного» контракта второго сервиса, с которым предполагается синхронная интеграция (например, артефакт в nexus), можно по нему попробовать сгенерировать клиента.
Например, для gradle и java это может быть как-то так:
Большой скрипт сборки gradle на kotlin
// Здесь или в properties val baseJavaPackage = ru.my.company.service.one // Объявить все нужные спеки для генерации клиентов val clients = listOf( mapOf( "name" to "service2Api", "spec" to "$rootDir/connector/src/main/resources/clients/service-2-openapi.yml", "package" to "client.service2" ) ) val jsonInclude = "com.fasterxml.jackson.annotation.JsonInclude" val generateTaskNames = mutableListOf<String>() // Сформировать задания для генерации clients.forEach { val taskName = "generate${client["name"]?.replaceFirstChar { it.uppercase() }}Client" generateTaskNames.add(taskName) tasks.register<org.openapitools.generator.gradle.plugin.tasks.GenerateTask>(taskName) { generatorName.set("java") inputSpec.set(it["spec"]) outputDir.set("${project.extra["generatedSourcesPath"]}") apiPackage.set("$baseJavaPackage.${it["package"]}.client") modelPackage.set("$baseJavaPackage.${it["package"]}.model") invokerPackage.set("$baseJavaPackage.${it["package"]}.invoker") configOptions.set( mapOf( "generatedConstructorWithRequiredArgs" to "false", "generatedConstructorWithAllArgs" to "false", "useSpringBoot3" to "true", "useSwaggerUI" to "false", "documentationProvider" to "none", "serializationLibrary" to "jackson", "library" to "restclient", "useBeanValidation" to "false", "gradleBuildFile" to "false", "idea" to "true", "interfaceOnly" to "false", "openApiNullable" to "false", "additionalModelTypeAnnotations" to listOf( "@lombok.NoArgsConstructor", "@lombok.AllArgsConstructor" ).joinToString(separator = ";"), "additionalModelTypeAnnotations" to "@$jsonInclude($jsonInclude.Include.NON_EMPTY)", "additionalModelTypeAnnotations" to "@$jsonInclude($jsonInclude.Include.NON_NULL)" ) ) } } // Обязательно чистить перед генерацией tasks.withType<org.openapitools.generator.gradle.plugin.tasks.GenerateTask> { dependsOn("clean") } // Обязательно генерировать перед компиляцией tasks.withType<JavaCompile> { dependsOn(generateTaskNames) }
Генератор, разумеется, можно настроить по своему вкусу, даже заставить писать реактивный котлин.
Использование через бины Spring. Сначала под каждого клиента создать бин-обёртку.
Немного java
@Configuration @RequiredArgsConstructor public class RestClientConfig { private final MyConfigs myConfigs; @Bean @SneakyThrows // По желанию public Service2Api service2Client(MyLoggingInterceptor interceptor) { // Можно объявить бин интерсептора для аудита, выдачи метрик или логирования. Должен быть наследник HttpRequestInterceptor, HttpResponseInterceptor. // Билдер restClientBuilder с обычной реализацией на основе RestClient.builder(), настройкой интерсепторов и, при необходимости, SSL RestClient restClient = restClientBuilder(interceptor).build(); // Указать путь к сгенерированному классу var apiClient = new ru.my.company.ApiClient(restClient); apiClient.setBasePath(myConfigs.getUrlTemplate().formatted(myConfigs.getGatewayHost()))); return new Service2Api(apiClient); } }
Билдер вы наверняка уже используете. Либо можно быстро нагуглить простую имплементацию.
Далее на основе клиентов можно сделать нужное количество компонент-коннекторов в одноименном слое.
Снова немного java
@Slf4j @Component @RequiredArgsConstructor public class Service2ConnectorImpl implements Service2Connector { private final Service2Api api; @Override public MyType getSomeResource(MyId id) { log.info("getSomeResource id = {}", id); return api.getSomeResource(id).getResource(); } }
Разумеется, количество методов ограничено вашей фантазией или фантазией аналитика, в исключительных ситуациях – чекстайлом.
Если нужна гексагональная архитектура, то к этому компоненту добавляется интерфейс-порт, который выкладывается в отдельный пакет доменного слоя.
Мessaging-ext + messaging-int: асинхрон бывает разный
Если говорить о сложных корпоративных системах из множества по-разному отмасштабированных сервисов, то зачастую регулировка нагрузки происходит в том числе за счёт использования партиций брокера. Использование той или иной шины считается сейчас хорошей практикой, и при должном проектировании и настройке упрощает взаимодействие между блоками системы. Но системы внутри компании могут быть разными и, в том числе, общаться между собой асинхронно. Тогда на первый план выходят не вопросы производительности, а вопросы безопасности (авторизация, IAM, аудит и прочее). В таких случаях, при наличии внешнего и внутреннего асинхронного взаимодействия, имеет смысл разделить такие потоки данных, потому что:
разные топики;
разный набор моделей;
у похожих моделей скорее всего разный набор полей;
разные заголовки (может быть связано с трассировкой, аудитом, авторизацией;
разная валидация входящих сообщений.
При наличии внешних асинхронных интеграций, контракт может публиковаться в виде артефакта asyncapi. При необходимости публиковать внутренний контракт может потребоваться также разделить слой на два:
asyncapi-ext – доступен внешним пользователям, возможно. публикуется отдельно;
asyncapi-int – доступен команде, публикуется во внутреннем репозитории для импорта другими сервисами стрима или продукта.
Domain – основа бизнес-логики
На основе контракта openapi/asyncapi можно спроектировать доменные модели и интерфейс взаимодействия с ними (проще говоря, методы, доступные извне); таким образом формируется граница контекста.
В этом слое основной код, в принципе ничем не отличающийся от обычных сервисов на простом DDD, инкапсулируется в одном месте, отделяется от абстракции интерфейсов взаимодействий.
Может быть использован и усложнённый подход: разделение домена на сами модели и их поведение. В таком случае происходит разделение на два слоя:
domain – для сложной предметной области тут множество моделей домена, поддоменов;
domain-logic – сервисы-компоненты, маппинги, интерфейсы доступа к другим слоям.
Разумеется, использование DDD не отменяет ценность SOLID-подхода. Однако, при проектировании реальных систем могут быть нефункциональные ограничения (например, дефицит разработчиков или скорость разработки), которые заставляют гибко трактовать понятие домена, да и вообще Single Responsibility. В этих, реальных, случаях, домен может состоять из очень близких по смыслу и использованию сущностей или к нему могут быть неотделимо "приклеены" поддомены (т.н. HC/LC, о применении такого паттерна много написано, например здесь и здесь). Таким образом, в некоторых случаях экономят на разработке, частично нарушая принцип LC (low coupling): получается высокая связанность объектов. Вот в таких случаях доменный слой может разрастаться, тогда возможно выделение методов работы с доменными моделями в отдельный слой domain-logic. Есть, конечно вариант разделение слоёв по пользовательским сценариям или бизнес-сущностям, как в монолитах. В любом случае слой domain выделять нужно, как отдельный или набор слоёв.
Вид сверху на слои
Таким образом получаются слои:
-
domain – основной слой, здесь данные:
модели;
наборы ошибок (могут быть в слое логики);
могут быть интерфейсы-порты к другим слоям;
могут быть мапперы;
-
domain-logic – может быть отделен от domain, здесь заключена обработка данных:
валидации;
бизнес-логика;
всякая корпоративная мишура;
могут быть интерфейсы-порты к другим слоям;
могут быть мапперы;
-
openapi – слой для аналитика или архитектора:
контракт;
необходимая документация (опционально);
-
api – модуль без классов:
сгенерированные DTO;
возможна сборка и публикация артефакта;
-
web – частично сгенерированный по api:
cгенерированные контроллеры;
могут быть сгенерированные делегаты;
имплементация контроллеров или делегатов;
промежуточные компоненты, могут быть с валидацией и маппингом;
вызов доменных слоев, возможно с некоторой простой агрегацией;
-
persistence – слой работы с базой или кешом, инкапсуляция операций:
DDL и миграция (flyway);
entity, repository;
возможна агрегация вызовов репозиториев в некоторые промежуточные компоненты;
возможно отслеживать выполнение (завершение) транзакций Spring в промежуточных компонентах;
-
asyncapi – контракты для других сервисов:
модели, которые выставляются наружу;
возможен asyncapi в ресурсах;
возможна генерация DTO по контракту;
возможна сборка и публикация артефакта;
-
messaging-ext – модуль внешних асинхронных интеграций, менее доверенные и менее надёжные каналы:
выбор канала отправки;
упаковка сообщений для отправки (формирование эвента);
прием сообщений (KafkaListener) и маппинг на доменные модели;
вызов обработчиков доменного слоя для входящих сообщений;
возможна некая валидация входящих сообщений от внешних продуктов/микросервисов;
-
messaging-int – модуль внутренних асинхронных интеграций, выше уровень доверия к интеграциям, в целом аналогично messaging-ext:
выбор канала отправки;
упаковка сообщений для отправки (формирование эвента);
прием сообщений (KafkaListener) и маппинг на доменные модели;
вызов обработчиков доменного слоя для входящих сообщений;
возможна более простая валидация входящих сообщений;
возможен импорт артефактов с моделями для асинхронных интеграций;
-
connector – модуль внешних синхронных интеграций:
возможен импорт артефактов с моделями для интеграций;
возможны клиенты к другим сервисам;
коннекторы к другим сервисам;
маппинг из доменных моделей в DTO и обратно;
-
app – слой, который собирается в последнюю очередь:
само приложение;
возможен вспомогательный функционал.
Часть слоёв без кода, им тесты не требуются:
openapi – контракт от архитектора;
api – сгенерированные DTO;
asyncapi – контракт и сгенерированные или написанные разработчиком DTO.
Основная часть слоёв с логикой, их покрывать модульными тестами:
domain – могут быть простые валидаторы, которые покрываются тестами;
domain-logic – бизнес логика доменного слоя. Слой может быть совмещён с domain. Модульные тесты на части пользовательских историй, фабрики и валидации;
web – тестировать можно агрегацию и валидацию;
persistence – тестами покрываются реализации и всяческие агрегации если есть;
messaging-ext – тестировать можно конвертацию моделей и валидацию входящих сообщений. При наличии каких-то стратегий определения каналов отправки – тоже покрывать тестами;
messaging-int – тесты аналогично предыдущему слою;
connector – тестами покрывать какие-то хитрые конвертации и валидации при их наличии;
Отдельно – слой приложения app: содержит только приложение для запуска, собирается в последнюю очередь; требует интеграционных тестов.
При таком подходе получается, что на уровне каждого слоя можно:
определять какие зависимости нужны;
определять какие тесты нужны;
писать тесты можно не сразу, а распланировать по слоям, таким образом распределяя эти трудозатраты по разным спринтам и, возможно, разработчикам, а также совмещать сугубо техническую задачу с бизнесовой, то есть и выполнять дорожную карту продукта, и поддерживать качество;
экспериментировать, внедрять новые фичи, пробовать и заменять какие-то библиотеки не сразу, а поэтапно, удешевляя разработку и снижая риски.
Так мог бы выглядеть проект (дерево модулей и пакетов) в IDE
Все ошибаются
Хороший подход – использование доменных ошибок. Если проще говорить – это унификация подхода к обработке, которая, например, позволяет использовать некую стратегию (ещё статья). Этот подход хорошо ложится на DDD (можно через три буквы почитать тут). Например, можно объявить некую иерархию исключений (или ошибок-моделей, смотря что надо) в доменной области, агрегировав их в фабрики и запечатав (инкапсулировав) их создание и отлов в одном месте. В случае использование подхода не через проброс ошибок, а через передачу некого Result<T> = T | ErrorData вместо методов отлова исключений могут быть методы парсинга/сериализации/маппинга для использования например в RestController (упаковка в ResponseEntity) или KafkaTemplate (упаковка в эвент брокера).
Наличие своих, доменных, позволяет абстрагироваться от SDKшных и библиотечных ошибок (перехватывая их), и обрабатывать дальше строго детерминированные ошибки. В любом случае доменные ошибки могут иметь дополнительные поля, необходимые сервису/продукту/платформе, которые разрабатываются.
Как пример, можно иметь что-то вроде такого:
Код на kotlin
Для джавистов специально поясню нюансы.
// Самое важное - базовое исключение // Для джавистов: класс помечен разрешённым для наследования open class BaseException( // Для джавистов: поля, наследованные (переопределённые) у RuntimeException override val message: String? = null, override val cause: Throwable? = null, // Для джавистов: дополнительные аргументы конструктора со значениями по умолчанию // Как маппить на ответы синхронной интеграции. Один из простых вариантов // Значение по умолчанию для кода HTTP тут задано. httpCode: Int = HttpStatus.INTERNAL_SERVER_ERROR.value(), // код ошибки в продукте, очень важно code: String? = null, // дополнительное, можно не использовать если нет требований errorData: ErrorData? = null ) : RuntimeException( errorResponse?.message ?: message, cause ) { // немного некритичной вкусовщины, в дальнейшем может обеспечить гибкость на некоторых слоях абстракции // Для джавистов: новые read-only свойства (поля) класса, помеченные разрешёнными для переопределения open val httpCode: Int = httpCode open val code: String = code ?: this::class.simpleName.toString().createExceptionCode() // ещё немного вкусовщины: эту структуру можно отдавать в ответ на синхронные вызовы, а можно отправить в брокер // Для джавистов: новое read-only свойство (поле) с инициализацией значения при создании объекта val errorData: ErrorData = errorData ?: ErrorData( message = message, httpCode = this.httpCode, code = this.code ) }
Это вариант реализации, я не убеждаю использовать именно такую реализацию. Всё решают требования и их хорошая запись аналитиком.
Дальше архитектура строится с учётом функциональных и нефункциональных требований. Например, для каждого слоя или для каждого порта сделать своё базовое исключение (ошибку), а от них – наследовать уже более конкретные исключения (ошибки). Например так:
Снова kotlin
// Иерархия некоторых сетевых ошибок // Для джавистов: в котлине однотипные классы с общими предками допускается объявлять в одном файле // Для джавистов: класс помечен как доступный для наследования open class NetworkException( message: String? = null, cause: Throwable? = null, httpCode: Int = HttpStatus.BAD_GATEWAY.value(), code: String? = null, errorData: ErrorData? = null ) : BaseException( message = message, cause = cause, httpCode = httpCode, code = code, errorData = errorData ) // Для джавистов: все параметры передаются в конструктор родительского класса. Отличие в параметре по умолчанию httpCode { // Тут могут быть вторичные конструкторы, фабрики, смотря что нужно } // Дальше формировать классы в соответствии с потребностями // Здесь, например, только httpCode по умолчанию другой, возможно переопределить // Для джавистов: по умолчанию публичность, без модификатора, наследники возможны open class ConnectionException( message: String? = null, cause: Throwable? = null, httpCode: Int = HttpStatus.SERVICE_UNAVAILABLE.value(), code: String? = null, errorData: ErrorData? = null ) : NetworkException( message = message, cause = cause, code = code, httpCode = httpCode, errorData = errorData ) // Тут фиксированный httpCode // Для джавистов: по умолчанию публичность, без модификатора, наследники не предполагаются class UploadingDataException( message: String? = null, cause: Throwable? = null, code: String? = null, // При необходимости можно значение по умолчанию, какую-то константу errorData: ErrorData? = null ) : NetworkException( message = message, cause = cause, // Фиксированный HTTP-код ошибки: httpCode = HttpStatus.INTERNAL_SERVER_ERROR.value(), // При необходимости можно фиксированное значение кода (всё равно наследников не будет): code = code, errorData = errorData )
Опять несколько искусственный пример, впрочем, показывающий некоторую иерархию классов.
Если говорить про RestController`ы, то в бине @RestControllerAdvice можно перехватывать базовое исключение, поля которого будут заполнены надлежащим образом, и из него единственным вариантом формировать спринговый ResponseEntity<ErrorData>.
Пример error handling
Допустим, ошибка возникает на слое хранения. В случае Hibernate это обычно не «база недоступна» (такие ошибки чаще возникают до клиентских вызовов, даже в других потоках), а нарушение ограничения, дублирование и прочие SQL-ошибки. В слое хранения реализация т.н. «порта». Интерфейсы в доменном слое.
Получается, в «портовых» слоях ловим ASAP, оборачиваем в ошибку (которая exception или которая data class, в этом контексте не важно), ошибка через интерфейс-порт доходит до доменного слоя (тут не важно, склеены domain и domain-logic), дальше или возвращается в качестве ответа в web client, либо спринговый AOP перехватывает и формирует ответ.
Если же доменная логика вызвана не из web-слоя, то по ошибке формируется event или response, смотря по какой схеме работаем с асинхронным каналом.
Возможен гибридный подход, когда в условную кафку event всегда генерируется, независимо от успешности, и дальше в web возвращается ошибка в каком-то виде, где оборачивается в ответ по HTTP REST.
Итого
Почему критически важно проектировать контракты в распределенной компании?
Во-первых, это единственный источник истины. Контракт, утвержденный архитектором или аналитиком, становится юридически (в техническом смысле) обязывающим документом для обеих сторон интеграции. Он устраняет двусмысленности, которые часто возникают при устных обсуждениях или в переписке.
Более того, Мартин Фаулер в статье "Integration Contract Test" рекомендует подход CDC (Consumer Driven Contracts), который является логическим продолжением Contract First:
Тесты по контракту, определяемому потребителем (CDC) — это техника, которая позволяет командам, зависящим от сервисов других команд, двигаться быстро, не будучи заблокированными. Команда-потребитель пишет набор тестов, который фиксирует её ожидания от сервиса-поставщика. Затем этот набор выполняется поставщиком как часть его CI/CD-конвейера
Этот же подход описан в книге Continuous Delivery от Джеза Хамбла и Дэйва Фарли:
«Контракт, определяемый потребителем, — это набор соглашений между сервисом и его потребителями, который описывает взаимодействия между ними... Этот паттерн позволяет командам разрабатывать и развертывать свои сервисы независимо»
То есть явный контракт позволяет командам работать независимо и параллельно. Пока одна команда реализует сервис, соответствующее контракту, другая может готовить клиентскую часть, мокая ответы на основе той же спецификации. Это значительно сокращает время на интеграцию.
Во-вторых, это инструмент декомпозиции и проектирования доменов. Прежде чем писать бизнес-логику, архитектор вынужден четко продумать, какие данные сервис потребляет и предоставляет, каковы его границы ответственности. Это естественным образом ведет к более чистому разделению на ограниченные контексты (Bounded Context) в духе DDD. Контракт становится артефактом, который можно обсуждать с бизнес-аналитиками, не погружаясь в детали реализации.
Бинарная совместимость как защита от поломок
Храня сгенерированные модели в Nexus, вы превращаете их в настоящую «валюту». Команда-потребитель просто добавляет зависимость на конкретную версию some-service-api. При обновлении версии зависимого сервиса команда-потребитель видит это как изменение версии библиотеки, а не как необходимость переписать половину интеграционного кода. Современные системы сборки сразу покажут конфликты, если две разные библиотеки требуют несовместимые версии одного api.jar. Это прямой механизм обеспечения бинарной совместимости.
Документация, которая всегда актуальна
Для нового разработчика, подключающегося к проекту, наличие артефакта с моделями — огромный бурст. Ему не нужно копировать DTO из чужого репозитория или писать их вручную по документации (которая может устареть). Он добавляет зависимость и получает актуальные, готовые к использованию и согласованные модели. Автодополнение в IDE подскажет все поля. Это само по себе служит отличной, всегда актуальной документацией, резко сокращая количество ошибок.
Разные потребители - один инструмент
Для руководителей и тимлидов: этот подход — инвестиция в предсказуемость разработки. Он снижает риски срыва сроков интеграций, облегчает онбординг новых сотрудников и позволяет командам работать автономно.
Для архитекторов и аналитиков: контракты (OpenAPI/AsyncAPI) становятся вашим основным инструментом проектирования и коммуникации с бизнесом и командами. Слой openapi — это ваша зона контроля и влияния.
Для разработчиков: вы получаете четко очерченную область работы (domain, connector), изолированную от «шатания» контрактов, и готовые, надежные модели для интеграций. Вы тратите время на бизнес-логику, а не на рутинный маппинг данных.
Итоговая архитектура, где контракт первичен, код вторичен, а слои обеспечивают порядок, превращает сложную распределенную разработку из хаотичного искусства в управляемую инженерную дисциплину. Это и есть настоящая «архитектура для людей».
Дисклеймер
Сразу отвечу на вопросы:
Промпт, видимо, был такой: "<играю в тётю Вангу>"?
Разметка очень похожа на LLM, мой мозг после первых пары абзацев пометил как LLM и отказался читать дальше.
просто структурировано просто маячок, а когда по тексту жирным выделяются фразы, это уже для меня ллм детектед
Можно попробовать набрать Alt+0150, Alt+0171 – появятся удивительные символы, неудивительные для русской типографики. А, например, Ctrl + B – выделение жирного текста. Вообще, хоткеи текстовых редакторов для некоторых писателей могут открыть грандиозный новый мир.
А ещё, как ни странно, я пользуюсь запятыми. Так показываю не только успешное окончание 7 класса средней школы, но и – внезапно – уважение к читателю. Надеюсь, это взаимно.
А как вы выделяете слои в сложных программах?
Комментарии (50)

sergey_prokofiev
17.02.2026 19:14Слоистая архитектура для людей
Нет, это написано не для людей.
Для людей бы начали с описания решаемой проблемы: такая то предметная область, столько то сервисов, столько то команд разработчиков. Решили использовать микросервисы и Java как едиснтвенный язык реализации.
И затем уже перешли бы к описанию ваших прости госпроди мавенов, нексусов и прочей фигни, имеющей весьма отдаленное отношение к архитектуре.

shadowphoenix Автор
17.02.2026 19:14Да, можно пойти издалека и налить воды. Да, можно игнорировать NDA и описать предметную область. Это было бы втрое больше.
Да, можно было бы написать ADR и C4, но это была бы другая статья.
Да, можно было написать верхнеуровневые требования и спроектировать платформу. Но это тоже была бы другая статья.
У меня не было таких целей.
Предложенные решения можно применять к разным предметным областям в энтерпрайзе.
Я не описываю архитектуру всей системы, уровень абстракции гораздо ниже. Я не описываю продукт, и не хотел.
Зачем я буду писать, что выбирал джаву и мавен (не знаю, какую вы статью про мавен читали, не уверен, что эту), если я не выбирал это?

sergey_prokofiev
17.02.2026 19:14Да, можно было бы написать ADR и C4, но это была бы другая статья.
Да, можно было написать верхнеуровневые требования и спроектировать платформу. Но это тоже была бы другая статья.
Я не описываю архитектуру всей системы, уровень абстракции гораздо ниже.
Да, действительно зачем в статье с названием про архитектуру писать что-то про архитектуру.
У меня не было таких целей.
Тогда возможно стоило бы назвать статью например "структурирование кода микросервисов на джаве".
Зачем я буду писать, что выбирал джаву и мавен
Да не пишите что вы их выбирали. Напишите сверху что статья про джаву и мавен. Чтобы люди, которым джава монописуальна сразу закрыли вкладку и не ломали себе глаза.
ИМХА, предложенный подход выглядит несколько эм... нестандартно, и деплоймент микросервисов в "по слоям" выглядит оверинжинирингом(а разбиение отвественности людей "по слоям" также не выглядит самым лучшим менеджерским решением). Но оправдано ли это - мы никогда не поймем потому что не знаем ничего о контексте в котором принимались эти решения и какие альтернативы рассматривались. А может это вообще best practices в экосистеме java, но наверное и это тоже стоило бы упомянуть.

shadowphoenix Автор
17.02.2026 19:14По меткам и хабам должна быть отсылка к java.
Но стоит в тизере указать ещё раз, возможно.

sergey_prokofiev
17.02.2026 19:14Но стоит в тизере указать ещё раз, возможно.
Я думаю стоит убрать слово "архитектура" из названия и все станет на своим места.

shadowphoenix Автор
17.02.2026 19:14Отсылки к бест практис и книгам по ссылкам в статье. Если никогда не читать и не пользоваться - то никогда не узнать, верно.

sergey_prokofiev
17.02.2026 19:14А остылки на гугл в конце статьи нет? Если никогда не искать то и не найдешь, верно ж. Для людей(ц)

shadowphoenix Автор
17.02.2026 19:14Да, действительно зачем в статье с названием про архитектуру писать что-то про архитектуру.
"Что-то" не хочу, я не индус и не Маяковский, нет KPI по строкам.
ADR и C4 неприменимы в этом контексте и на этом уровне абстракции, он слишком низко находится для таких аббревиатур. Употребление этих терминов потребовало бы совсем другую статью, другую целевую аудиторию и другие хабы. Нельзя в одной статье рассказывать и поо контекст контейнера, и про слои микросервиса. Было бы смешение уровней абстракций.

sergey_prokofiev
17.02.2026 19:14Нельзя в одной статье рассказывать и поо контекст контейнера, и про слои микросервиса.
Да, но только архитектура - это про ADR. Про нефункциональные требования и работу с ними. А про слои внутри микросервиса - это даже не low level design. NFR выполняется, контракты соблюдены - да и пофиг что внутри, оно же МИКРО и если там говнокод - то он и останется локализованным и управляемым.

Dhwtj
17.02.2026 19:14Контракты и API (а здесь указаны только внешние API) вещи разные. А тут всё в одну кучу: монолиты, микросервисы, зелёное, длинное.
Микросервисы как-то не слоистая совсем, наоборот: режем по модулям и каждый имеет все слои.
Разработка API по принципу «сначала контракт» приобрела популярность с появлением сервисно-ориентированной архитектуры (SOA), а позже — с развитием микросервисной архитектуры
Но к монолиту подходит. Очень распространено для описания связи фронт бэк так как компилятор её проверить не может. И ещё для связей между доменами в ддд

shadowphoenix Автор
17.02.2026 19:14Всё так, некоторые подходы по отдельности можно использовать с монолитом, можно комбинировать. Те же слои или DDD-части в монолите могут проектироваться по другой логике, спору нет.

SolidSnack
17.02.2026 19:14Наверное контракт это некое ограничение в коде, например интерфейс, что для меня, как разработчика, сразу понятно. А вы про какие-то контракты в текстовом виде что-ли? Под нейросетки готовитесь?)) Замаскировали слово промпт))
Иначе я не понимаю что за контракты могут храниться в конфлюенсе, или рядом с задачей в жире...
Вот такой контрак - делаем хорошо, плохо не делаем. Возле каждой задачи
Вообще это ещё 1 винегрет из "архитектурных" терминов (DTO, DDD, контракты, интерфейсы, слои и тд и тп) а по сути смысловая нагрузка 0 (сугубо моё мнение). У вас такое количество слоев, половина без кода (????) Ошибки генерятся со слоя домена, а если к базе не получится подключиться, эта ошибка будет с доменного слоя тянуться, а не со слоя с базой данных?)
Вот вам и протекание вашей архитектуры, воды налили, слова использовали, а толку...

shadowphoenix Автор
17.02.2026 19:14Люди в конфлюенс выкладывают текстовые описания API например.
Дифы, правки моделей описывают в комментариях к задачам.
Документация может лежать в папочке на сетевом диске.Как только не хранят контракты и договоренности!
Тут вопрос в организации процессов разработки, зрелости команды, наличия инструментов. Например, почти на каждом проекте мне дают почитать
OpenAPIсинхронную интеграцию (модели и методы) как таблицу на странице в конфлюенсе. И во многих компаниях это считается нормально.

shadowphoenix Автор
17.02.2026 19:14Какая именно половина без кода? Можно поинтересоваться алгоритмом подсчета?

SolidSnack
17.02.2026 19:14Часть слоёв без кода, им тесты не требуются:
Ваша цитата, даже 1 слой без кода это много, переубедите меня))

shadowphoenix Автор
17.02.2026 19:14Зачем переубеждать?
Один из вопросов вселенной и вообще всего:
3 - это кучка?
Ясно только, что 1 - не половина (в контексте статьи).

shadowphoenix Автор
17.02.2026 19:14Ошибки генерятся со слоя домена, а если к базе не получится подключиться, эта ошибка будет с доменного слоя тянуться, а не со слоя с базой данных?)
Если обработать на слое хранения, до за свой слой она не выпадет никуда. Аналогично ошибки от условного RestTemplate.

SolidSnack
17.02.2026 19:14Как запрос вообще может добраться до домена если проект к базе даже не подключился?))

9lLLLepuLLa
17.02.2026 19:14База может отвалиться в процессе работы

shadowphoenix Автор
17.02.2026 19:14Обязательно отвалится! Как и брокер, и редис с сентиэлями. Но, как правило, это не в рамках бизнес сценариев. И зачастую не в тех потоках.
В идеале мониторщики с елк или Prometeus-Tempo-Loki-Grafana помогают.

shadowphoenix Автор
17.02.2026 19:14Stateless?
Вместо БД кеш?
Разные варианты бывают. Не понятна суть вопроса.

shadowphoenix Автор
17.02.2026 19:14Насчет проброса ошибок. В принципе, от слоёв ddd отличий почти нет.
В "портовых" слоях ловим asap, оборачиваем в ошибку (которая exception или которая data class, в этом контексте не важно), ошибка через интерфейс- порт доходит до доменного слоя (тут не важно, склеены domain и domain-logic), дальше или возвращается в качестве ответа в web client, либо спринговый aop перехватывает и формирует ответ.
Если же доменная логика вызвана не из web-слоя, то по ошибке формируется event или response, смотря по какой схеме работаем с асинхронщиной.
Возможен гибридный подход, когда в условную кафку event всегда генерируется, независимо от успешности, и дальше в web возаращается.
Так что отвечая на ваш вопрос
будет с доменного слоя тянуться, а не со слоя с базой данных?
Да, доменное исключение с доменного слоя
И
эта ошибка
Нет, не "эта". Сама ошибка jpa инкапсулирована в своем слое хранения и не идет за границы, как, например, не идут dao-модели.
А вот на этот вопрос:
а если к базе не получится подключиться
ответ такой: это подключение в Java-spring-data происходит несколько раньше клиентского вызова, не в иомент обработки запроса. Условно говоря, его делают нагенерированные спрингом классы, стартующие с сервисом.

SolidSnack
17.02.2026 19:14Я не знаю что такое DDD слои, вы говорите много, а толку...

shadowphoenix Автор
17.02.2026 19:14Можно перейти по ссылкам в статье, почитать упомянутые книги, может появится толк.

shadowphoenix Автор
17.02.2026 19:14Вот вы пишете, что были на 25+ собеседованиях. А я - на 250+, и чуть меньше - сам проводил.
Это архитектурная статья, не совсем для разработчиков. Для ее прочтения нужно как минимум хорошо понимать такие буквы, как DRY, ACID, SOLID, CAP, API, TCP, SDK, MQ, REST, RPC, RESTFul, k8s, DDD, а еще владеть буржуйским языком, в котором: concurrency, api first, swagger, asyncapi, и много чего другого.
И статья в основном про глубокую степень контейнеризации элементов системы, сиречь jvm-стек в оркестрации.
И это я не упомянул массу других нюансов, как безопасность и инкапсуляция инфраструктуры и данных.
Толк будет, если всё это изучить. Таков беспощадный оскал современного облачного энтерпрайза.

leva1981
17.02.2026 19:14Отличная статья. Но довольно сложная для восприятия, потому что тема на самом деле ооооочень многогранная и сложная.
Было бы очень круто увидеть демонстрационный проект. Это позволило бы ответить на большое количество вопросов, а тем, кто готов этим пользоваться - позволило бы намного быстрее начать.
shadowphoenix Автор
17.02.2026 19:14Да, тема важная, горячая, сложновата. Я давно варюст в таком энтерпрайзе, для меня это скорее средний уровень, но перестраховался и поставил сложный. Как оказалось - не зря. (Такое ощущение, что комментируют не читая).
Сам проект - увы. Для написания питомца тоже сложновато, нет столько времени. И для питомцев все это не нужно.
Но. Я тут уговариваю электронного товарища сварганить картинку - имитацию дерева проекта в идее. Добавлю в статью, лучше должно стать.

leva1981
17.02.2026 19:14Не, не, не)) Вместо картинки лучше уж в самых простых вариантах пример. Я сам сторонник такого подхода, но подступиться очень тяжело. И самому тяжело и плюс сопротивление коллег как правило нешуточное. Какое там апи, которое аналитики напишут с архитектором... Сваггер + @Schema наше все, rest-service-dao самые лучшие и понятные слои. И вообще, работать надо, а не фигней всякой страдать, времени на это сейчас нету...

shadowphoenix Автор
17.02.2026 19:14Да, вы говорите про слои ddd, как кажется, я тоже когда-то делал так или dto-api-impl(domain-dao-web).
Верно, работать надо)
Наверно со временем нейронкой по картинке сгенерирую прототип, чтоб подсунуть в статью в качестве примера.

shadowphoenix Автор
17.02.2026 19:14А чему коллеги сопротивляются, если не NDA? И какие аргументы?
Быстро структуру проекта можно показать только так

leva1981
17.02.2026 19:14Не NDA конечно. Обычные человеческие слабости. Сложно, не нужно, непонятно, непривычно, избыточно...
За структуру спасибо, очень интересно! Есть несколько вопросов.
1) АПИ в отдельном слое/модуле - ок. Домен в отдельном слое/модуле - ок. Но насколько оправданно выносить из слоя api слои connector, messaging-int, web? Не было бы удобнее перенести их в app ? Ведь по сути это то, что нужно для работы приложения.
2) Почему домен разбит на два модуля - domain и domain-logic? Не является ли это избыточным разбиением? Сущности домена - анемичные или с логикой? Если с логикой, то не является ли избыточным domain-logic и почему дополнительная логика не реализована в app/service вместо целого модуля domain-logic?
3) Насколько оправдан вынос entity из домена? Я имею ввиду, почему не стали реализовывать entity используя доменные объекты, а вместо этого решились на введение дополнительных мапперов и конвертаций? Насколько это оправданно и удобно?
4) Где тесты (вижу только пакет с интеграционными) ? :)
shadowphoenix Автор
17.02.2026 19:14Набор слоёв частично пришёл "сверху", немного другой. Добавить получилось, убрать - нет. Громко ныл на собраниях, в будущем доменные слои может удастся склеить. Но будет уже много кода, и будет не так просто убирать.
1 Connector, messaging, persistence - имплементация портов, это про гексагональность. В будущем действительно может измениться тип СУБД. Остальные имплементации вряд ли. У меня тоже сомнения в уместности.
Что касается коннекторов. Обращения к общим, системным сервисам, постепенно выносится в корпоративную библиотеку, так что там скоро останутся импорты. Обращения к другим сервисам продукта сейчас пытаются кодогенерировать по контракту, так что, наверно, со временем количество кода в этом слое уменьшится.
Что касается messaging. Если условно приватные и публичные интеграции. Такие контракты утверждаются разными способами, чуть разные требования. Разделение на внешние и внутренние позволит лучше контролировать рзменения, и показывает разработчику, что можно шатать, а что - ни в коем случае.
Что касается app. Там интеграционные тесты и отдельный подсчет покрытия. Имхо получился сплав жёсткого насаждения и компетенций девопсеров. С продуктовой точки зрения наверно бесмыссленный слой, только приложение, стартующее всю спринговую машину.

shadowphoenix Автор
17.02.2026 19:142 Разбиение домена на модели и логику насадили извне можно сказать. От этого вижу один сравнительно маленький плюс: слои чуть меньше, модели почти не меняются, меняется логика, оптимизация сборки градлом. Слабый аргумент. Думаю, через год может что-то измениться, когда получим опыт разработки и поддержки.
Доменные модели лично я запрещал с логикой. Дата-классы. В других командах мягче требования.
Дополнительную логику от дата-классов выносили в экстеншены (это в котлине типа утилитарных методов), отдельно, но при необходимости. Это обычно для логов, мониторинга, упрощение обращений к каким-то полям. Ничего длинного и сложного.

leva1981
17.02.2026 19:14Принял по всем пунктам, большое спасибо за развернутые ответы!
"Доменные модели лично я запрещал с логикой. Дата-классы." - тут просто решили не связываться с DDD ?
shadowphoenix Автор
17.02.2026 19:14Логика рядом, чем не DDD? Может не очень канонично.
Там вопрос еще расчета покрытия тестами. Интерфейсы и дата-классы не нуждаются. По маскам имен и путей проще исключать единообразно.

shadowphoenix Автор
17.02.2026 19:143 А вот вынос DAO оправдался. За счёт Contract First сложная вложенность классов в DTO, почти 1-к-1 маппится на доменную модель, она тоже "структурированная", в одном из продуктов видно. На это опираются валидации и всякие пользовательские истории. А вот модели хранения упростили, "уплостили", в одном из продуктов навесили проекций.
Для будущей поддержки, поиска данных должно быть проще. Ну и часть искусственных требований нам выдвинули.
Но, к слову, там удалось модели сделать так, чтоб чуть оптимизировать хранение и поиск. Использовали особенности РСУБД. Еще один аргумент за слой persistence.

shadowphoenix Автор
17.02.2026 19:144 Юниты в каждом слое с кодом кроме app. Вроде писал. Рисовать не стал, чтоб не мусорить.

AlexViolin
17.02.2026 19:14Представляется совершенно логично, что domain содержит 2 раздела - Entities и Logic. Совершенно непонятно зачем его разбивать на 2 модуля.

shadowphoenix Автор
17.02.2026 19:14В предыдущих коммантариях отвечал. Если кратко - это решение "сверху".
Постарались получить плюсы от такого подхода.

shadowphoenix Автор
17.02.2026 19:14О, у вас такая же статья для решётки.
Не знал, насколько onion популярен в других ЯП.
gybson_63
Хорошо забытое
Запись на стене
И почему бы не остаться на SOAP + XDTO и т.п.?
shadowphoenix Автор
wsdl
Шаг влево или вправо - расстрел и тотальное падение. С большим количеством сервисов это пытка.
Думаю, это такая ирония.
gybson_63
Реализация SOAP на JSON тоже странная затея. Как-будто игра в энтерпрайз, наклеим усы и притворимся большими.
shadowphoenix Автор
Не напоминайте. Я же это видел.
shadowphoenix Автор
Если отсылка к публикации контрактов, то скажу вот что: часть контрактов может быть платформенная или от СБ, она особо не меняется и обязана поддерживаться, там гибкость может вредить.
Публикация моделей не заставляет абсолютно всех абсолютно все модели использовать. Тут пространство для проектирования и выбора.
Лично мне на моём проекте это полезно.
Такую особенность можно включать и выключать, это не краеугольный камень слоистости, а доп. опция.
SOAP, всё ж, строже намного.
gybson_63
Я просто пытаюсь увидеть, в пределе это выльется в "SOAP", когда вдруг окажется, что риски несоблюдения значительно выше соблюдения контрактов или нет.
Потому что это совсем не редкость, когда в процессе возникает : "Аааа, так вот почему они так сделали".
Ведь JSON и появился для того, чтобы сначала делать, а потом думать. Теперь свежая идея - сначала подумать, нарисовать, спроектировать. А как добиваться соблюдения договоренностей? Где-то достаточно физической силы, а другим нет.
Насколько в обратную сторону качнется, интересно же.
И эти агенты ИИ, которым палец в рот не клади. Там может и еще строже что придумают.
shadowphoenix Автор
Соблюдать договоренности QA заставят, частично они - тот барьер между разработкой и хаосом. Если нормальный коллектив и нормальные процессы.
А вообще "заставить соблюдать договоренности" - тема больная. Можно решать по-разному.
shadowphoenix Автор
Нет, надеюсь, до SOAPа не дойдёт, хотелось бы баланса.
gybson_63
Ставлю на PYON
shadowphoenix Автор
Ну и SOAP придумали в другое время, когда еще яндекс не обучал всех подряд, а систем было на порядки меньше.