Introduction
As a Unity project grows, dependencies become harder to see and replace. Typical signs are more GameManager.Instance calls, repeated scene lookups, and input, networking, save, analytics, or billing code that cannot be swapped without changing its callers.
Dependency injection, or DI, addresses these problems by moving construction and lifetime management to explicit composition boundaries.
Zenject and its fork Extenject were the best-known Unity DI containers for years. As of July 26, 2026, the latest Zenject release is 9.2.0 from May 2020, while Extenject has been quiet since its 9.3.1 prerelease in July 2022.
Reflex 14.3.1 was released in June 2026 and VContainer 1.19.0 in July 2026. This article uses Unity 6-era APIs and describes the libraries as they existed on July 26, 2026.
Keep Zenject or Extenject in an existing project if it is stable.
For a new project, evaluate Reflex first.
Compare VContainer when you need its broader lifecycle features; use manual DI when the project is small.
Zenject is not suddenly unusable. The concern is that a DI container affects scene startup, prefab creation, lifetimes, editor tooling, generated code, and IL2CPP builds. For a new multi-year project, maintenance matters as much as convenience.
DI is not the same as using interfaces
DI means that an object receives dependencies instead of constructing them internally.
public sealed class PlayerMoveService
{
private readonly IPlayerInput _input;
public PlayerMoveService(IPlayerInput input)
{
_input = input;
}
}
The composition root can create IPlayerInput and pass it to PlayerMoveService. This is already DI; a container is optional.
Manual construction is often clearer for a small graph. A container becomes useful when the project needs consistent registration, scopes, disposal, and scene-aware object creation. DI should expose dependencies, not hide them behind a global resolver.
Why Unity makes DI more complicated
Plain C# objects can use constructor injection. Unity adds MonoBehaviour, scenes, prefabs, Inspector references, and callback order.
A practical split is:
- Plain C# classes: constructor injection
-
Scene
MonoBehaviourcomponents: method or field injection -
References local to one prefab:
SerializeField - Runtime-created prefabs: a project-owned factory plus explicit injection
Do not move every reference into DI. Buttons, labels, and child transforms are usually clearer in the Inspector. DI is most useful for networking, save data, input, time, randomness, purchases, logging, and analytics.
Awake and dynamic prefabs
For ordinary scene objects, Reflex intends to inject before Awake and OnEnable when a ContainerScope exists. Do not assume the same behavior for altered script execution order, additive or asynchronous loading, or manually instantiated prefabs.
An active prefab runs Awake and OnEnable during Instantiate; later injection cannot change that. A safe project-wide rule is to keep Awake self-contained and begin DI-dependent work from Initialize or StartAsync.
Model lifetimes explicitly
- Singleton: one shared instance
- Scoped: one instance per container
- Transient: one instance per resolution
API clients may belong in the root scope. Battle score, waves, and enemy collections usually belong to a scene scope. DI is valuable because it makes lifetime and disposal boundaries explicit, not because it creates more singletons.
Why Zenject became popular
Zenject addressed Unity-specific problems through contexts, installers, initialization and tick interfaces, factories, pools, SignalBus, validation, subcontainers, and prefab injection. Its feature set and documentation made it a natural default.
The cost appears when application code directly depends on DiContainer, PlaceholderFactory<T>, SignalBus, ITickable, or IInitializable. Replacing the container then requires redesigning factories, events, update loops, and startup order.
Keep container-specific conveniences near the composition boundary.
Is Zenject still usable?
Yes, especially in an existing project.
Existing projects
Do not rewrite a stable foundation only because upstream development is quiet. Migration can affect scene startup, prefab ownership, factories, pools, signals, tests, Addressables, and IL2CPP stripping.
Keeping Zenject is reasonable when current builds are continuously tested, the team can patch or fork it, and the project already relies on Zenject-specific features. “Unmaintained” should trigger risk assessment, not an automatic rewrite.
New projects
The justification is weaker. Unity has continued to change editor APIs, IL2CPP, code generation, and Unity 6 internals since the last main Zenject release. Reflex and VContainer have shipped recent compatibility updates.
A DI package touches scenes, generated code, reflection, editor tools, and build pipelines. Choosing Zenject for a new multi-year project means accepting that your team may become its effective maintainer.
Is Zenject too slow?
The Reflex README publishes a benchmark that resolves a transient object with a four-level dependency chain 10,000 times.
| Environment | Reflex | Zenject | VContainer |
|---|---|---|---|
| Android / Mono | 4.9 ms / 54.7 KB | 34.4 ms / 503.9 KB | 20.3 ms / 70.3 KB |
| Android / IL2CPP | 4.0 ms / 140.6 KB | 15.8 ms / 1,000 KB | 4.2 ms / 140.6 KB |
| Windows / Mono | 0.7 ms / 140.6 KB | 5.6 ms / 1,000 KB | 1.9 ms / 140.6 KB |
| Windows / IL2CPP | 1.4 ms / 140.6 KB | 6.2 ms / 1,000 KB | 3.0 ms / 140.6 KB |
Each cell is time / GC allocation. See the current Reflex performance table.
This is a Reflex-published benchmark, not an independent test. It measures resolution throughput, not scene scanning, initial container construction, player size, or a complete startup path.
Within this workload, Reflex is faster and allocates less than Zenject. That may matter when resolving many transient objects at startup, but it does not make Zenject unusable. DI should normally resolve at composition boundaries, while gameplay uses already-injected references.
If a project resolves thousands of dependencies every frame, redesigning that path matters more than replacing the container.
Why Reflex is a strong default for new projects
Reflex provides a relatively small Unity-focused DI surface instead of reproducing every Zenject feature. As of July 2026 it includes root and scene containers, three lifetimes, lazy and eager resolution, multiple injection styles, factories, manual scopes, runtime GameObject injection, a debugger, and a Roslyn Source Generator.
It does not depend on runtime code emission and targets IL2CPP and WebGL. Version 14.3.1 also continued Unity 6 compatibility work.
Recent releases do not guarantee future health. Inspect issue response, contributor concentration, community knowledge, and whether your team can diagnose failures without upstream help.
Keep application-facing factories and events behind project-owned abstractions:
public interface IEnemyFactory
{
EnemyView Create(EnemySpawnParameter parameter);
}
Only the composition root should know about Reflex's Container or RegisterFactory. The DI package should assemble the architecture, not become it.
Installing Reflex
This article uses the Reflex 14.x API. Older examples may use AddSingleton, AddScoped, or AddTransient, so check the package version.
In Unity Package Manager, select Add package from git URL:
https://github.com/gustavopsantos/reflex.git?path=/Assets/Reflex/#14.3.1
Pin a release tag and check Reflex Releases before adoption.
Create ReflexSettings directly under Assets/Resources with Create > Reflex > Settings. For application-wide registrations, create a RootScope prefab and add it to those settings. For each DI-enabled scene, create GameObject > Reflex > SceneScope, which adds a ContainerScope component.
Registering plain C# classes
using UnityEngine;
public interface IPlayerInput
{
Vector2 ReadMove();
}
public sealed class UnityPlayerInput : IPlayerInput
{
public Vector2 ReadMove()
{
return new Vector2(
Input.GetAxisRaw("Horizontal"),
Input.GetAxisRaw("Vertical"));
}
}
public sealed class PlayerMoveService
{
private readonly IPlayerInput _input;
public PlayerMoveService(IPlayerInput input)
{
_input = input;
}
public Vector3 CalculateVelocity(float speed)
{
Vector2 move = _input.ReadMove().normalized;
return new Vector3(move.x, 0f, move.y) * speed;
}
}
The service does not know the Unity input API and can use a test implementation of IPlayerInput. The sample uses the legacy Input Manager only to stay small.
Register it with the Reflex 14.x bindings API:
using Reflex.Core;
using Reflex.Enums;
using UnityEngine;
public sealed class GameplayInstaller : MonoBehaviour, IInstaller
{
public void InstallBindings(ContainerBuilder builder)
{
builder.RegisterType(
typeof(UnityPlayerInput),
new[] { typeof(IPlayerInput) },
Lifetime.Singleton,
Resolution.Lazy);
builder.RegisterType(
typeof(PlayerMoveService),
new[] { typeof(PlayerMoveService) },
Lifetime.Scoped,
Resolution.Lazy);
}
}
-
Lifetime.Singleton: one instance shared by the registering container and its children -
Lifetime.Scoped: one instance per container -
Lifetime.Transient: a new instance per resolution -
Resolution.Lazy: create on first use -
Resolution.Eager: create while building the container
Injecting a MonoBehaviour
using Reflex.Attributes;
using UnityEngine;
public sealed class PlayerView : MonoBehaviour
{
[SerializeField] private CharacterController _controller;
[SerializeField] private float _speed = 5f;
private PlayerMoveService _moveService;
[Inject]
private void Construct(PlayerMoveService moveService)
{
_moveService = moveService;
}
private void Update()
{
Vector3 velocity = _moveService.CalculateVelocity(_speed);
_controller.Move(velocity * Time.deltaTime);
}
}
The controller and speed are prefab-local configuration, so they stay in the Inspector. The input-dependent service is injected.
If Construct is not called, check that the scene contains a SceneScope or ContainerScope, the scene was loaded through the expected path, and PlayerMoveService is registered in that scene or a parent scope.
For production code, validate injection during explicit initialization before the view joins the update loop.
Registering a ScriptableObject or existing instance
Use RegisterValue for an existing settings asset:
[CreateAssetMenu(menuName = "Game/Player Move Settings")]
public sealed class PlayerMoveSettings : ScriptableObject
{
public float Speed = 5f;
}
public sealed class GameplayInstaller : MonoBehaviour, IInstaller
{
[SerializeField] private PlayerMoveSettings _settings;
public void InstallBindings(ContainerBuilder builder)
{
builder.RegisterValue(
_settings,
new[] { typeof(PlayerMoveSettings) });
}
}
RegisterValue creates a singleton registration. If the value implements IDisposable, Reflex disposes it with the container, so avoid duplicate ownership by another SDK or scope.
Dispose is not UnityEngine.Object.Destroy; Unity objects still follow scene, prefab, and explicit Unity ownership rules.
Avoid many raw string or numeric registrations. Reflex has no direct WithId equivalent, so use small types such as ApiBaseUrl and AssetBaseUrl to prevent accidental wiring.
Do not turn Resolve into a Service Locator
Passing Container through application code and calling Resolve<T> hides the class's real dependencies and forces tests to construct a container.
Inject the services directly:
public sealed class BattleService
{
private readonly ISaveDataRepository _saveData;
private readonly IAnalytics _analytics;
public BattleService(
ISaveDataRepository saveData,
IAnalytics analytics)
{
_saveData = saveData;
_analytics = analytics;
}
}
Limit container access to composition roots, installers, DI-aware factories, and framework boundaries. Microsoft's DI guidelines recommend the same approach.
When explicit resolution is necessary, Single<T> detects duplicate registrations. Resolve<T> returns the last valid registration. Use IEnumerable<T> or distinct contracts when multiple implementations are intentional.
Source Generator and IL2CPP
Reflex supports IL2CPP without runtime code emission and provides a Roslyn Source Generator.
For generated field, property, or method injection, add SourceGeneratorInjectable and make the type public partial:
[SourceGeneratorInjectable]
public partial class PlayerView : MonoBehaviour
{
[Inject]
private void Construct(PlayerMoveService moveService)
{
_moveService = moveService;
}
}
For nested types, every containing type must also be public partial.
Source generation does not remove every AOT limitation. The Reflex README notes a possible ExecutionEngineException for constructor-injected IEnumerable<T> when required IL2CPP code is missing.
Test early with desktop and Android IL2CPP, production stripping settings, iOS Xcode generation when relevant, and Addressables-created scenes and prefabs.
Creating dynamic prefabs safely
Scene objects can be injected by SceneScope. Runtime prefabs should go through one project-owned factory. Reflex provides GameObjectSelfInjector and the GameObjectInjector APIs.
The exact implementation changes with Addressables and pooling, so this is conceptual code:
public sealed class EnemyViewFactory : IEnemyViewFactory
{
private readonly EnemyView _prefab;
private readonly Transform _parent;
public EnemyViewFactory(EnemyView prefab, Transform parent)
{
_prefab = prefab;
_parent = parent;
}
public EnemyView Create(Vector3 position)
{
EnemyView view = Object.Instantiate(
_prefab, position, Quaternion.identity, _parent);
Container container =
_parent.gameObject.scene.GetSceneContainer();
GameObjectInjector.InjectRecursive(
view.gameObject, container);
view.Initialize();
return view;
}
}
Keep container-specific types inside the factory and choose the container that represents the object's actual scene or child scope.
Recommended rules:
- do not use DI dependencies in
AwakeorOnEnable, - keep
create -> inject -> initialize -> join gameplay, - inject pooled instances once and reset them on reuse,
- reconsider pool sharing when the parent scope changes,
- keep Addressables handles and release responsibility in the factory or pool.
SetActive(false) after creation cannot undo callbacks that already ran, and GameObjectSelfInjector cannot guarantee every component's relative Awake order.
VContainer is the main alternative
VContainer 1.19.0 remains actively maintained. It provides LifetimeScope, source generation, PlayerLoop integration, pure C# entry points, UniTask and ECS integration, diagnostics, async-scene child scopes, and keyed registration.
These features matter when DI should coordinate async startup, Entities, or a pure C# update loop. They are not requirements for every GameObject project.
| Decision point | Reflex | VContainer |
|---|---|---|
| Direction | Small Unity-focused DI surface | DI plus lifecycle and entry-point integration |
| Scene injection | SceneScope injects the scene | Explicit component registration is central |
| Pure C# entry points | Project-owned when needed | Interfaces such as IStartable
|
| UniTask integration | Separate from DI core | Official integration |
| Keyed registration | Use distinct types | Supported |
In the Reflex-published Android IL2CPP benchmark, Reflex records 4.0 ms and VContainer 4.2 ms. The difference is too small to choose by ranking alone.
Compare registration style, PlayerLoop requirements, lifetime design, team experience, and debugging needs in a small prototype.
Manual DI is also valid
For a small game or isolated feature, direct construction may be safer than adding a container:
private void Awake()
{
var input = new UnityPlayerInput();
var moveService = new PlayerMoveService(input);
_playerView.Initialize(moveService);
}
This is only a minimal composition example. A real project can place async startup in a separate bootstrap flow.
Manual DI is easy to inspect, has no container-specific AOT behavior, and is unaffected by third-party maintenance. Add a container when the dependency graph, scopes, or disposal requirements justify it.
Mapping Zenject concepts to Reflex
| Zenject / Extenject | Reflex or project-owned replacement |
|---|---|
ProjectContext |
RootScope |
SceneContext |
Scene ContainerScope
|
MonoInstaller |
MonoBehaviour, IInstaller |
BindInstance |
RegisterValue |
AsSingle / AsTransient
|
Singleton / Transient lifetime |
AsCached |
Re-evaluate as scoped or singleton |
NonLazy |
Resolution.Eager |
PlaceholderFactory |
Project factory plus RegisterFactory
|
SignalBus |
C# events, R3, or a project EventBus |
MemoryPool |
UnityEngine.Pool or a project pool |
IInitializable / ITickable
|
Explicit bootstrap and project update loop |
| SubContainer | Child scopes or scene hierarchy |
Do not mechanically rename APIs. Lifetimes, subscription disposal, and update order must be reconsidered.
A safer migration order is:
- Stop adding new Zenject-specific types.
- Wrap factories and events in project-owned interfaces.
- Remove Zenject references from plain C# domain code.
- Separate composition roots by scene or feature.
- Add startup and disposal tests.
- Replace installers and contexts last.
Avoid long-term coexistence and never let both containers create the same singleton.
Common DI failures in Unity
Creating an interface for everything
Not every value object or calculation class needs an IFoo. Prioritize external I/O, time, randomness, input, networking, storage, purchases, and analytics.
Using the container as a global variable
Calling Container.Resolve<T>() everywhere hides dependencies and makes tests require a configured container.
Resolving every frame
private void Update()
{
var input = _container.Resolve<IPlayerInput>();
}
Inject once and keep the reference. Fixing this design usually matters more than choosing a faster container.
Storing scene state in root singletons
Root scope suits APIs, authentication, master data, logging, and analytics. Battle state and screen-specific presenters normally belong to a scene scope.
Expecting DI to define async startup order
DI resolves dependencies; it does not define the correct order for loading master data, save files, and scenes. Use an explicit bootstrap flow.
Conclusion
DI is not about eliminating every singleton or creating an interface for every class. It exposes dependencies, centralizes composition, and makes root, scene, and prefab lifetimes explicit.
Zenject can remain a reasonable foundation for a stable existing project. Its latest main release dates from 2020, however, and Extenject has also been quiet since 2022. For a new project that must follow future Unity versions, it is difficult to justify Zenject as the default.
Reflex remains actively maintained and provides Unity-focused scopes, source generation, debugging, and runtime prefab injection. VContainer offers a broader lifecycle and entry-point model when a project needs it.
Keep an existing Zenject project when migration cost is not justified.
Evaluate Reflex first for a new Unity project.
Compare VContainer when PlayerLoop integration, pure C# entry points, UniTask, ECS, or broader lifecycle management are real requirements.
Start with manual DI when the project is small.
A DI container automates construction and lifetime management; it does not design the software. Keep it near composition boundaries and preserve the option to replace it later.
Top comments (0)