Проектирование сервисов и методов
Проектирование сервисов и методов
1. Определение границ сервиса
Начнём с вопроса: что вообще считать сервисом?
В современной практике термин «сервис» используется в нескольких значениях:
- Микросервис — автономное приложение со своей БД, жизненным циклом и API.
- Domain-сервис — класс в доменном слое, реализующий логику, не принадлежащую конкретной сущности (например,
TransferServiceдля перевода денег между счетами). - Application-сервис — точка входа в приложение (например,
CreateOrderCommandHandler), координирующий транзакции и вызовы домена. - Интеграционный сервис — адаптер к внешней системе (платёжный шлюз, SMS-провайдер).
Независимо от уровня, проектирование любого сервиса начинается с ответа на три вопроса:
- Какова его единственная цель?
Если не удаётся сформулировать её одним предложением — границы размыты.
Пример хорошей формулировки: «Обеспечивает создание заказа с проверкой доступности товара, резервированием и инициацией оплаты».
Пример плохой: «Управляет заказами» — слишком широко.
- Какие акторы инициируют его работу?
Это определяет контракт и уровень детализации. Сервис, вызываемый пользователем через UI, должен валидировать входные данные и формировать понятные сообщения об ошибках. Сервис, вызываемый другим внутренним компонентом в рамках транзакции, может полагаться на предварительную валидацию и возвращать структурированные исключения.
- Какие внешние зависимости он имеет?
Каждая зависимость — это потенциальная точка отказа и сложность интеграционного тестирования. Если сервис зависит от пяти внешних систем, вероятно, его стоит разделить или ввести уровень абстракции (например, шлюз агрегации).
Практический алгоритм определения границ:
- Выделите бизнес-транзакцию — атомарную операцию, имеющую смысл для предметной области (например, «оформить заказ», «подтвердить email»).
- Определите участников — какие сущности, агрегаты, внешние системы участвуют.
- Проверьте, нарушает ли операция инварианты нескольких агрегатов.
- Если инвариант одного агрегата — логика принадлежит самому агрегату (
Order.AddLine()). - Если инвариант нескольких — требуется domain-сервис (
OrderFulfillmentService).
- Если инвариант одного агрегата — логика принадлежит самому агрегату (
- Оцените жизненный цикл — должен ли сервис обрабатывать асинхронные события, компенсационные действия, повторные попытки? Это влияет на выбор паттернов (Saga, Circuit Breaker).
Важно: границы сервиса должны совпадать с границами согласованности данных. Если для выполнения операции требуются данные из нескольких БД без распределённой транзакции — это сигнал к пересмотру: либо агрегировать данные заранее (через событийную модель), либо выделить отдельный процесс оркестрации.
2. Проектирование контракта метода
Контракт метода — это его «публичное обещание»: что он принимает, что гарантирует и что может пойти не так. Хороший контракт позволяет клиенту использовать метод без изучения его исходного кода.
Контракт включает четыре аспекта:
2.1. Сгнатура (имя, параметры, возвращаемое значение)
- Имя должно отражать действие, а не реализацию:
ReserveInventory()лучше, чемCallWarehouseApi(). - Параметры — минимально необходимый набор. Избегайте «мешков» вроде
Dictionary<string, object>или «god-объектов» с 15 полями. Если параметров больше трёх, стоит рассмотреть введение DTO (Данные 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— обычно нет.
Почему это важно?
- Сетевые сбои делают повторные вызовы неизбежными.
- Клиент не может отличить «метод не дошёл» от «метод выполнился, но ответ потерялся».
- Без идемпотентности повтор приведёт к дубликатам (два заказа, два списания).
Как обеспечить идемпотентность?
- Ввести идемпотент-ключ (например,
X-Idempotency-Key: <guid>), генерируемый клиентом. - Сохранять результат первого вызова с этим ключом.
- При повторном вызове с тем же ключом — возвращать кэшированный результат.
Это требует дополнительного хранилища (обычно 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.Now—IClock.Now, чтобы управлять временем в тестах. - Фиксированные входные данные: избегать
Guid.NewGuid(), использоватьIGuidGenerator.
Unit-тест должен проверять один сценарий поведения. Если для покрытия метода требуется 20 тестов — он слишком велик (нарушение SRP).
См. также
Другие статьи этого же раздела в боковом меню (как на странице «О разделе»). Каждая система имеет свою архитектуру построения; систему нужно разворачивать под нагрузку; нужно понимать обновления и исправление ошибок; рано или поздно — интеграция, безопасность, расширение и поддержка. Подход к проектированию — это стратегия, которая определяет, откуда начинается работа над системой и в каком порядке формируются её компоненты. Принципы — это критерии оценки. Они позволяют задать вопрос — Если бы мы сделали иначе, что пошло бы не так через год? Хороший код сегодня — это рабочий код и тот, который можно безопасно изменить… Любое действие пользователя — это запрос на изменение состояния, а не прямая команда. Функциональные требования отвечают на вопрос что система делает? (Пользователь может оформить заказ). Традиционный подход — Команда проектирует систему, Пишет код, По завершении — создаёт документацию для сдачи заказчику или архивирования Проектирование баз данных — это системная инженерная дисциплина, направленная на создание структуры хранения данных, которая обеспечивает корректность, целостность, производительность, расширяемость… Современные программные системы редко существуют изолированно. Переходите к изучению этой статьи только после того, как изучите микросервисы. Переходите к изучению этой статьи только после того, как изучите микросервисы. Распределённые системы представляют собой совокупность независимых вычислительных узлов, которые взаимодействуют между собой через сеть для достижения общей цели. Современные организации ежедневно генерируют огромные объёмы информации.Проектирование программных систем
Подходы к проектированию
Принципы проектирования
Проектирование функциональных UI
Проектирование под нефункциональные требования
Документация как инструмент проектирования
Проектирование баз данных
Проектирование API и интеграций
Паттерны микросервисной архитектуры
Проектирование веб-разработки
Проектирование распределенных систем
Хранилища DWH и ETL-процессы