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

Что не так
Хорошо, когда в команде есть договорённости по структурированию кода. Плохо, что они не всегда являются достаточно подробными и однозначными. Часто ли приходя на новый проект вам приходилось видеть подобную структуру пакетов?
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, у нас интерфейсы для такого изначально не предназначены

Очень рад за вас!
По мере роста проекта в нём появляются интеграции с другими сервисами, кэшами, брокерами сообщений. Куда обычно кладут интеграции? Конечно же в пакет 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/
Добавить комментарий