Вступление
Паттерн 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 и не оставлять слепых зон в графе зависимостей.
Правильный дизайн сэкономит в будущем много времени и сил на поддержку и развитие проекта.
UPD
Возможно, стоило при написании статьи и примеров кода, вместо базового паттерна Command использовать паттерн Function Object. Но есть проблема, что Function Object нигде не описан и является как бы естественной конструкцией и естественным приёмом программирования, в отличие от более формального паттерна Command, который конечно же является наследником Function Object.
Надеюсь, что это примечание облегчит понимание статьи и описанного подхода.
Комментарии (15)
MyraJKee
05.10.2025 17:28Какой-то не полный пример. Команд должно быть несколько? Иначе зачем тогда оно вообще нужно. И если их будет несколько, не факт что получится реализовать общий интерфейс? А будет ещё хуже, когда например уже существующий интерфейс команды не получится расширить из-за того что в методе execute не нужны такие параметры
zelenin
05.10.2025 17:28автор не адаптировал паттерн Command, а заменил команду на сервис
antonb73 Автор
05.10.2025 17:28Проблема в том, что нет такого паттерна как Service.
Когда мы говорим о Service имеется ввиду некое общее определение:
Service — обычно структурный или фасадный паттерн, инкапсулирующий определённую бизнес-логику или предоставляющий набор операций (сервис), которые используют другие компоненты приложения.
И да, после модификации Command вышел за границы своей изначальной ответсвенности, и по сути это уже не Command.
В объектно-ориентированном программировании шаблон проектирования Команда является поведенческим шаблоном, в котором объект используется для инкапсуляции всей информации, необходимой для выполнения действия или вызова события в более позднее время. Эта информация включает в себя имя метода, объект, который является владельцем метода и значения параметров метода.
Но моя статья про практику, а не теорию. Возможно стоит именовать предложенный подход как то иначе?
PS Возможно предлагаемый подход больше напоминает Function Object Pattern, который не столь широко известен.
SadOcean
05.10.2025 17:28Мне кажется, в вашем подходе теряется главное - абстрактность команд.
Execute идет без параметров не потому, что так модно, а потому, что все команды поддерживают один интерфейс, разнородные команды можно складывать в очередь, выполнять позже, сериализовать и десериализовать, передавать по сети и т.д.
Ну и потребителю команды должно быть плевать, о чем она.
Команды - это функции с ООП оберткой.
Если же она принимает явные параметры - теряется суть паттерна. Это становится просто анемичной функцией с архитектурной ООП космонавтикой.
Ваша статья примерно так и выглядит, уж извините. Фабрика для порождения команды, которой передается сервис и параметр, чтобы у сервиса вызвать параметр - выглядит как FooBar ООП едишн и непонятно, что решает.
А между тем сама проблема довольно сложная и важная - задача разделения данных для выполнения (фактически параметров команды) и зависимостей довольно важна, ровно для того, чтобы не таскать везде зависимости (что само по себе противоположно тому, зачем нужны команды).
Простой пример, который это реализует, может выглядеть так:
- Это пример для команд, которые сохраняются в очередь и передаются по сети, чтобы клиент и сервер одинаково их применяли.
- Команда делается из нескольких классов, собственно команды (класс определенного типа с данными) и ее исполнителя - специализированного класса, который содержит логику и набор зависимостей, которые нужны команде для работы.
- Исполнители создаются лениво и 1 раз для 1 типа комманды
- При старте команды сохраняется собственно класс данных (он же сериализуется и может быть отложен). При необходимости выполнения исполнитель команд находит/создает в списке специализированный исполнитель и отдает ему команду.
Это же можно сделать шиворот-навыворот - команда описывается с Execute, который требует некоторый тип Context класса с зависимостями.
Если кому то интересно, могу накидать код, все ж с рабочего проекта копипастить не очень (да и кривенький)antonb73 Автор
05.10.2025 17:28В подходе ничего не теряется, Command как был так и остался, никто его не запрещал, в моём подходе он был изменён для решения других задач.
Это статья не про улучшение Command.
В примечании в конце статьи указал, что видимо надо было танцевать от Function Object, было бы меньше недопонимания.
vsting
По моему паттерн command, этот антипаттерн, потому что скрывает за командами реальные классы и модули, это усложняет поиск класса которы привязан к команде. И в таком приложении реально добавляет неудобства поддержки.
holgw
Ну тогда по вашим словам принципиально любое инкапсулирование логики в классах будет усложнять читаемость. И тогда 90% паттернов -- это антипаттерны, потому что в них предполагается передача зависимостей через конструктор.
Каким образом? Все зависимости читаются в конструкторе класса (команды в нашем случае). Либо прямо по месту использования можно по одному клику провалиться в реализацию интересующего нас метода.
Ну и паттерн "Команда" не про это вообще -- у команды может и не быть внешних зависимостей, никто не запрещает логику прямо в классе команды реализовать. Он (этот паттерн) нужен исключительно как подход для объединения множества операций под единым интерфейсом.
Например, классический пример -- это реализация undo\redo функционала в приложении. Приложение фиксирует последовательность команд, запущенных пользователем и если все команды закрыты одним интерфейсом
то, при необходимости откатить изменения, мы можем просто вытягивать элементы стэка _commandsHistory и вызывать ICommand.Undo(), абстрагировавшись от реализации этих самых команд.
antonb73 Автор
Вообще то нет, вы описываете обычный Virtual Function, а комманда в своём первоначальном описании:
Команда является поведенческим шаблоном, в котором объект используется для инкапсуляции всей информации, необходимой для выполнения действия или вызова события в более позднее время. Эта информация включает в себя имя метода, объект, который является владельцем метода и значения параметров метода.
holgw
При чем тут Virtual Function, что из того что я описал хоть как-то может относиться к виртуальным функциям?
И я описал для чего этот паттерн обычно используется, а вы зачем-то в качестве контраргумента привели одно из определений паттерна, это вообще перпендикулярные вещи.
Вы не согласны именно с моим утверждением про то что это паттерн "для объединения множества операций под единым интерфейсом" или с тем что у реализации этого паттерна не обязательно должны быть внешние зависимости?
antonb73 Автор
не согласен с
Единый интерфейс даёт виртуальный метод, поддержка которого встроено во множество языков программирования и не требует отдельных структур.
holgw
Эм, единый интерфейс, как ни странно, определяют при помощи... интерфейсов. А виртуальные методы -- это же просто встроенная возможность языка переопределять методы наследуемого класса, при чем тут они?
Например, у нас в коде определены несколько сущностей с похожим функционалом -- Cat, Dog, Human, реализующие метод Run(). Если мы хотим в коде смоделировать стадион, по которому будут бегать экземпляры наших людей, кошек и собак (т.е. нам не важно как именно бегает конкретный инстанс объекта, на четырех ногах или на двух, нам важно только знать, что этот объект умеет бегать), то что мы будем использовать? Виртуальные методы (честное слово, не представляю как) или все-таки для этого используются интерфейсы?
antonb73 Автор
я понял, о чем вы говорите. Постараюсь объяснить.
Абстракцию "интерфейс" можно рассматривать как частный случай абстракции "виртуальный метод". Во многих объектно-ориентированных языках абстрактные методы являются частным случаем виртуальных методов, при этом абстрактный метод — это виртуальный метод без реализации, который требует обязательного переопределения в классах-потомках. Интерфейсы же по сути содержат только абстрактные методы (без реализации), задавая контракт, который должны реализовывать классы.
Не все языки имеют конструкции языка - интерфейсы.
В языке C++ абстракция интерфейса поддерживается через использование абстрактных классов с чисто виртуальными методами. В C++ нет отдельного ключевого слова для интерфейсов, как в некоторых других языках, но любой абстрактный класс с одним или более чисто виртуальными методами (объявленными с = 0) фактически играет роль интерфейса.
virtual void methodName() = 0;