Сравнение архитектуры статик-шин 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. Ни одна сторона не побеждает другую — у каждой свой ответ на одну и ту же проблему, и оба ответа рабочие.