Перейти к основному содержимому

6.11. Проектирование сервисов и методов

Разработчику Архитектору Аналитику

Проектирование сервисов и методов

1. Определение границ сервиса

Начнём с вопроса: что вообще считать сервисом?

В современной практике термин «сервис» используется в нескольких значениях:

  • Микросервис — автономное приложение со своей БД, жизненным циклом и API.
  • Domain-сервис — класс в доменном слое, реализующий логику, не принадлежащую конкретной сущности (например, TransferService для перевода денег между счетами).
  • Application-сервис — точка входа в приложение (например, CreateOrderCommandHandler), координирующий транзакции и вызовы домена.
  • Интеграционный сервис — адаптер к внешней системе (платёжный шлюз, SMS-провайдер).

Независимо от уровня, проектирование любого сервиса начинается с ответа на три вопроса:

  1. Какова его единственная цель?
    Если не удаётся сформулировать её одним предложением — границы размыты.
    Пример хорошей формулировки: «Обеспечивает создание заказа с проверкой доступности товара, резервированием и инициацией оплаты».
    Пример плохой: «Управляет заказами» — слишком широко.
  1. Какие акторы инициируют его работу?
    Это определяет контракт и уровень детализации. Сервис, вызываемый пользователем через UI, должен валидировать входные данные и формировать понятные сообщения об ошибках. Сервис, вызываемый другим внутренним компонентом в рамках транзакции, может полагаться на предварительную валидацию и возвращать структурированные исключения.
  1. Какие внешние зависимости он имеет?
    Каждая зависимость — это потенциальная точка отказа и сложность интеграционного тестирования. Если сервис зависит от пяти внешних систем, вероятно, его стоит разделить или ввести уровень абстракции (например, шлюз агрегации).

Практический алгоритм определения границ:

  1. Выделите бизнес-транзакцию — атомарную операцию, имеющую смысл для предметной области (например, «оформить заказ», «подтвердить email»).
  2. Определите участников — какие сущности, агрегаты, внешние системы участвуют.
  3. Проверьте, нарушает ли операция инварианты нескольких агрегатов.
    • Если инвариант одного агрегата — логика принадлежит самому агрегату (Order.AddLine()).
    • Если инвариант нескольких — требуется domain-сервис (OrderFulfillmentService).
  4. Оцените жизненный цикл — должен ли сервис обрабатывать асинхронные события, компенсационные действия, повторные попытки? Это влияет на выбор паттернов (Saga, Circuit Breaker).

Важно: границы сервиса должны совпадать с границами согласованности данных. Если для выполнения операции требуются данные из нескольких БД без распределённой транзакции — это сигнал к пересмотру: либо агрегировать данные заранее (через событийную модель), либо выделить отдельный процесс оркестрации.


2. Проектирование контракта метода

Контракт метода — это его «публичное обещание»: что он принимает, что гарантирует и что может пойти не так. Хороший контракт позволяет клиенту использовать метод без изучения его исходного кода.

Контракт включает четыре аспекта:

2.1. Сигнатура (имя, параметры, возвращаемое значение)

  • Имя должно отражать действие, а не реализацию: ReserveInventory() лучше, чем CallWarehouseApi().
  • Параметры — минимально необходимый набор. Избегайте «мешков» вроде Dictionary<string, object> или «god-объектов» с 15 полями. Если параметров больше трёх, стоит рассмотреть введение DTO (Data Transfer Object).
  • Возвращаемое значение должно быть однозначным:
    • void — если метод идемпотентен и не требует подтверждения (например, LogEvent()),
    • Result<T, Error> или Either — в функциональных стилях, где ошибки часть контракта,
    • Task<T> — если асинхронно,
    • Исключения — только для исключительных ситуаций (например, сбой инфраструктуры), а не для бизнес-ошибок («недостаточно средств» — не исключение).

2.2. Предусловия

Что должно быть истинно до вызова метода?

  • Валидность входных данных (например, email соответствует RFC 5322),
  • Состояние системы (например, «пользователь аутентифицирован»),
  • Ограничения окружения (например, «доступна сеть»).

Предусловия не проверяются внутри метода, если они относятся к ответственности вызывающего кода. Например, метод ProcessPayment(PaymentRequest request) не проверяет, авторизован ли пользователь — этим должен заняться слой авторизации выше.

2.3. Постусловия

Что гарантированно будет истинно после успешного выполнения?

  • Изменения в состоянии («заказ создан в статусе Draft»),
  • Побочные эффекты («отправлено email-уведомление»),
  • Инварианты («баланс неотрицателен»).

Постусловия — основа для тестирования: unit-тесты проверяют, что постусловия выполняются при заданных предусловиях.

2.4. Обработка ошибок

Критически важный элемент. Ошибки делятся на три категории:

КатегорияПримерКак обрабатывать
Бизнес-ошибки«Недостаточно товара на складе», «Карта просрочена»Возвращать структурированный результат (Result.Fail(InsufficientStock)), не бросать исключения. Клиент может принять решение (предложить альтернативу, запросить подтверждение).
Ошибки валидации«Email пуст», «Сумма ≤ 0»Возвращать список нарушений (ValidationResult). Часто обрабатываются на уровне представления.
Системные ошибки«Таймаут БД», «Сбой сети»Исключения или Result.Fail<SystemFailure>. Обрабатываются на уровне инфраструктуры (повтор, fallback, логирование).

Исключение — это сигнал о непредвиденном состоянии. Если ошибка прогнозируема (даже если редка), она должна быть частью контракта.


3. Идемпотентность и безопасность

Эти свойства определяют, как метод реагирует на повторный вызов — ключевой фактор при проектировании распределённых систем.

  • Безопасный (safe) метод — не изменяет состояние системы. В HTTP это GET, HEAD, OPTIONS.
  • Идемпотентный метод — повторный вызов с теми же параметрами не изменяет результат после первого успешного выполнения. PUT, DELETE в REST — идемпотентны; POST — обычно нет.

Почему это важно?

  • Сетевые сбои делают повторные вызовы неизбежными.
  • Клиент не может отличить «метод не дошёл» от «метод выполнился, но ответ потерялся».
  • Без идемпотентности повтор приведёт к дубликатам (два заказа, два списания).

Как обеспечить идемпотентность?

  1. Ввести идемпотент-ключ (например, X-Idempotency-Key: <guid>), генерируемый клиентом.
  2. Сохранять результат первого вызова с этим ключом.
  3. При повторном вызове с тем же ключом — возвращать кэшированный результат.

Это требует дополнительного хранилища (обычно TTL-кэш на 24–72 часа), но исключает бизнес-риски.


4. Версионирование и эволюция

Система живёт дольше, чем любой отдельный метод. Проектирование должно учитывать будущие изменения.

Стратегии версионирования:

ПодходКакПлюсыМинусы
Версия в URL (/api/v1/orders)Явная, простаяЧёткое разделениеНарушает принцип, что URL — идентификатор ресурса
Версия в заголовке (Accept: application/vnd.myapp.v2+json)Семантически корректно (RFC 7240)Сохраняет чистоту URLСложнее тестировать, требует инфраструктурной поддержки
Параметр запроса (?version=2)ГибкоПоддержка нескольких версий в одном endpoint’еРиск кэширования старой версии прокси

Правила эволюции контракта (для обратной совместимости):

  • Добавлять поля можно — клиенты проигнорируют новые.
  • Удалять поля нельзя — сломает старые клиенты. Вместо этого помечать как deprecated.
  • Изменять тип поля нельзя. Если нужен новый формат — добавлять новое поле (price_v2).
  • Изменять семантику — только с новой версией API.

Внутри системы (не в публичном API) можно использовать более гибкие механизмы: feature flags, стратегии миграции («двумя столбцами»), события с версией схемы (OrderCreated_v2).


5. Тестирование как часть проектирования

Метод, который нельзя протестировать изолированно, плохо спроектирован. Критерий: можно ли написать unit-тест без запуска БД, сети, UI?

Техники обеспечения тестируемости:

  • Инверсия зависимостей: передавать репозитории, сервисы через интерфейсы.
  • Чистые функции: выносить вычисления без побочных эффектов (расчёт налогов, валидация).
  • Изоляция времени: вместо DateTime.NowIClock.Now, чтобы управлять временем в тестах.
  • Фиксированные входные данные: избегать Guid.NewGuid(), использовать IGuidGenerator.

Unit-тест должен проверять один сценарий поведения. Если для покрытия метода требуется 20 тестов — он слишком велик (нарушение SRP).