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

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