Идеальная структура сервиса

от автора

Не хочу читать статью, хочу узнать, как мне теперь структурировать сервисы, и вернуться к работе
Можно возвращаться к работе

Можно возвращаться к работе

Что не так

Хорошо, когда в команде есть договорённости по структурированию кода. Плохо, что они не всегда являются достаточно подробными и однозначными. Часто ли приходя на новый проект вам приходилось видеть подобную структуру пакетов?

ru.superbank.superservice:     controller:         CustomerController         MoneyController     domain:         Customer         Money     service:         CustomerService         MoneyService     repository:         CustomerRepository         MoneyRepository

Думаю, что часто. И выглядит вроде неплохо, всё аккуратно разложено по пакетам, понятно, где точка входа, где лежит обработчик, и как он ходит в базу. А если в контроллере ещё и логики нет, можно считать, что вам крупно повезло! Но такое возможно только на старте проекта.

Пакет service

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

ru.superbank.superservice:     service:         CustomerService         CustomerServiceImpl         MoneyService         MoneyServiceImpl

или даже так:

ru.superbank.superservice:     service:         CustomerService         MoneyService         impl:             CustomerServiceImpl             MoneyServiceImpl
Я пишу на Go, у нас интерфейсы для такого изначально не предназначены
Разработчик Go заглянул в промышленный проект на Java

Разработчик Go заглянул в промышленный проект на Java

Очень рад за вас!

По мере роста проекта в нём появляются интеграции с другими сервисами, кэшами, брокерами сообщений. Куда обычно кладут интеграции? Конечно же в пакет service! И конечно не забывают сделать по интерфейсу для каждой интеграции, надо же где-то документацию писать.

ru.superbank.superservice:     service:         CustomerService         MoneyService         impl:             CustomerServiceImpl             MoneyServiceImpl         kafka:             KafkaService             impl:                 KafkaServiceImpl         dictionaty:             DictionaryRestService             impl:                 DictionaryRestServiceImpl

Бизнес-логика усложняется, и возникает соблазн переиспользовать отдельные фрагменты, все же знают про принцип DRY. А с учётом того, что у нас уже есть подробная документация по сигнатуре и назначению методов, мы можем считать, что проблем у нас не будет. Но это не так. Основная проблема в том, что классы из пакета service начинают вызывать друг друга, а цепочки таких вызовов становятся очень длинными. И если документация не была вовремя актуализирована, можно получить неприятную ситуацию, когда вызываемые методы делают не только то, что в ней указано.

Упрощённый пример из реальной жизни
public class BusinessServiceImpl implements BusinessService {        private final KafkaService kafkaService;      public BusinessService(KafkaService kafkaService) {       this.kafkaService = kafkaService;     }      /**      * выполнить какое-то действие и отправить ответ в топик      */     public void process(Request request) {         Response response = innerProcess(request);            kafka.sendResponse(response);     } }  public interface KafkaService {     /**      * отправка сообщения в топик      */     void sendResponse(Response response) }  public class KafkaServiceImpl implements KafkaService {        private final KafkaSender kafkaSender;     private final SaveResponseService saveResultService;      public KafkaServiceImpl(KafkaSender kafkaSender,                            SaveResponseService saveResultService) {       this.kafkaSender = kafkaSender;       this.saveResultService = saveResultService;     }      void sendResponse(Response response) {         KafkaMessage message = toKafkaMessage(response);         kafkaSender.send(message);         saveResultService.save(response);     } }

Тут мы ожидали, что сообщение будет отправлено в брокер, но, судя по реализации, оно ещё и сохраняется. В данной ситуации это может быть и нужно, но переиспользовать такой метод точно не стоит.

Пакет domain

Он же dto, он же model, суть от этого не меняется. Пока проект небольшой, всё хорошо, и там лежат сущности для работы с базой (вы же используете подход contract-first и генерируете DTO для контроллеров и различных интеграций?). Но в дальнейшем там появляется много разных классов, относящихся не только к базе. Это могут быть как запросы-ответы для внешних сервисов (а ведь их можно было генерировать!), так и различные вспомогательные DTO, которые используются, например, для агрегации данных. На одном проекте я сопровождал сервис-монолит, который разбили на модули. Один из модулей так и назывался — domain. Там лежали ВООБЩЕ ВСЕ сущности, которые так или иначе использовались в сервисе. И ориентироваться в нём было очень сложно.

Пакет controller

Вроде всё логично, в пакете лежат классы и методы, которые являются точкой входа в приложение. Но приложение может иметь не только синхронный API. Ваш сервис может получать сообщения из различных брокеров, запускать какие-то задачи по расписанию, в нём может быть организован асинхронный обмен сообщениями внутри самого приложения либо обработка шагов бизнес-процесса. Где будут лежать обработчики для всего этого вы уже догадались.

Что с этим делать

API сервиса в отдельном пакете

В первоначальном варианте отдельного пакета удостоились только контроллеры, но, как было сказано выше, не только они могут быть точкой входа в ваше приложение. Всё это должно быть выделено в отдельный пакет:

ru.superbank.superservice:     entrypoint:         controller:             CustomerController         consumer:             KafkaConsumer         scheduler:             TaskScheduler

Эти классы не должны содержать логики, только первичная обработка запроса, например валидация.

Отдельный обработчик для каждого метода API

Да, именно так. Отдельный класс для каждой «ручки» с ОДНИМ публичным методом. Т.о. мы не только избавляемся от огромных классов, содержащих в себе логику для обработки нескольких методов API, но и от необходимости делать интерфейсы ради документации. Взамен мы получим на порядок больше файлов, но их уже можно структурировать, используя подпакеты:

ru.superbank.superservice:     logic:         customer:             CreateCustomerOperation             UpdateCustomerOperation

Будет ли пакет называться logic, handler или как-то ещё, не важно. Главное, чтобы такой пакет был, и классы в нём не вызывали друг друга.

Не всегда можно вместить всю обработку в один класс, особенно если в зависимости от параметров запроса она может иметь разные сценарии, либо саму обработку логично разбить на шаги. В этом случае можно добавить отдельные обработчики, которые будут вызываться из основного, выполняющего роль оркестратора.

Разделение бизнес-логики и интеграций

Все интеграции должны быть вынесены в отдельный слой. Работа с БД — тоже интеграция, т.е. структура должна выглядеть примерно так:

ru.superbank.superservice:     integration:         repository:             CustomerRepository             CustomerRepositoryAdapter         rest:             DictionaryRestClient             DictionaryRestClientAdapter         kafka:             KafkaSender             KafkaSenderAdapter

При именовании почти всех подобных классов обычно принято использовать суффикс Service. Исключением являются только классы для работы с БД, они могут иметь суффиксы Repository, Dao, какие-то ещё. Сейчас мы видим, что классов с суффиксом Service нет вообще, зато появились какие-то Adapter-ы. Звучит знакомо. Точно, это же что-то из гексагональной архитектуры! Только вместо портов Sender, Client и Repository, которые по сути ими и являются.

Адаптеры вмещают в себя часть бизнес-логики, они формируют запрос к нужному ресурсу, вызывают соответствующий порт и валидируют ответ. Они, в отличие от классов-обработчиков, могут иметь по несколько публичных методов, но точно так же не должны вызывать друг друга.

Если у сервиса много интеграций, таких классов так же будет немало. И как их компоновать внутри пакета integration, решать вам.  

Порты выполняют отправку сформированного адаптерами запроса и обработку ошибок.

Пример реализации
public class CustomerRepositoryAdapter {        private final CustomerRepository customerRepository;      public CustomerRepositoryAdapter(CustomerRepository customerRepository) {       this.customerRepository = customerRepository;     }      public List<Customer> getCustomers(CustomerIdsHolder holder) {         List<Long> customerIds = holder.getCustomerIds();         List<Row> customerRows = customerRepository.getCustomersByIds(customerIds);         if (customerRows.isEmpty()) {             throw new BusinessException()         }         return toCustomers(customerRows);     } }  public class CustomerRepository {        private final DbClient dbClient;      public CustomerRepository(DbClient dbClient) {       this.dbClient = dbClient;     }      public List<Row> getCustomersByIds(List<Long> customerIds) {         try {             return dbClient.select("SELECT customer WHERE id IN :customerIds")               .bind("customerIds", customerIds)               .toList();         } catch (SqlExceltion e) {             throw new DbException(e);         }     } }

Адаптеры, как правило, работают только с соответствующим им портом. Исключением являются адаптеры для БД, т.к. иногда в одном методе можно собрать ответ из нескольких методов разных репозиториев, чтобы потом отдать агрегированные данные.  

Ни в адаптерах, ни в портах не могут инициироваться транзакции. Они должны начинаться на уровень выше, в обработчиках, т.к. во-первых транзакция может затронуть несколько адаптеров, а во-вторых методы адаптеров могут переиспользоваться, и не всегда отдельная транзакция будет нужна.

Наш любимый пакет service

Теперь у нас есть операции, клиенты, сендеры, репозитории и прочие адаптеры. Но мы, разработчики, привыкли к классам типа Service! И самое лучшее применение для таких классов — логика, которая может быть переиспользована, либо логическое объединение интеграций. Добавим же, наконец, пакет service:

ru.superbank.superservice:     service:         CacheService         AggregationService

Эти классы, в отличие от обработчиков, могут содержать более одного публичного метода. В них определяются границы транзакций. И, конечно же, они не должны вызывать друг друга.

А как же интерфейсы?

Интерфейсы — отличный инструмент, который есть в разных языках программирования, в большей или меньшей степени поддерживающих ООП. Они незаменимы, когда у вас есть несколько реализаций, либо вы хотите выделить какую-то часть функциональности в классе так, чтобы её можно было использовать в определённых сценариях (в этом случае класс может реализовывать несколько интерфейсов). Возможно есть ещё какие-то примеры рационального использования интерфейсов, но я таких не встречал.

Ага, конечно

Ага, конечно

Отдельные пакеты для моделей там, где они используются

Вы же не используете одни и те же DTO и для базы, и для внешнего API? Тогда рано или поздно таких классов может стать очень много. Чтобы не запутаться в моделях в большом проекте, их нужно держать там, где они используются. Т.е. представления таблиц из БД должны лежать там же, где происходит работа с базой (пакет integration.repository), DTO запросов и ответов от внешнего сервиса там, где он вызывается (integration.rest).

Не стоит так же забывать про преобразование одних сущностей в другие. Часто их делают в классах, содержащих бизнес-логику или внешний вызов. Это не только ухудшает читаемость кода, но и усложняет отладку, т.к. при таком подходе состояние объекта может меняться в разных местах. Именно поэтому преобразования должны выделяться в отдельные классы — мапперы (конверторы, трансформеры).

Валидация переданных параметров часто выполняется средствами используемых фреймворков/библиотек, но иногда есть необходимость проверять данные вручную. Такие проверки могут содержать достаточно много кода, и их, так же как преобразования, есть смысл выносить в отдельные классы, которые должны находится рядом с валидируемыми сущностями.

Пример структуры с «мапперами», валидаторами и DTO:

ru.superbank.superservice:     entrypoint:         controller:             domain:                 GetCustomersResponse                 CreateCustomerRequest                 CreateCustomerResponse             mapper:                 GetCustomersResponseMapper                 CreateCustomerResponseMapper             validator:                 CreateCustomerRequestValidator     integration:         repository:             domain:                 Customer             mapper:                 CustomerMapper

Модули

В отдельный модуль нужно выделять как минимум контракт вашего сервиса и SDK, которые будут публиковаться. Этого можно не делать, только если в вашем банке вашей компании принято страдать, а разработчикам из соседних команд нравится самим писать бойлерплейт.

Есть ли смысл делить на модули остальное? У меня нет однозначного ответа на этот вопрос. С одной стороны пакеты позволяют достаточно удобно структурировать проект. Сравните

super-service   entrypoint-module     ru.superbank.superservice.entrypoint.controller: CustomerController   integration-module     ru.superbank.superservice.integration.repository: CustomerRepository

и

super-service   ru.superbank.superservice.entrypoint.controller: CustomerController   ru.superbank.superservice.integration.repository: CustomerRepository

Но если сервис очень большой, разделить его на модули всё же стоит. Делить можно либо по «слоям», либо по «доменам», либо комбинировать оба подхода. Я бы предложил следующий вариант:

super-service:   application: //точка входа в приложение     ru.superbank.superservice   customer-api: //обработчики запросов     ru.superbank.superservice.customer.entrypoint     ru.superbank.superservice.customer.logic   money-api: //обработчики запросов     ru.superbank.superservice.money.entrypoint     ru.superbank.superservice.money.logic   core: //общие сервисы и интеграции, можно для удобства разбить на несколько модулей     ru.superbank.superservice.core.service     ru.superbank.superservice.core.integration

При этом функциональность, которая не должна переиспользоваться, выделена в отдельные модули. Это так же позволит в будущем разделить приложение на несколько сервисов, если потребуется. Достаточно сделать интерфейсы для классов из модуля core, которые будут использоваться в модулях api. Структура приложения в этом случае усложняется, но оно того стоит.

super-service:   application: //точка входа в приложение     ru.superbank.superservice   customer-api: //обработчики запросов     ru.superbank.superservice.customer.entrypoint     ru.superbank.superservice.customer.logic   money-api: //обработчики запросов     ru.superbank.superservice.money.entrypoint     ru.superbank.superservice.money.logic   customer-core: //интерфейсы, реализуемые в модулей core     ru.superbank.superservice.customercore.service     ru.superbank.superservice.customercore.integration   money-core: //интерфейсы, реализуемые в модулей core     ru.superbank.superservice.moneycore.service     ru.superbank.superservice.moneycore.integration   core: //общие сервисы и интеграции     ru.superbank.superservice.core.service     ru.superbank.superservice.core.integration

Что это нам даёт

Единообразная структура

Новый разработчик не будет страдать, глядя на 100 сервисов, написанных по-разному. Он без труда найдёт точку входа в сервис и сразу будет видеть, какие интеграции у него есть. При наличии хорошей документации на вики новый разработчик сможет достаточно быстро править баги и вносить небольшие доработки (добавить пару полей, новую интеграцию) в уже существующие методы. И ему не придётся для этого лопатить весь проект. Проверено на себе. Когда-то я, будучи начинающим специалистом, пришёл на такой проект. И очень быстро, не вникая глубоко в бизнес, начал писать код. Описанная структура отчасти повторяет то, что я там тогда подсмотрел (а потом внедрил на 2 проектах), немного отличается неймингом и является продолжением тех самых идей, но лучше масштабируется.

Лучшая читаемость кода

Из классов обработчиков вынесено всё лишнее, и они содержат только бизнес-логику. Если при этом давать понятные названия методам, можно читая код сверху вниз понять, что происходит при обработке, даже если вы этот код в первый раз видите. Все преобразования вынесены в отдельные классы, что так же повышает читаемость кода.

Абстракции одного уровня не зависят друг от друга

Меняя код в одном обработчике, вы гарантировано не сломаете другой. Это важное отличие от «плоской» структуры, где сервисы могут как попало вызывать друг друга, и одна из основных причин использовать такой подход.

Проще писать тесты и ориентироваться в них

Когда у вас есть CustomerService на 1000 строк, вам придётся либо сделать класс CustomerServiceTest на 5000 строк, либо делать по одному классу на каждый его метод, чтобы по-честному его протестировать (не процента покрытия ради, а пользы для). Если же для каждого обработчика есть отдельный класс, то для него можно сделать и отдельный тест. Когда из общей массы кода можно выделить логику, преобразования, валидации и интеграции, вам будет проще понять, где нужны интеграционные тесты, а где достаточно написать модульные.

Заключение

«Ты же просто смешал чистую архитектуру с её слоями, DDD с выделением логики, приплёл пару понятий из гексагональной архитектуры и хочешь плюсы в карму!» Да, всё так, на эти темы уже написано достаточно книг и статей, где они намного лучше раскрыты. И, конечно же, не существует никакой «идеальной структуры приложения». Равно, как и «чистого кода» и «чистой архитектуры». В разработке невозможно следовать принципу win‑win. Разработка — это всегда компромисс между производительностью, скоростью написания кода, понятностью реализации и простотой сопровождения. Именно компромисс я и предлагаю.


ссылка на оригинал статьи https://habr.com/ru/articles/870398/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *