
Для кого статья: для техлидов и системных аналитиков (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 класса средней школы, но и – внезапно – уважение к читателю. Надеюсь, это взаимно.
А как вы выделяете слои в сложных программах?