Вступление

Паттерн 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)


  1. vsting
    05.10.2025 17:28

    По моему паттерн command, этот антипаттерн, потому что скрывает за командами реальные классы и модули, это усложняет поиск класса которы привязан к команде. И в таком приложении реально добавляет неудобства поддержки.


    1. holgw
      05.10.2025 17:28

      command, этот антипаттерн, потому что скрывает за командами реальные классы и модули

      Ну тогда по вашим словам принципиально любое инкапсулирование логики в классах будет усложнять читаемость. И тогда 90% паттернов -- это антипаттерны, потому что в них предполагается передача зависимостей через конструктор.

      это усложняет поиск класса которы привязан к команде

      Каким образом? Все зависимости читаются в конструкторе класса (команды в нашем случае). Либо прямо по месту использования можно по одному клику провалиться в реализацию интересующего нас метода.

      Ну и паттерн "Команда" не про это вообще -- у команды может и не быть внешних зависимостей, никто не запрещает логику прямо в классе команды реализовать. Он (этот паттерн) нужен исключительно как подход для объединения множества операций под единым интерфейсом.

      Например, классический пример -- это реализация undo\redo функционала в приложении. Приложение фиксирует последовательность команд, запущенных пользователем и если все команды закрыты одним интерфейсом

      Stack<ICommand> _commandsHistory;
      
      interface ICommand
      {
        void Undo();
      }

      то, при необходимости откатить изменения, мы можем просто вытягивать элементы стэка _commandsHistory и вызывать ICommand.Undo(), абстрагировавшись от реализации этих самых команд.


  1. MyraJKee
    05.10.2025 17:28

    Какой-то не полный пример. Команд должно быть несколько? Иначе зачем тогда оно вообще нужно. И если их будет несколько, не факт что получится реализовать общий интерфейс? А будет ещё хуже, когда например уже существующий интерфейс команды не получится расширить из-за того что в методе execute не нужны такие параметры