Разрабатывая информационную систему с нуля, мы можем выбрать практически любой вариант технологии и архитектуры в целом, в том числе — принцип взаимодействия частей системы. Но что делать, если система уже есть и у неё довольно богатая история? Как большую энтерпрайз систему, которая развивалась в режиме монолита, разделить на микросервисы и организовать взаимодействие между ними?
Часто основная сложность заключается в том, что нужно одновременно поддерживать уже существующий код монолита и параллельно внедрять новые принципы и подходы. В статье я расскажу, как мы в Wrike, используя reverse engineering и немного кодогенерации, реализовали первые шаги по выделению отдельных микросервисов и запустили первый «почти настоящий» BFF-сервис в рамках нашего монолита.

Привет! Меня зовут Слава Тютюньков, я Backend Tech Lead в Wrike. В этой статье я хочу поговорить о том, как мы в backend-команде готовились к работе с монолитом, чем в этой задаче нам помог reverse engineering, как мы использовали кодогенерацию, с какими сложностями столкнулись в процессе и что получили в итоге.
Как система выглядит сейчас и к чему мы хотим прийти
Wrike — это SaaS-решение для совместной работы и управления проектами. Архитектура системы представляет собой распределенный монолит — одно большое веб-приложение и около сотни различных дополнительных сервисов рядом. Но несмотря на многообразие сервисов мы не можем назвать текущую архитектуру микросервисной: сервисы работают с общей базой данных, лежат в монорепозитории, большая часть логики сосредоточена в нескольких крупных модулях, разделяемых между всеми сервисами.
При этом у монолита много различных потребителей API: основной web-клиент, мобильные приложения, публичные API и интеграции.

Мы довольно продолжительное время работаем в рамках такой архитектуры, у нас неплохо выстроены процессы вокруг. Например, мы деплоимся ежедневно, обновляя при этом как часть системы, так и всю систему полностью. Но у подобной архитектуры (как и у любой другой) есть недостатки. Мы понимаем, что по мере развития и роста компании нам так или иначе станет «тесно» в рамках монолита. Поэтому мы постепенно движемся в сторону разделения монолита на микросервисы.
Мы хотим, чтобы архитектура в итоге выглядела примерно так:

Но сделать это не так просто по разным причинам:
- Продукт не стоит на месте: мы постоянно развиваем его, изменяем функциональность, добавляем новые фичи и т.д. 
- Есть технические аспекты: код и модули тесно связаны. 
- Проблемы обратной совместимости по API. Например, у мобильного приложения Wrike отдельный релизный цикл. Нам сложно изменить что-то в монолите и не затронуть при этом мобильное приложение. 
Поэтому в первую очередь мы решили изолировать API для мобильного приложения, вынеся его в отдельный сервис — BFF. Таким образом мы сможем отделить монолит от внешнего потребителя API и создать «фасад» для монолита.

BFF позволит нам сосредоточиться на изменениях в самом монолите, его устройстве, внутренних коммуникациях и вынесении отдельных фрагментов. При этом мы сможем не бояться что-то сломать и сделать API неконсистентным.

Постепенно выделяя микросервисы, мы хотим прийти к такой архитектуре:

Готовимся к работе
Когда мы собираемся проектировать новую систему или менять существующую, подготовка — важный этап. Чтобы процесс изменения системы не усложнял жизнь всей команде, нужно договориться об огромном количестве нюансов и решений —технических, инфраструктурных и организационных. В этом тексте мы рассмотрим техническую составляющую — организация инфраструктуры заслуживает отдельной статьи.
О чем мы решили договориться заранее:
Протокол взаимодействия микросервисов. Мы выбрали REST-Like и JSON в качестве транспорта. Мы рассматривали и другие варианты вроде gRPC или RSocket, но нас устроил REST: мы пошли по «консервативному пути» — большинство разработчиков в команде умеют с ним работать, поэтому на первых этапах внедрения ребятам будет проще и удобнее.
Библиотеки. В качестве клиента мы договорились использовать Retrofit2, Jackson — в качестве библиотеки для маппинга JSON-ов.
Описание схемы. Еще одна проблема монолита — у эндпоинтов нет описания, есть только код. При работе в микросервисном мире подобное недопустимо: без описания API невозможно построить нормальное взаимодействие между микросервисами. Опираясь на предыдущий выбор (REST+JSON), логичным способом описания стал OpenAPI.
Организация процесса. Описанного API в виде схемы недостаточно, необходимо контролировать и гарантировать, что реальный интерфейс сервиса соответствует имеющейся схеме. Мы решили использовать подход Schema-First. В рамках этого подхода разработчик напрямую не может менять в коде интерфейс и настройки эндпоинта, все происходит через схему — меняется схема, меняется интерфейс сервиса. А если реализация не соответствует схеме, сервис просто не соберется и не запустится.
В качестве «основы» для микросервисов мы выбрали довольно стандартное решение — Spring Boot и Spring MVC.
Дальше нужно было выбрать первую «жертву».
Параметры, по которым мы выбирали:
- Отсутствие собственного домена. 
- Узкоспециализированный API. 
- Отдельный релизный цикл. 
- Особые требования обратной совместимости. 
В итоге мы решили создать BFF для Android-приложения:
- Мобильное приложение покрывает большой объем функциональности системы. У него есть своя специфика, но для него нельзя выделить один домен. 
- Мобильное приложение разделяет общие эндпоинты с web-версией, но у него своя специфика построения интерфейса и загрузки данных. Наличие интерфейса, специализированного и оптимизированного под мобильное приложение — важный фактор, и BFF как раз может его решить. 
- Монолит деплоится ежедневно, мобильное приложение не может себе такого позволить. Более того, миграция пользователей на новые версии происходит довольно медленно. 
- Нам необходимо поддерживать обратную совместимость эндпоинтов продолжительное время. Сейчас с Android-командой действует соглашение о сохранении обратной совместимости как минимум полгода. 
Шаг первый: создаем сервис BFF
Для начала рассмотрим часть взаимодействия от мобильного приложения к монолиту через BFF.

Мы создали пустой сервис, настроили его и запустили:

Все заработало — сервис есть, трафика пока что нет, но первый этап мы прошли.

Шаг второй: разбираемся, где брать данные
Мы строим BFF в качестве «фасада» к монолиту. В этом случае логично брать данные из монолита. Мы это делаем, используя его текущий API.
Выбираем способ получения данных. Получить данные можно несколькими способами. Первый — использовать кастомный клиент. Если клиент позволяет получать данные через REST или внутренние коммуникации с монолитом, можно использовать его. В нашей ситуации такой клиент был, но не подходил для наших задач: у нас есть публичный API, но для требований Android-приложения этого было недостаточно. В частности есть различия в модели и представлении данных: Android-приложение ориентируется на внутреннюю специфику, недоступную через публичный API.
Тогда мы решили посмотреть на монолит как на большой микросервис и попытались встроить его в общую архитектуру. Для этого нужно было создать схему и описать монолит в общих терминах.
Если в компании система уже описана какой-либо схемой, то можно использовать готовую. В нашем случае каждая команда по-своему описывает протокол взаимодействия с фронтендом, и общей схемы нет. Поэтому нам нужно было ее создать.
Создаем схему. Если схема нужна для небольшого под-домена или небольшого подмножества эндпоинтов в монолите, то можно описать ее вручную. Скорее всего, возникнет вопрос с актуализацией, но в целом это возможно. Мы не хотели вручную писать схему для 150 эндпоинтов, поэтому этот способ нам не подошел.
Еще один вариант — использовать готовое решение. Например, если эндпоинты описаны через Spring MVC, то библиотека springdoc-openapi позволяет по имеющимся аннотациям получить схему. Но мы используем кастомный web-фреймворк, поэтому такой способ нам тоже не подошел.
Тогда мы решили написать подобную библиотеку самостоятельно.
Здесь нам и пригодился реверс инжиниринг. Мы проанализировали эндпоинты: оказалось, что большая часть из них (почти все интересующие нас) выглядят похожими друг на друга.
@HandlerMetaInfo(
       tags = "navigation",
       path = "api/navigation_settings",
       method = HttpMethod.PUT,
       securitySchemas = {}
)
public class PutNavigationSettings implements SchemaHandler<Input, Output> {
   protected Input parseRequest(final HttpServletRequest request) {
       return new Input(
               Integer.parseInt(request.getParameter("mode")),
               Stream.of(request.getParameterValues("items"))
                       .map(NavigationItem::fromString)
                       .filter(Objects::nonNull)
                       .collect(Collectors.toList())
       );
   }
   protected Output processRequest(final Input input) {
       /// save to db
       return new Output(input.getItems());
   }
   static class Input {
       private final int mode;
       private final List<NavigationItem> items;
       Input(final int mode, final List<NavigationItem> items) {
           this.mode = mode;
           this.items = items;
       }
       public int getMode() {
           return mode;
       }
       public List<NavigationItem> getItems() {
           return items;
       }
   }
   static class Output {
       private final List<NavigationItem> items;
       Output(final List<NavigationItem> items) {
           this.items = items;
       }
       public List<NavigationItem> getItems() {
           return items;
       }
   }
}Input (в нашем случае — отдельный класс, которые описывают модель), Output (то, что мы отдаем клиенту) и некоторая мета-информация в аннотация и/или конфигах.
Эта структура легко ложится на схему OpenAPI:

Схема выглядит стандартной и несложной. Мы написали библиотеку, которая генерирует схему по структуре эндпоинтов и собрали полный список — получилось порядка 150. Затем запустили обход всех эндпоинтов и получили схему. После опубликовали схему в artifactory, чтобы переиспользовать в BFF. Также это нужно, чтобы фронтенд мог взять схему и на своей стороне получить декларативный клиент.
Получается, что мы описали схему монолита, и для BFF он теперь выглядит практически как микросервис. Большой и не очень удобный, но с ним можно работать.

Шаг третий: из схемы в код
Дальше в дело вступила кодогенерация. С помощью схемы мы получили декларативный клиент для BFF. Чтобы получать данные из монолита, мы запроцессили схему через OpenAPI Generator и добавили особенности, которые были нужны в нашем случае.
Получили по схеме все нужные модели:
@JsonPropertyOrder({
       JSON_PROPERTY_MODE,
       JSON_PROPERTY_ITEMS
})
public class PutNavigationSettingsDto {
   static final String JSON_PROPERTY_MODE = "mode";
   static final String JSON_PROPERTY_ITEMS = "items";
   private final int mode;
   private final List<NavigationItem> items;
   @JsonCreator
   private PutNavigationSettingsDto(@JsonProperty(JSON_PROPERTY_MODE) final int mode,
                                    @JsonProperty(JSON_PROPERTY_ITEMS) final List<NavigationItem> items) {
       this.mode = mode;
       this.items = items;
   }
   @JsonGetter(JSON_PROPERTY_MODE)
   public int getMode() {
       return mode;
   }
   @JsonGetter(JSON_PROPERTY_ITEMS)
   public List<NavigationItem> getItems() {
       return items;
   }
   public static Builder builder(final int mode) {
       return new Builder(mode);
   }
   public static final class Builder {
       private final int mode;
       private List<NavigationItem> items;
       public Builder(final int mode) {
           this.mode = mode;
       }
       public Builder withItems(final List<NavigationItem> items) {
           this.items = items;
           return this;
       }
       public PutNavigationSettingsDto build() {
           return new PutNavigationSettingsDto(mode, items);
       }
   }
}@JsonPropertyOrder({
       JSON_PROPERTY_ITEMS
})
public class PutNavigationSettingsResponseDto {
   static final String JSON_PROPERTY_ITEMS = "items";
   private final List<NavigationItem> items;
   @JsonCreator
   private PutNavigationSettingsResponseDto(@JsonProperty(JSON_PROPERTY_ITEMS) final List<NavigationItem> items) {
       this.items = items;
   }
   @JsonGetter(JSON_PROPERTY_ITEMS)
   public List<NavigationItem> getItems() {
       return items;
   }
   public static Builder builder() {
       return new Builder();
   }
   public static final class Builder {
       private List<NavigationItem> items;
       public Builder() {
       }
       public Builder withItems(final List<NavigationItem> items) {
           this.items = items;
           return this;
       }
       public PutNavigationSettingsResponseDto build() {
           return new PutNavigationSettingsResponseDto(items);
       }
   }
}Получили декларативный Retrofit2-клиент:
public interface NavigationSettingsApi {
   @Headers({
           "Content-Type:application/json"
   })
   @PUT("navigation_settings")
   Call<PutNavigationSettingsResponseDto> updateNavigationSettings(@Header("Authorization") String authToken,
                                                                   @Header("account") Account accountId,
                                                                   @Body PutNavigationSettingsDto input);
}Это позволило «объявить» клиент в сервисе, использовать его для работы и не думать о том, работаем мы с монолитом или микросервисом.

Шаг четвертый: проксируем
Чтобы минимизировать трудозатраты команды мобильных разработчиков, на первом этапе мы решили максимально сохранить текущий протокол и поменять только эндпоинт — адрес, куда «ходит» мобильное приложение. BFF в нашем случае получился проксирующим с небольшим добавлением специфики монолита.
Мы описали схему BFF, переиспользуя те компоненты, которые получили на предыдущем шаге:
/navigation_settings:
 put:
   tags:
     - navigation
   requestBody:
     required: true
     content:
       "application/json":
         schema:
           $ref: '#/components/schemas/NavigationSettingsPutRequest'
   responses:
     200:
       description: OK
       content:
         application/json:
           schema:
             $ref: "#/components/schemas/NavigationSettingsPutResponse"Дальше мы сгенерировали интерфейсы для Spring MVC. Чтобы минимизировать количество ошибок в коммуникации между микросервисами, мы следуем правилу — разработчики самостоятельно не описывают интерфейсы эндпоинтов в микросервисах. Подход Schema-First постулирует, что описывается всегда только схема, а интерфейсы генерируются автоматически. Это уменьшает риск ошибки разработчика в имплементации и гарантирует консистентное взаимодействие между микросервисами.
@Validated
public interface NavigationControllerApi {
   @RequestMapping(value = "/navigation_settings",
           produces = {"application/json"},
           consumes = {"application/json"},
           method = RequestMethod.PUT)
   @PreAuthorize("#principal.accountId != null")
   default ResponseEntity<WrikeResponseDto> navigationSettingsPut(@AuthenticationPrincipal final AuthInfo principal,
                                                                  @Valid @RequestBody final NavigationSettingsPutRequestDto navigationSettingsPutRequestDto) {
       return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
   }
}Аналогичным образом генерируем модели.
Проксируем запросы, используя Retrofit2-клиент:
@Override
public ResponseEntity<WrikeResponseDto> navigationSettingsPut(final AuthInfo principal, @Valid NavigationSettingsPutRequestDto input) {
   final Response<PutNavigationSettingsResponseDto> response;
   try {
       final WrikeToken wrikeToken = principal.getWrikeToken();
       final String authToken = wrikeToken.getBearerToken();
       response = navigationSettingsApi.updateNavigationSettings(
                       authToken,
                       wrikeToken.getRequestAccountId().get(),
                       PutNavigationSettingsDto.builder(0)
                               .withItems(input.getItems())
                               .build()
               )
               .execute();
   } catch (final IOException e) {
       log.error("", e);
       return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
   }
   if (response == null) {
       log.warn("[PUT] Response is null");
       return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
   }
   if (!response.isSuccessful()) {
       return ResponseEntity.status(response.code()).build();
   }
   final PutNavigationSettingsResponseDto data = response.body();
   return ResponseEntity.ok(
           WrikeResponseDto.builder(true)
                   .withData(data)
                   .build()
   );
}Логика обработчика описывается следующим образом:
- Берем входные данные из запроса. 
- Добавляем необходимые заголовки. 
- Используя полученный декларативный клиент, делаем запрос к монолиту. 
- Полученные данные оборачиваем и отдаем в ответ клиенту. 
Мы закрыли последний этап на схеме, имплементируя взаимодействие между мобильным клиентом и монолитом через BFF.

В первой версии мы задеплоили именно такой вариант, и он заработал. Правда были небольшие проблемы с проксированием и сбором метрик, но пилотный проект как раз и нужен, чтобы собрать проблемы и исправить их.
Анализируем, что получилось
Кажется, что все хорошо, можем продолжать пилить микросервисы. Но давайте чуть внимательнее посмотрим на проксирующий код эндпоинов в BFF — тот самый код, который позволяет получать данные.
По сути это обращение к монолиту через использование общей архитектуры. При работе с микросервисом у нас будет все то же самое.
Давайте взглянем на реализацию проксирования запросов еще раз:
Pеализация navigationSettingsPut
@Override
public ResponseEntity<WrikeResponseDto> navigationSettingsPut(final AuthInfo principal, @Valid NavigationSettingsPutRequestDto input) {
   final Response<PutNavigationSettingsResponseDto> response;
   try {
       final WrikeToken wrikeToken = principal.getWrikeToken();
       final String authToken = wrikeToken.getBearerToken();
       response = navigationSettingsApi.updateNavigationSettings(
                       authToken,
                       wrikeToken.getRequestAccountId().get(),
                       PutNavigationSettingsDto.builder(0)
                               .withItems(input.getItems())
                               .build()
               )
               .execute();
   } catch (final IOException e) {
       log.error("", e);
       return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
   }
   if (response == null) {
       log.warn("[PUT] Response is null");
       return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
   }
   if (!response.isSuccessful()) {
       return ResponseEntity.status(response.code()).build();
   }
   final PutNavigationSettingsResponseDto data = response.body();
   return ResponseEntity.ok(
           WrikeResponseDto.builder(true)
                   .withData(data)
                   .build()
   );
}Если посмотреть на код внимательнее, то можно увидеть, что в нем очень много специфики эндпоинтов монолита. Нам нужно передавать токен авторизации: когда запрос идет между микросервисами или от BFF в основную систему, нужно явно передавать авторизационные заголовки. Также необходимо передавать дополнительные данные (в нашем примере — ID аккаунта пользователя, чтобы корректно отработал роутинг между различными сегментами системы).
В коде довольно много бойлерплейта обработки — следствие того, что мы выбрали REST. Мы должны убедиться, что запрос ушел, вернулся с правильным статусом и только после этого можем доставать данные и с ними работать.
Если для каждого вызова внутри микросервисов использовать подобный подход, то это не сильно облегчит нам жизнь. Поэтому мы решили посмотреть, что можно оптимизировать с точки зрения кода.
Что мы хотели изменить:
- Избавиться от бойлерплейта. 
- Не передавать и не заполнять общие параметры. 
- Абстрагироваться от протокола и маппинга данных. Мы хотели описать микросервис как обычный бин, не завязывать интерфейсы на конкретные имплементации REST (Retrofit 2) и убрать маппинг (Jackson) из описания моделей. 
- Оставить возможность работать на «низком» уровне (стриминг, более тонкая обработка статусов и т.д.). 
Для решения этих проблем мы снова обратились к кодогенерации.
«Тюним» кодогенерацию
Мы разбили генерацию клиента на два слоя-этапа.
Первый слой — интерфейс микросервиса. На этом уровне генерируется только API сервиса. Никаких аннотаций, упоминаний про Retrofit или что-либо еще.
Сервисы:
public interface NavigationSettingsService {
   PutNavigationSettingsOutputDto updateNavigationSettings(PutNavigationSettingsInputDto input);
}Также поступили с моделью данных: генерируем DTO из схемы с билдерами. Таким способом мы полностью изолируем систему от имплементации.
Модели:
public class PutNavigationSettingsInputDto {
   private final int mode;
   private final List<NavigationItem> items;
   protected PutNavigationSettingsInputDto(
           final int mode,
           final List<NavigationItem> items
   ) {
       this.mode = mode;
       this.items = items;
   }
   public static Builder builder(final int mode) {
       return new Builder(mode);
   }
   public static final class Builder {
       private final int mode;
       private List<NavigationItem> items;
       public Builder(final int mode) {
           this.mode = mode;
       }
       public Builder withItems(final List<NavigationItem> items) {
           this.items = items;
           return this;
       }
       public PutNavigationSettingsInputDto build() {
           return new PutNavigationSettingsInputDto(mode, items);
       }
   }
}Модели не поменялась — мы только убрали аннотации.
Второй слой — имплементация «траспорта». На этом этапе генерируется декларативный Retrofit2-клиент и Jackson mixin-ы для моделей (они нужны, чтобы связать реальную модель с тем, как данные передаются по сети). В качестве дефолтной имплементации интерфейса микросервиса мы добавили вызовы Retrofit2-клиента и перенесли весь бойлерплейт обработки на этот уровень.
Retrofit2-клиент выглядит похожим на предыдущий вариант: мы только добавили дополнительную обертку для ответов методов и вынесли таким способом часть логики из сервиса.
public interface NavigationSettingsServiceGateway {
   @Headers({
           "Content-Type:application/json"
   })
   @PUT("navigation_settings")
   RetrofitCall<PutNavigationSettingsOutputDto> updateNavigationSettingsMobile(@Header("Authorization") String authToken,
                                                                               @Header("account") IdOfAccount accountId,
                                                                               @Body PutNavigationSettingsInputDto input);
}Retrofit2 — дополнительная «обертка», которая реализует часть логики (в частности, обработки статуса ответа). Эта обертка может быть вынесена в отдельную библиотеку (что мы и планируем сделать в будущем). Сейчас обертка генерируется по шаблону и располагается рядом с остальными классами.
RetrofitCall
public class RetrofitCall<T> implements Call<T>, WrikeCall<T> {
   private static final Logger log = LoggerFactory.getLogger(RetrofitCall.class);
   protected final int retryCount;
   protected Call<T> delegate;
   public RetrofitCall(final Call<T> delegate, final int retryCount) {
       this.delegate = delegate;
       this.retryCount = retryCount;
   }
   private static <T> RemoteCallException processErrorMessage(final Response<T> response) {
       if (response == null) {
           return new RemoteCallException(0, "Unknown gateway error", null);
       }
       return RemoteCallException.of(response.message(), response.code(), getErrorBody(response));
   }
   private static <T> String getErrorBody(final Response<T> response) {
       try {
           return response.errorBody() != null
                   ? response.errorBody().string()
                   : "Null error body";
       } catch (final Exception ex) {
           return "Exception occurred while get error body. " + ex.getMessage();
       }
   }
   @Override
   public T getBody(final boolean withRetries) {
       final Response<T> response = withRetries && retryCount > 1
               ? getResponseWithRetries()
               : getResponse();
       if (response == null || !response.isSuccessful()) {
           throw processErrorMessage(response);
       }
       return response.body();
   }
   @Override
   public Response<T> execute() {
       return getResponse();
   }
   @Override
   public void enqueue(final Callback<T> callback) {
       delegate.enqueue(callback);
   }
   @Override
   public boolean isExecuted() {
       return delegate.isExecuted();
   }
   @Override
   public void cancel() {
       delegate.cancel();
   }
   @Override
   public boolean isCanceled() {
       return delegate.isCanceled();
   }
   @Override
   public RetrofitCall<T> clone() {
       try {
           @SuppressWarnings("unchecked") final RetrofitCall<T> clone = (RetrofitCall<T>) super.clone();
           clone.delegate = delegate.clone();
           return clone;
       } catch (final CloneNotSupportedException ex) {
           throw new RuntimeException(ex);
       }
   }
   @Override
   public Request request() {
       return delegate.request();
   }
   @Override
   public Timeout timeout() {
       return delegate.timeout();
   }
   private Response<T> getResponseWithRetries() {
       Response<T> currentResponse = null;
       RetrofitCall<T> currentCall = null;
       for (int retryNumber = 0; retryNumber < retryCount && (currentResponse == null || !currentResponse.isSuccessful()); ++retryNumber) {
           currentCall = currentCall != null
                   ? currentCall.clone()
                   : this;
           try {
               currentResponse = currentCall.getResponse();
           } catch (final RemoteCallException ex) {
               log.warn("Response getting failed on retry number {}", retryNumber, ex);
               currentResponse = null;
           }
       }
       return currentResponse;
   }
   private Response<T> getResponse() {
       try {
           return delegate.execute();
       } catch (final IOException ex) {
           throw new RemoteCallException(ex);
       }
   }
}Используя полученный клиент, подготавливаем стандартную реализацию сервиса. С одной стороны, мы даем разработчику возможность использовать готовый шаблон: здесь есть необходимые вызовы и обработки, чтобы оперировать только верхнеуровневыми моделями. С другой — в этом сервисе присутствуют все необходимые зависимости, чтобы реализовать соответствующий метод вручную. Это, например, может быть полезно, если необходимо специальным образом обработать ответ и/или организовать иной способ вызова удаленного сервиса.
public class NavigationSettingsServiceImpl implements NavigationSettingsService {
   protected static final Logger log = LoggerFactory.getLogger(NavigationSettingsServiceImpl.class);
   protected final AuthDataProvider authDataProvider;
   protected final NavigationSettingsServiceGateway gateway;
   public NavigationSettingsServiceImpl(final AuthDataProvider authDataProvider, final NavigationSettingsServiceGateway gateway) {
       this.authDataProvider = authDataProvider;
       this.gateway = gateway;
   }
   @Override
   public PutNavigationSettingsOutputDto updateNavigationSettings(PutNavigationSettingsInputDto input) {
       final AuthDataProvider.AuthData authData = authDataProvider.getAuthData();
       if (log.isDebugEnabled()) {
           log.debug("request 'updateNavigationSettings': userId={}, accountId={}", authData.getUserId(), authData.getAccountId());
       }
      
       return gateway.updateNavigationSettingsMobile(authData.getAuthToken(), authData.getAccountId(), input).getBody();
   }
}Mixin — специальный способ Jackson добавить описание моделей, не меняя их код.
@JsonPropertyOrder({
       JSON_PROPERTY_MODE,
       JSON_PROPERTY_ITEMS,
})
public abstract class PutNavigationSettingsInputDtoMixin {
   static final String JSON_PROPERTY_MODE = "mode";
   static final String JSON_PROPERTY_ITEMS = "items";
   @JsonCreator
   private PutNavigationSettingsInputDtoMixin(@JsonProperty(JSON_PROPERTY_MODE) final int mode,
                                              @JsonProperty(JSON_PROPERTY_ITEMS) final List<NavigationItem> items) {
   }
   @JsonGetter(JSON_PROPERTY_MODE)
   public abstract int getMode();
   @JsonGetter(JSON_PROPERTY_ITEMS)
   public abstract List<NavigationItem> getItems();
}Чтобы вся схема заработала с минимальными усилиями, мы подготовили дефолтную конфигурацию Jackson, Retrofit2 и т.д. Разработчику останется только подключить конфигурацию к проекту.
MixinRegistration + конфигурация:
public class MixinRegistrationModule extends SimpleModule {
   @Override
   public void setupModule(final SetupContext context) {
       super.setupModule(context);
      
       /// ...
       context.setMixInAnnotations(PutNavigationSettingsInputDto.class, PutNavigationSettingsInputDtoMixin.class);
       context.setMixInAnnotations(PutNavigationSettingsOutputDto.class, PutNavigationSettingsOutputDtoMixin.class);
       /// ...
   }
}@Configuration
public class JacksonModelMixinsConfiguration {
   @Bean(name = "jacksonObjectMapperMixinCustomizer")
   public Jackson2ObjectMapperBuilderCustomizer jacksonObjectMapperBuilderCustomizer() {
       return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder
               .modulesToInstall(new MixinRegistrationModule());
   }
}Регистрация бина сервиса с использованием дефолтной реализации с Retrofit2-клиентом:
public class ApiGatewayConfiguration {
   private final Retrofit retrofit;
   public ApiGatewayConfiguration(final Retrofit.Builder retrofitBuilder) {
       this.retrofit = retrofitBuilder
               .addCallAdapterFactory(retrofitCallAdapterFactory())
               .build();
   }
   protected CallAdapter.Factory retrofitCallAdapterFactory() {
       return new RetrofitCallAdapterFactory();
   }
   protected <T> T createRegionApiGateway(final Class<T> gatewayClass) {
       return retrofit.create(gatewayClass);
   }
   @Bean
   public NavigationSettingsService beanNavigationSettingsService(final AuthDataProvider authDataProvider) {
       return new NavigationSettingsServiceImpl(authDataProvider, createRegionApiGateway(NavigationSettingsServiceGateway.class));
   }
   /// other services
}На текущий момент мы используем Retrofit2, но такой подход позволяет заменить его на другой инструмент — кастомный http-фреймворк, Spring OpenFeign или что-то другое.
Возможности Spring Framework 6
В Spring Framework 6 ребята делают кастомные аннотации для описания декларативного клиента. Если при этом они сделают так, чтобы эти аннотации были наравне и на клиентской стороне (декларативный клиент), и на серверной (интерфейс для REST методов), то можно будет сократить количество кодогенерации и вместо схемы использовать одну разделяемую библиотеку с интерфейсом :)
Что получилось в итоге
@Override
public ResponseEntity<WrikeResponseDto> navigationSettingsPut(final AuthInfo principal, @Valid NavigationSettingsPutRequestDto input) {
   final PutNavigationSettingsOutputDto response = navigationSettingsService.updateNavigationSettingsMobile(
           PutNavigationSettingsInputDto.builder(0)
                   .withItems(input.getItems())
                   .build()
   );
   return ResponseEntity.ok(
           WrikeResponseDto.builder(true)
                   .withData(response)
                   .build()
   );
}- Убрали бойлерплейт обработки http/rest. 
- Вынесли отдельно модель и интерфейс сервиса. 
- Код обработчика в BFF выглядит чистым и приятно читаемым. 
- Используем подход для других микросервисов. 
Сейчас в команде мобильной разработки мы работаем над выделением одного из микросервисов из монолита. Для этого мы описываем схему сервиса, генерируем интерфейс по этой схеме, а в качестве имплементации подставляем локальные бины, которые у нас уже есть.
Сейчас мы учим систему работать через конкретный интерфейс. Когда будем готовы вынести базу данных и код сервиса отдельно, нам нужно будет заменить транспорт на http, и все должно заработать.
BFF на проде, второй сервис на подходе, и мы почти завершили миграцию.
Выводы
Выбор подхода — важный шаг при перестроении системы. Какие технологии будут использоваться, как все элементы системы связываются воедино — об этом стоит договориться заранее. Мы потратили на этот этап достаточно много времени и пересмотрели разные варианты решения проблемы, но оно того стоило.
Реверс инжениринг — хороший способ описать текущую систему. Изучение текущего кода, его обработка и получение схемы позволяет описать систему и решает проблему автоматизации.
Кодогенерация упрощает жизнь и избавляет от бойлерплейта. Кодогенерация помогает решить много задач и позволяет унифицировать подход, чтобы разработчики подходили к реализации одинаково.
Такой подход позволяет нам пошагово двигаться от монолита к микросервисам.
Эта статья — пересказ моего доклада с конференции CodeFest 2022. Если хотите посмотреть видео — вот ссылка.
Буду рад ответить на вопросы и комментарии!
 
          