Сравнение архитектуры статик-шин Vortex и DI-инъекций VContainer

В Unity-проектах есть несколько устоявшихся подходов к организации архитектуры. Два из них — статические шины данных (как в Vortex Framework) и контейнеры внедрения зависимостей (как VContainer или Zenject). На бумаге они решают одну и ту же задачу: «как одна часть системы достучится до другой». На практике подходы существенно различаются — и в стиле кода, и в цене расширения, и в характере ошибок.

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


Зачем вообще понадобилась альтернатива DI

Прежде чем сравнивать механически, стоит ответить на вопрос: DI — это уже отраслевой стандарт. Зачем Vortex отказался от него?

Это не «недосмотр» и не «velosipedostroenie». Это сознательный архитектурный выбор, продиктованный тремя соображениями.

1. DI-каркас родом из Enterprise-мира. Constructor injection, контейнеры, регистрации — это паттерны, отполированные на серверных приложениях, где подмена реализаций — реальный сценарий: разные базы данных, миграции между версиями, A/B-тесты сервисов. В Unity-разработке такие сценарии редки: за всю жизнь проекта InventoryService обычно один. Платить ceremoning регистраций за гипотетическую гибкость — экономически невыгодно.

2. Unity-инспектор уже даёт композицию через ассеты. Когда геймдизайнер настраивает поведение через ScriptableObject-конфиги, прехотят через дропдауны, дёргают тоглы в SdkSettings — он уже занимается dependency composition, просто другим способом. Vortex осознанно идёт навстречу этому workflow: связи между системами выражаются через ScriptableObject-конфиги и asmdef-границы, а не через программный Container.Register. Это inspector-native подход, который снимает с разработчика обязанность дублировать каждое решение геймдизайнера в коде.

3. Stable contracts вместо graph resolution. В DI зависимости выражаются как граф объектов, который контейнер разрешает в runtime. В Vortex они выражаются как стабильные шинные контракты, доступные напрямую через статический фасад. Граф не убран — он просто перенесён с runtime-объектов на assemblies, layers и шины. Это даёт компактность вызовов (Inventory.AddItem(...) вместо _inventoryService.AddItem(...) с цепочкой регистраций) ценой явности dependency graph.

То есть Vortex — не «DI для бедных» и не «отрицание DI». Это альтернативная композиция, где роль контейнера выполняют:

  • статические шины-фасады (стабильные точки доступа);
  • ScriptableObject-конфиги (выбор реализаций);
  • иерархия слоёв через asmdef (контроль направления связей);
  • Driver-pattern (подменяемость реализаций).

Каждый из этих механизмов работает на свою часть задачи, которую в DI закрывает контейнер. Получается opinionated runtime со структурным каноном, а не «договорились писать аккуратно».

Дальше — детальное сравнение того, как это выглядит в коде, и где каждый подход выигрывает.


Что такое «выдача наград»

Возьмём типичную систему игровых наград. В RPG, гачи, или любой игре с прогрессией это устройство встречается одинаково:

  • Пресет (ассет в проекте) — конфигурация таблицы дропа.
  • Группы в пресете — взвешенный rng-выбор.
  • Награды в группе — конкретные единицы (предмет, хил, спавн моба).
  • Поведение выдачи — что именно происходит при применении награды (в Vortex это стратегия, в VContainer — команда + handler).

И в Vortex, и в VContainer структура данных совпадает. Различие — в том, как поведение выдачи получает доступ к Inventory, Pawn, Spawner и подобным системам.


Декомпозиция данных — одинакова

В обеих архитектурах:

RewardPreset (ScriptableObject)
    └── RewardPack[]              ← взвешенные группы
            └── RewardData[]      ← одиночные награды
                    └── ...       ← стратегия / команда

Различие только в последнем уровне:

  • Vortex. RewardData хранит RewardStrategy — полиморфный объект с данными и поведением (паттерн Strategy: «знаю, как себя выполнить»). Поведение обращается к чужим системам через статические шины-фасады (Inventory.AddItem(...)), за которыми скрыты подменяемые драйверы реализаций.
  • VContainer. RewardData хранит RewardCommand — полиморфный объект только с данными (паттерн Command из CQRS: «декларация намерения, без знания о выполнении»). Поведение вынесено в отдельный сервис-handler, который получает зависимости через конструктор.

Это не косметическая разница в названиях — это разные паттерны проектирования, и они задают весь дальнейший стиль кода в каждом подходе.


Точка различия — как достучаться до соседей

Вариант Vortex

Прежде чем смотреть код — важная оговорка о терминах. Inventory в Vortex — это не монолитный singleton с захардкоженной логикой. По канону каждая шина Vortex двухуровневая:

Inventory (статический контроллер-фасад)
    └── IInventoryDriver  ← подменяется через DriverConfig
            └── InventoryDriverLocal / InventoryDriverNetwork / InventoryDriverMock

Сам Inventory — это тонкая шина-фасад с фиксированным публичным API (AddItem, CanAdd, Remove...). Что происходит за этим API — определяет драйвер, выбранный в DriverConfig. Локальный, серверный, тестовый mock — стратегия об этом не знает и не должна. Когда стратегия вызывает Inventory.AddItem(...), она обращается к шине, а не к реализации.

Это и есть Vortex-аналог DI-инверсии зависимостей: связь идёт не на конкретный класс, а на устойчивый контракт шины.

Теперь — собственно стратегия:

[Serializable]
public class GiveItemStrategy : RewardStrategy
{
    [SerializeField] private string itemName;
    [SerializeField, Min(1)] private int count = 1;

    public override bool Validation(string targetId = null, float power = 1f)
        => Inventory.CanAdd(targetId, itemName, count);

    public override RewardResult GiveReward(string targetId = null, float power = 1f)
    {
        var scaled = Mathf.Max(1, Mathf.RoundToInt(count * power));
        Inventory.AddItem(targetId, itemName, scaled);
        return RewardResult.Ok(scaled);
    }
}

Inventory.AddItem(...) — это вызов через шину к текущему активному драйверу инвентаря. Доступ к шине открыт из любой точки кода, но сама реализация скрыта за контрактом.

Стратегия — это атомарный самодостаточный класс с данными и поведением, который десериализуется Unity как обычный объект. Поведение «как выдать награду» живёт прямо здесь, рядом с её данными — в одном неделимом узле, который нельзя разделить на «данные отдельно, исполнение отдельно». Но то, как именно инвентарь добавит предмет — определяется не стратегией, а текущим драйвером системы инвентаря.

Вариант VContainer

Класс награды становится pure data — командой. Поведение вынесено в отдельный сервис-handler:

[Serializable]
public class GiveItemCommand : RewardCommand
{
    [SerializeField] private string itemName;
    [SerializeField, Min(1)] private int count = 1;

    public string ItemName => itemName;
    public int Count => count;
}

public class GiveItemHandler : RewardCommandHandler<GiveItemCommand>
{
    private readonly IInventoryService _inventory;

    public GiveItemHandler(IInventoryService inventory)
    {
        _inventory = inventory;
    }

    protected override bool ValidationTyped(GiveItemCommand cmd, string targetId, float power)
        => _inventory.CanAdd(targetId, cmd.ItemName, cmd.Count);

    protected override RewardResult ExecuteTyped(GiveItemCommand cmd, string targetId, float power)
    {
        var scaled = Mathf.Max(1, Mathf.RoundToInt(cmd.Count * power));
        return _inventory.AddItem(targetId, cmd.ItemName, scaled)
            ? RewardResult.Ok(scaled)
            : RewardResult.Fail("InventoryAddFailed");
    }
}

IInventoryService приходит через конструктор — контейнер VContainer подставляет конкретный инстанс при создании handler'а. Каждый handler работает с одним типом команды и явно объявляет, что ему нужно. Сама команда (GiveItemCommand) знает только что выдать, не как — это знание перенесено в handler.


События после выдачи

Обе системы используют одинаковую модель — мутация состояния плюс событие об успешной выдаче. И обе одинаково защищают право на эмит: подписаться может любой, но триггерить событие — только тот, кто отвечает за выдачу. Различается только механизм этой защиты.

В Vortex это статический класс с парой event-полей. Защита эмита держится на модификаторе доступа internal:

public static class RewardBus
{
    public static event Action<RewardEventData> OnRewardGiven;
    public static event Action<RewardEventData> OnRewardFailed;

    internal static void EmitGiven(RewardEventData data) => OnRewardGiven?.Invoke(data);
    internal static void EmitFailed(RewardEventData data) => OnRewardFailed?.Invoke(data);
}

Доступ к подписке открыт отовсюду (public event), а эмит вызывается только изнутри сборки — внешний код через internal-метод вызвать не сможет. Защита работает на уровне границы сборки (assembly).

В VContainer тот же контракт оформлен как сегрегация интерфейсов:

public interface IRewardEventBus
{
    event Action<RewardEventData> OnRewardGiven;
    event Action<RewardEventData> OnRewardFailed;
}

public interface IRewardEventEmitter
{
    void EmitGiven(RewardEventData data);
    void EmitFailed(RewardEventData data);
}

public class RewardEventBus : IRewardEventBus, IRewardEventEmitter { ... }

Один и тот же инстанс регистрируется под обоими интерфейсами. Сервис, который может эмитить, инжектится через IRewardEventEmitter. Подписчики получают только IRewardEventBus — у них физически нет метода для эмита, его в интерфейсе нет.

Симметрия защиты

Это — ключевой момент сравнения. Оба подхода решают одну и ту же задачу — изолировать право на эмит — но разными механизмами C#:

Что защищает Vortex VContainer
Право на эмит Модификатор internal — assembly boundary Сегрегация интерфейсов — IRewardEventEmitter отдельно от IRewardEventBus
Право на подписку public event — открыто всем IRewardEventBus инжектится всем желающим
Гарантия Внешняя сборка не вызовет emit Потребитель без IRewardEventEmitter не вызовет emit
Чем приобретается Принадлежность к сборке Явное разделение интерфейсов

В обоих случаях нарушить разделение нельзя случайно. Vortex кладёт защиту на уровень компиляции (сборка не видит internal-членов чужой сборки), VContainer — на уровень контракта (интерфейс без метода — это интерфейс без метода). Оба механизма строгие, оба надёжные, просто работают на разных слоях языка.


Регистрация и инициализация

Vortex

Регистрация автоматическая. Статические контроллеры существуют как только класс загружен. Шины событий — то же самое, доступны сразу после старта приложения. Никаких installer'ов и явных configure-шагов нет.

// Достаточно объявить класс — статика подцепится сама.
public static class RewardController { ... }
public static class RewardBus { ... }

VContainer

Регистрация ручная — через LifetimeScope.Configure:

public class GameLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.Register<IInventoryService, InventoryService>(Lifetime.Singleton);
        builder.Register<IPawnService, PawnService>(Lifetime.Singleton);

        builder.Register<IRewardCommandHandler, GiveItemHandler>(Lifetime.Singleton);
        builder.Register<IRewardCommandHandler, GiveHealHandler>(Lifetime.Singleton);
        builder.Register<IRewardCommandHandler, SpawnMimicHandler>(Lifetime.Singleton);

        builder.Register<IRewardCommandDispatcher, RewardCommandDispatcher>(Lifetime.Singleton);
        builder.Register<RewardEventBus>(Lifetime.Singleton).AsImplementedInterfaces();
        builder.Register<IRewardService, RewardService>(Lifetime.Singleton);
    }
}

Каждая зависимость объявлена явно. При появлении новой системы — новая строка.


Использование на стороне потребителя

В обеих архитектурах потребитель выглядит похоже, отличается только способ получения сервиса.

Vortex:

public class ChestController : MonoBehaviour
{
    [SerializeField] private RewardPreset rewards;

    public void OnOpen(string playerId)
    {
        var picked = rewards.GetReward();
        foreach (var reward in picked)
            reward.GiveReward(playerId);
    }
}

VContainer:

public class ChestController : MonoBehaviour
{
    [SerializeField] private RewardPreset rewards;

    private IRewardService _rewardService;

    [Inject]
    public void Construct(IRewardService rewardService) => _rewardService = rewardService;

    public void OnOpen(string playerId)
    {
        var picked = _rewardService.GetReward(rewards);
        foreach (var reward in picked)
            _rewardService.GiveReward(reward, playerId);
    }
}

Семантика та же. Тело метода OnOpen практически идентично. Разница в установочной части — в Vortex её нет, в VContainer есть метод Construct с [Inject].


Тестируемость

Две архитектуры исповедуют разные testing philosophies — это не «одна тестируется лучше другой», а две разные школы.

Vortex — deterministic system testing

Vortex ориентирован на детерминированное системное тестирование: подмена драйверов, playmode-окружение, симуляция состояния. Тест собирает тестовый DriverConfig с моками шинных реализаций (InventoryDriverTest : IInventoryDriver), Vortex поднимает всю систему с этими моками — и тест прогоняет сценарии целиком, проверяя итоговое состояние моделей.

// псевдокод playmode-теста на Vortex
[Test, RunInPlayMode]
public void ChestOpening_AddsExpectedItems()
{
    LoadDriverConfig("Test/MockedInventory");        // подмена через канон
    var chest = SpawnTestChest();
    chest.Open(playerId);
    Assert.That(Inventory.GetItems(playerId), Has.Member("sword"));
}

Это integration-style: тестируется полная цепочка «команда → контроллер → шина → драйвер → модель». Достоинство — детерминированное поведение полной системы под нагрузкой реальных сценариев. Недостаток — каждый тест дороже подъёмом окружения.

Чистые юнит-тесты на стратегию без подъёма Vortex-инфраструктуры возможны, но в проектах на Vortex это редкий случай — основная масса проверок идёт через playmode.

VContainer — isolated object testing

VContainer ориентирован на изолированное юнит-тестирование через мок-конструкторы:

[Test]
public void GiveItemHandler_AddsToInventory()
{
    var mockInventory = Substitute.For<IInventoryService>();
    mockInventory.AddItem(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>()).Returns(true);

    var handler = new GiveItemHandler(mockInventory);
    var cmd = new GiveItemCommand { /* ... */ };
    var result = handler.ExecuteTyped(cmd, "player_1", 1f);

    Assert.That(result.Success, Is.True);
    mockInventory.Received().AddItem("player_1", "sword", 1);
}

Никакого контейнера, никакого playmode. Чистый юнит handler'а с моком зависимости. Достоинство — быстро, дёшево, изолированно. Недостаток — тестируется поведение одного класса, не полная цепочка взаимодействий. Интеграционная связность между handler'ами всё равно тестируется отдельно (через integration tests или playmode).

Это разные школы, а не «лучше / хуже»

Аспект Vortex (deterministic system) VContainer (isolated object)
Базовый тип теста Playmode + driver swap Pure C# unit test
Стоимость одного теста Выше (подъём окружения) Ниже (только конструктор)
Что тестируется Полная цепочка систем Один класс в изоляции
Защита от регрессии На уровне сценариев На уровне контрактов
Покрытие edge cases Сложнее (нужно собрать сценарий) Проще (передал краевое значение в мок)
Уверенность в интеграции Высокая (тестируется собранная система) Низкая (нужны отдельные integration tests)

Если в команде культура «один класс — один unit-тест», VContainer окажется удобнее. Если культура «фичу прогоняем end-to-end», удобнее Vortex. Обе школы дают рабочую защиту от регрессий, просто разными средствами.


Расширение новым типом награды

Vortex — один файл (новая стратегия):

[Serializable]
public class GiveXpStrategy : RewardStrategy
{
    // данные + Validation + GiveReward
}

VContainer — два файла (команда + handler) плюс регистрация:

// 1. Команда
[Serializable]
public class GiveXpCommand : RewardCommand { ... }

// 2. Handler
public class GiveXpHandler : RewardCommandHandler<GiveXpCommand>
{
    public GiveXpHandler(IXpService xp) { ... }
}

// 3. Регистрация в LifetimeScope
builder.Register<IRewardCommandHandler, GiveXpHandler>(Lifetime.Singleton);

Если включить авто-регистрацию через рефлексию — третий шаг автоматизируется. Тогда расширение требует двух файлов вместо одного.


Замена реализации без правки потребителей

Симметричный к расширению вопрос: что делать, если меняется не тип награды, а способ работы целой системы. Например, инвентарь переехал с локальной модели на серверную, и AddItem теперь идёт через сетевой вызов.

В Vortex меняется драйвер. Контроллер Inventory остаётся, меняется только InventoryDriverNetwork : IInventoryDriver, выбирается в DriverConfig. Стратегии наград (GiveItemStrategy) не правятся ни одной строкой — они и не знали, через какой драйвер шёл вызов. Это плата за дисциплину «обращаться к шине, а не к реализации».

В VContainer меняется регистрация. Старая строка builder.Register<IInventoryService, LocalInventoryService> заменяется на builder.Register<IInventoryService, NetworkInventoryService>. Handler'ы (GiveItemHandler) не правятся — они работают через интерфейс IInventoryService. Это плата за церемонию «всегда программируй на интерфейс».

В обоих случаях замена реализации изолирована от мест её использования — единственная разница в том, где находится точка подмены: ScriptableObject-конфиг или метод Configure.


Цена компактности и явности

Связи в Vortex не скрыты — они перенесены в другой механизм. В VContainer связи выражаются через object graph и constructor injection: каждое отношение явно объявлено как параметр конструктора. В Vortex те же связи выражаются через shared data contracts и статические шины доступа: каждая система видит другую через шинный API с фиксированным контрактом. Dependency graph не hidden, он relocated.

Vortex даёт компактность ценой того, что для понимания «кто чем пользуется» нужно искать обращения к шинам, а не параметры конструкторов. Vortex меньше полагается на compile-time dependency declaration, но сильнее — на архитектурный канон и структурную стандартизацию среды: иерархия слоёв через asmdef-границы, обязательный Driver-pattern с whitelist, GUID на типовых единицах, [SerializeReference] для полиморфных декомпозиций, internal-эмит шин. Правило «изменять данные имеет право только контроллер» держится не на «договорились писать аккуратно», а на том, что структура проекта не оставляет удобного места для альтернативы.

VContainer даёт каноническую compile-time связность ценой ceremoning. Каждая зависимость объявлена в конструкторе. Каждый сервис зарегистрирован. Каждый потребитель получает зависимости через [Inject]. Это снимает вопрос «где это используется» — достаточно поиска по конструкторам. Но порог входа выше, файлов больше, любая правка требует пройти через LifetimeScope.

Порог входа в архитектуру

Различие, которое чувствуется не сразу, но влияет на adoption.

Важно разделять две вещи в Vortex:

  • Полная философия (Core-канон): Controller / Data / View, шинная развязка между системами, GUID на каждой типовой единице, ISaveable для сохраняемых модулей, иерархия слоёв Core / Unity / Sdk / AppLocale. Это требует adoption целиком — полу-Vortex на уровне канона не работает, нарушение одного контракта обнуляет смысл остальных.
  • Technical modules: пакеты Unity-слоя — UI Components, TweenerSystem, UIStateSwitcher, DatabaseSystem (Addressables-драйверы), готовые Handlers, AudioSystem с каналами, EditorTools-атрибуты. Эти модули можно брать отдельно, без adoption всей философии. Они спроектированы как modular composition framework: подключил пакет — пользуешься, не обязан тащить остальное.

То есть «Vortex требует переписать всё» — слишком жёсткая формулировка. Корректнее: полный канон требует adoption целиком, отдельные технические пакеты можно использовать независимо в проекте на любой архитектуре, включая DI.

VContainer в этом смысле проще — внедряется постепенно как механика. Можно начать с одной системы (например, заменить static class Inventory на IInventoryService с регистрацией), потом постепенно завернуть в DI остальные. Половина проекта может оставаться на static-singleton'ах, пока другая половина переезжает на интерфейсы. Это плавная миграция всей кодовой базы под одну механику.

Для существующего проекта различие критическое — переход на DI-механику через VContainer обычно дешевле, чем переход на Vortex-канон. Но это сравнение методологий, не пакетов: технические компоненты Vortex (тот же TweenerSystem или UIStateSwitcher) ставятся в любой проект отдельным package'м.

Для нового проекта различие меньше: с нуля и тот и другой принимаются разом. Но Vortex даёт полный архитектурный комплект (данные, сохранения, локализация, аудио, UI-инфраструктура), а VContainer — только механику разрешения зависимостей. Остальное в DI-проекте надо собирать самостоятельно либо подтягивать сторонние пакеты.


Когда что выбрать

Сценарий Лучше подходит
Прототип, jam-проект, обучение Vortex (быстрый старт)
Команда из 1–3 человек, средний проект Vortex (меньше ceremoning)
Команда из 10+ человек VContainer (явные границы)
Глубокая доменная модель, данные важнее сервисов Vortex (data-first архитектура из коробки)
Преимущественно сервисная архитектура VContainer (service-first идиома)
Школа isolated object testing (каждый класс — свой unit) VContainer (моки через конструктор)
Школа deterministic system testing (playmode + driver swap) Vortex (полные сценарии в собранной среде)
Множественные конфигурации сервисов под платформы VContainer (Lifetime.Scoped + разные регистрации)
Single-player игра без сложного многопользовательского состояния Vortex (статика не мешает)
Многосервисное приложение с разными жизненными циклами VContainer (контроль над Lifetime)
Постепенная миграция существующего проекта VContainer (внедряется по одной системе)
Зелёный проект с полной перестройкой архитектуры Vortex (полный комплект из коробки)
Минимизация числа файлов и абстракций Vortex
Dependency graph явно виден в коде через конструкторы VContainer
Связи между системами через shared data contracts Vortex

Устойчивость и расширяемость

В обеих архитектурах при дисциплинированной работе результат одинаково хороший. Различие — в природе уязвимостей.

Vortex уязвим к обходу канона. Структурные опоры (слоистая иерархия, Driver-whitelist, шинные internal-эмиттеры, обязательность GUID, разделение Controller / Data / View) держат основную линию защиты. Но если кто-то намеренно лезет в чужую сборку, пишет mutator в публичном API модели или сводит две системы прямой ссылкой минуя шину — компилятор разрешит. Архитектура не запрещает плохие решения, она делает их визуально чужеродными в коде проекта и легко вылавливаемыми на code review.

VContainer уязвим к разрастанию. Каждая новая система требует регистрации, каждое усложнение — нового интерфейса. В большом проекте LifetimeScope может вырасти в тысячи строк, и порог входа для нового разработчика становится высоким. Архитектура запрещает плохие решения, но не запрещает себя саму усложнять без нужды.

Сломать обе системы примерно одинаково сложно при понимании канона:

  • В Vortex — нужно либо нарушить разделение Controller / Data / View, либо ввести зависимости между системами вне шины.
  • В VContainer — нужно либо обойти DI через ServiceLocator / Find-методы, либо ввести static state.

Главное различие — в природе ограничителя. Vortex меньше полагается на compile-time dependency declaration, но сильнее на структурную стандартизацию среды плюс code review. VContainer — на compile-time контракты конструкторов и анализаторы кода. Если в команде культура review слабая, VContainer даст более жёсткие гарантии за счёт компилятора. Если культура сильная — Vortex даст ту же надёжность за счёт канона, с меньшим количеством файлов.

Архитектура — это не про «правильный» и «неправильный» способ. Это про выбор того, чем платить: каноном или ceremoning, shared data contracts или constructor injection, компактностью или явным dependency graph. Ни одна сторона не побеждает другую — у каждой свой ответ на одну и ту же проблему, и оба ответа рабочие.