Вступление
Паттерн Command — широко известный и мощный инструмент построения гибких систем, позволяющий целиком вынести логику каждого метода в отдельный класс.
В статье показано как совмещение Command с Dependency Injection (DI) даёт дополнительные преимущества в архитектуре приложений.
Статья будет полезна разработчикам всех уровней, а также архитекторам приложений.
Примеры кода и демо проект
Все примеры в статье и демо проект даны на C#, но сам подход примененим с любым языком программирования, который имеет библиотеку с методами внедрения зависимостей. Если же такая библиотека отсутствует, то всегда можно реализовать её самостоятельно.
Демо проект показывает архитектуру приложения состоящую исключительно из Command с использованием DI.
https://github.com/abaula/guess_number
Демо проект может служить пособием для изучения и экспериментов.
Паттерн Command и Dependency Injection
Шаблон Command давно известен и описан ранее, повторяться нет смысла.
Перейдём к сути, рассмотрев способы реализации шаблона.
В примерах из Интернет чаще встречается подход, когда зависимости и параметры запуска команды передаются через конструктор, а сам метод исполнения команды не принимает никаких параметров.
interface ICreateUser
{
UserDto Execute();
}
class CreateUser : ICreateUser
{
private readonly string _userName;
private readonly IUserProvider _userProvider;
public CreateUser(IUserProvider userProvider, string userName)
{
_userProvider = userProvider;
_userName = _userName;
}
public UserDto Execute()
{
var user = new UserDto { Name = _userName };
_userProvider.Create(user);
return user;
}
}
Я считаю более оптимальным подход, когда зависимости передаются через конструктор, а параметры передаются в метод исполнения команды.
interface ICreateUser
{
UserDto Execute(string userName);
}
class CreateUser : ICreateUser
{
private readonly IUserProvider _userProvider;
public CreateUser(IUserProvider userProvider)
{
_userProvider = userProvider;
}
public UserDto Execute(string userName)
{
var user = new UserDto { Name = userName };
_userProvider.Create(user);
return user;
}
}
Главное преимущество второго подхода - возможность максимально просто разделить жизненные циклы зависимостей и входных параметров, а значит эффективно применять Dependency Injection (DI) в Command.
Под возможностью разделить жизненные циклы зависимостей и входных параметров я имею ввиду разделение без дополнительных уровней абстракции.
Вот пример дополнительной абстракции, которая требуется при работе с командой принимающей все параметры через конструктор.
interface ICreateUserCommandFactory
{
ICreateUser CreateUserCommand(string userName);
}
class CreateUserCommandFactory
{
private readonly IUserProvider _userProvider;
public CreateUserCommandFactory(IUserProvider userProvider)
{
_userProvider = userProvider;
}
public ICreateUser CreateUserCommand(string userName)
{
return new CreateUser(_userProvider, userName);
}
}
Также можно добится разделения через создание экземпляра команды прямо по месту использования, что ещё менее удачное решение.
class UserService
{
private readonly IServiceProvider _serviceProvider;
public UserService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void CreateUser(string userName)
{
// Создаём команду
var userProvider = _serviceProvider.GetRequiredService<IUserProvider>();
var createUserCommand = new CreateUser(userProvider, userName);
// Выполняем команду для создания экземпляра пользователя.
var user = createUserCommand.Execute();
// Продолжение метода ...
}
}
Как видно второй пример реализации Command не является единственно возможным, но является на мой взгляд наиболее оптимальным.
Зачем совмещать Command и DI
Комбинируя паттерн Command с Dependency Injection, мы получаем явное отделение логики команд, их зависимостей и жизненного цикла компонентов. Команда становится "чистой": она реализует только бизнес-задачу, не заботится о создании сервисов, логировщиков, репозиториев, а получает их извне через конструктор или контейнер.
Преимущества этого подхода особенно проявляются в следующих ситуациях:
Тестируемость: можно легко подменять окружение команды на мок реализации.
Гибкость: смена логики реализаций (например, репозиториев или логики логирования) осуществляется без изменений в самих командах.
Расширяемость и низкая связанность: новые команды или сервисы внедряются без необходимости рефакторить старый код.
Отслеживаемость зависимостей: DI-контейнер явно указывает, кто от кого зависит.
Кратко о сути подхода
В адаптированной реализации, Command принимают все зависимости через DI-контейнер, что устраняет жёсткое связывание между командой и внешними сервисами. Такой подход особенно часто встречается в .NET, Java и других современных языках имеющих развитые средства внедрения инверсии управления.
Однако сами параметры передаются непосредственно в метод исполнения команды. Такой подход позволяет с наименьшими издержками реализовывать передачу обрабатываемых данных между командами - то есть реализовать конвеер, поток данных, dataflow.
Полный пример подхода.
interface ICreateUser
{
UserDto Execute(string userName);
}
class CreateUser : ICreateUser
{
private readonly IUserProvider _userProvider;
private readonly ICheckUserExists _checkUserExists;
private readonly IGenerateUserEmail _generateUserEmail;
public CreateUser(IUserProvider userProvider,
ICheckUserExists checkUserExists,
IGenerateUserEmail generateUserEmail)
{
_userProvider = userProvider;
_checkUserExists = checkUserExists;
_generateUserEmail = generateUserEmail;
}
public UserDto Execute(string userName)
{
if (_checkUserExists.Execute(userName))
throw new InvalidOperationException($"Пользователь с именем '{userName}' уже существует.");
var email = _generateUserEmail.Execute(userName);
var user = new UserDto { Name = userName, Email = email };
_userProvider.Create(user);
return user;
}
}
Как видно данный метод не только не содержит лишнего кода, но достаточно прост для понимания и написания модульных тестов.
Адаптация паттерна Command с применением Dependency Injection усиливает разделение ответственностей и облегчает масштабирование и тестирование команд, но кроме преимуществ имеет и ряд недостатков.
Преимущества
Слабое сопряжение: команды получают зависимости через DI-контейнер или конструктор, не зная о реализациях сервисов, что облегчает замену, модульное и интеграционное тестирование.
Расширяемость: легче внедрять новые команды и сервисы без изменения существующего кода.
Управление зависимостями: централизованная регистрация зависимостей и жизненного цикла компонентов через DI-контейнер.
Явность зависимостей: DI через конструктор делает зависимости класса видимыми сразу при создании, а не при использовании, что снижает вероятность runtime-ошибок.
Тестируемость: все внешние сервисы легко замокать или подменить при написании модульных и интеграционных тестов.
Недостатки
Избыточная абстракция: для мелких проектов, или тривиальных сервисов, DI и Command могут излишне усложнить архитектуру.
Рост числа классов: Command + DI увеличивают количество типов (интерфейс и его реализация для каждой команды), усложняя навигацию по проекту.
Порог вхождения: новичкам сложнее понять взаимосвязи при сложной цепочке команд и скоплении зависимостей, особенно при неправильной конфигурации DI-контейнера.
Хотя здесь важно отметить, что это целиком зависит от того насколько понятно функционал приложения разбит на отдельные части.
Потенциальная потеря производительности: DI-контейнеры вносят небольшую прослойку, а в высоконагруженных системах это может сказаться на скорости запуска и инстанцирования.
Стоит отметить, что в бизнес приложениях узким местом чаще является взаимодействие с хранилищами данных или другими микросервисами, поэтому использование DI-контейнеров не оказывает заметного влияния на производительность. Для снижения нагрузки при создании дерева объектов, можно использовать паттерн Lazy, в .NET DI-контейнеры часто поддерживают Lazy для автоматической поддержки отложенной инициализации.
Сложности с конфигурацией: наличие циклических зависимостей или неправильная настройка контейнера может привести к трудноуловимым ошибкам на этапе запуска.
Проектируем правильно
Чтобы использование Command + DI принесло ощутимую пользу в проекте, следует максимально использовать сильные стороны подхода и минимизировать его недостатки.
Разделение кода на команды
Cледование SOLID
Грамотное проектирование команд и сервисов требует всегда опираться на SOLID-принципы — особенно на "dependency inversion" и "single responsibility":
Команды должны зависеть только от интерфейсов и не содержать логики создания зависимостей.
Каждая команда отвечает только за одну задачу.
Задача команды должна описыватся в логике максимально понятной человеку.
Пересоленный SOLID
Важно отметить вредность излишнего увлечения абстракциями как структурными (классы, интерфейсы, уровни архитектуры), так и алгоритмическими.
Чрезмерное увлечение абстракциями негативно сказывается на качестве кода и производительности разработчиков, последнее проявляется в усложнении понимания, сопровождения и внедрения изменений в проект.
Проблемы избыточных абстракций
Избыточные уровни абстракции ведут к «архитектурной космичности»: код становится чрезмерно многослойным, даже простые задачи требуют изучения большого количества взаимосвязей.
Понижается читаемость: новые члены команды разработки испытывают трудности при погружении в систему из-за абстракций ради самих абстракций.
Ухудшение поддержки: малейшее изменение требований может потребовать внести правки во множество абстракций, что способствует ошибкам и наращиванию технического долга.
«Дырявые» абстракции: сложные абстракции часто не могут полностью спрятать детали реализации, что приводит к появлению багов и неожиданностей в поведении системы.
Причины вреда алгоритмических абстракций
Использование универсальных, шаблонных алгоритмических абстракций там, где достаточно было бы конкретных прямых реализаций, мешает оптимизации и делает код менее прозрачным для анализа и дебага.
Большое число слоёв может снижать производительность из-за дополнительных накладных расходов и усложнённого потока данных между слоями абстракций.
Возможен избыток «ложноположительных» предупреждений в статическом анализе, усложняя определение настоящих ошибок.
Признаки вредных абстракций
Абстракция используется лишь в одном месте, но создана «на вырост».
Абстракция нуждается в постоянных изменениях при добавлении новых фич, так как была сформирована на основе частного случая.
Практические рекомендации
Не выводить абстракции заранее без повторяющихся кейсов, а рефакторить по мере появления дублирования кода. Постоянный рефакторинг кода приносит больше пользы в долгосрочной перспективе, хотя в моменте может казатся излишней тратой сил и игрой в перфектционизм.
Следовать принципам SOLID с оглядкой на реальные задачи, не увлекаясь фанатично шаблонами проектирования.
Различать структурные и алгоритмические уровни, избегать смешения и простых повторов абстракций для каждой ситуации.
Правильный баланс между уровнем абстракции и конкретностью — один из ключей к качественной, поддерживаемой архитектуре.
DI с поддержкой Lazy в .NET
В .NET несколько популярных DI-контейнеров поддерживают ленивую (Lazy) инициализацию зависимостей напрямую или через сторонние библиотеки:
Microsoft.Extensions.DependencyInjection
DI от Microsoft доступен начиная с .NET Core. Поддерживает внедрение Lazy "из коробки". Можно просто указать зависимость в конструкторе как Lazy, и DI-контейнер создаст объект только при первом обращении через Lazy.Value. Для более продвинутого поведения (например, ленивое создание прокси) существуют дополнительные библиотеки (LazyProxy).
Autofac
Встроенная поддержка Lazy. Позволяет регистрировать зависимости как ленивые и внедрять их как Lazy. Существует библиотека LazyProxy.Autofac для более гибкого ленивого внедрения.
Unity Container
Поддерживает Lazy либо через встроенный функционал, либо с помощью LazyProxy.Unity. LazyProxy.Unity позволяет регистрировать сервисы как ленивые с проксированием вызовов.
Другие библиотеки и решения
Существует библиотека LazyProxy.ServiceProvider, которая добавляет ленивую инициализацию для стандартного Microsoft DI контейнера с проксированием. Для Autofac и Unity также есть аналоги, позволяющие избежать "грязного" избыточного кода с Lazy, сохраняя чистоту и прозрачность архитектуры.
Как избегать циклических зависимостей
Циклические зависимости — одна из самых частых ловушек в системах с DI и паттерном Command, особенно кода команды зачастую расширяют свою функциональность и начинают ссылаться друг на друга или на сервисы, которые зависят от них же. К счастью эта проблема решается простым способом, а разработчики привыкшие разделять контракт и его реализацию даже никогда с ней не сталкивались.
Подходы решения:
Явное разделение интерфейсов: внедряются только интерфейсы, а не реализации, чтобы не было перекрёстных ссылок между реализациями команд и сервисов.
Выделение общих зависимостей в отдельный слой, который используют более специфичные зависимости.
Использование промежуточных уровней обмена данными - событий/очередей, если требуется двустороннее взаимодействие между командами и службами — это обеспечивает асинхронный обмен и не требует прямых ссылок.
Заключение
Совмещение паттерна Command с DI даёт архитектурную гибкость и масштабируемость, но требует дисциплины и опыта, чтобы избежать избыточной сложности и проблем с поддержкой кода. Оптимально применять такой подход в крупных или средних проектах либо там, где требуется явное разделение логики на отдельные атомарные изолированные команды, а также при необходимости получить высокую степень покрытия кода тестами.
Паттерн Command, дополненный грамотным внедрением зависимостей, позволяет строить гибкие, хорошо масштабируемые архитектуры, особенно в мире современных приложений и микросервисов. Главное — не поддаваться искушению "сшивать" компоненты напрямую, не забывать о SOLID и не оставлять слепых зон в графе зависимостей.
Правильный дизайн сэкономит в будущем много времени и сил на поддержку и развитие проекта.
Комментарии (3)
MyraJKee
05.10.2025 17:28Какой-то не полный пример. Команд должно быть несколько? Иначе зачем тогда оно вообще нужно. И если их будет несколько, не факт что получится реализовать общий интерфейс? А будет ещё хуже, когда например уже существующий интерфейс команды не получится расширить из-за того что в методе execute не нужны такие параметры
vsting
По моему паттерн command, этот антипаттерн, потому что скрывает за командами реальные классы и модули, это усложняет поиск класса которы привязан к команде. И в таком приложении реально добавляет неудобства поддержки.
holgw
Ну тогда по вашим словам принципиально любое инкапсулирование логики в классах будет усложнять читаемость. И тогда 90% паттернов -- это антипаттерны, потому что в них предполагается передача зависимостей через конструктор.
Каким образом? Все зависимости читаются в конструкторе класса (команды в нашем случае). Либо прямо по месту использования можно по одному клику провалиться в реализацию интересующего нас метода.
Ну и паттерн "Команда" не про это вообще -- у команды может и не быть внешних зависимостей, никто не запрещает логику прямо в классе команды реализовать. Он (этот паттерн) нужен исключительно как подход для объединения множества операций под единым интерфейсом.
Например, классический пример -- это реализация undo\redo функционала в приложении. Приложение фиксирует последовательность команд, запущенных пользователем и если все команды закрыты одним интерфейсом
то, при необходимости откатить изменения, мы можем просто вытягивать элементы стэка _commandsHistory и вызывать ICommand.Undo(), абстрагировавшись от реализации этих самых команд.