Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров2.3K
Не хочу читать статью, хочу узнать, как мне теперь структурировать сервисы, и вернуться к работе
Можно возвращаться к работе
Можно возвращаться к работе

Что не так

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

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

Теги:
Хабы:
Всего голосов 8: ↑6 и ↓2+4
Комментарии16

Публикации

Ближайшие события