Onity vs VContainer / Zenject

A per-axis comparison of three Unity dependency-injection containers — Onity, VContainer, and Zenject / Extenject — written to read as an external evaluation rather than marketing copy. It is maintained in the Onity repository, and every benchmark number can be reproduced with the runner included in this repository.

Read this first — scope and caveats

  • Onity is a single Unity package that unifies dependency injection, reactive programming, and events. This document compares only the DI axis against VContainer and Zenject (which are DI-only); the unified-scope advantage is covered as its own axis below.
  • The benchmark timing numbers are indicative, not guaranteed. They were measured in the Unity Editor with the Mono scripting backend and in a Windows IL2CPP player, on a single Windows machine (Unity 2022.3.62f3). The full Editor and IL2CPP player runs both used 512 warmup iterations / 8 samples / 10,000 measured iterations per sample. A separate IL2CPP singleton release gate used 1000 samples at the same iteration count. Different Unity versions, scripting backends, and graph shapes can produce different absolute numbers and possibly different relative ordering. Treat the numbers as “this is what this Windows PC measured,” not “Onity is always faster.” They were produced by the Onity project’s own OnityDiBenchmarkRunner and have not been independently audited — the runner ships in this repository specifically so any reader can reproduce, or challenge, them.
  • Full DI benchmark detail: di-benchmark-summary.md.

Summary table

Axis Onity VContainer Zenject / Extenject
Resolve speed (Editor-Mono, indicative) Standard and baked lanes lead every measured path in this run Behind both Onity lanes Slowest of the three
Resolve speed (Windows IL2CPP player, indicative) Standard and baked lanes lead every measured path; singleton rechecked with 1000 samples Behind both Onity lanes in this run Slowest of the three
Build / registration speed (indicative) Fastest in both current Editor/Mono and IL2CPP player prepare/register runs Slower than Onity on prepare/register Slow
Steady-state resolve allocation Resolve machinery is designed allocation-free; transients allocate the returned instance Low (codegen mode) Higher
DI feature breadth Feature-complete for common Unity needs Broad Broadest
Entry-point lifecycle Automatic, no registration Manual RegisterEntryPoint Automatic
Collection / open-generic binds Yes Yes Yes
Conditional / id binds, Unbind No (deliberate non-goal) Limited Yes
IL2CPP / AOT Generated AOT activators plus runtime-probed reflection fallback; current Windows player benchmark beats VContainer on the measured scenarios Source-gen path Broad AOT support
Unified DI + Reactive + Events Yes (one package) DI only DI only
AI-friendliness / analyzer Usage guide + ONITY001ONITY006 None bundled Partial (ValidateAll)

Axis-by-axis

1. Resolve speed

On the Editor-Mono benchmark machine, Onity resolved faster than both VContainer and Zenject on every scenario:

Scenario Onity Standard Onity Baked VContainer Zenject
Resolve Singleton ~69 ns ~78 ns ~217 ns ~2,778 ns
Resolve Transient ~1,030 ns ~1,366 ns ~2,352 ns ~12,561 ns
Resolve Combined ~980 ns ~875 ns ~1,905 ns ~14,382 ns
Resolve Complex (6-level graph) ~20,874 ns ~20,828 ns ~40,270 ns ~281,814 ns

The Windows IL2CPP player run uses Onity’s generated AOT activator registry for the benchmark graph and keeps the same relative ordering:

Scenario Onity Standard Onity Baked VContainer Zenject Lower ns/op vs VContainer
Resolve Singleton ~18 ns ~18 ns ~95 ns ~449 ns ~82%
Resolve Transient ~159 ns ~191 ns ~541 ns ~2,448 ns ~71%
Resolve Combined ~176 ns ~196 ns ~612 ns ~3,080 ns ~71%
Resolve Complex (6-level graph) ~5,107 ns ~5,071 ns ~12,475 ns ~59,327 ns ~59%

The focused 1000-sample IL2CPP singleton gate measured Onity standard at 18.80 ns/op, Onity baked at 17.40 ns/op, VContainer at 94.39 ns/op, and Zenject at 435.24 ns/op. The raw reports retain the historical Onity (Reflection) label for the standard lane; generic resolves now use a dense type-id provider slot, while reflection remains only an activation fallback.

On Mono/JIT, the speed comes from a process-wide compiled-activator cache (Expression.Compile runs once per ConstructorInfo), compiled field/property/method setters, a [ThreadStatic] lock-free argument-array pool, a per-plan per-slot constructor-dependency cache, and dense type-id provider slots for standard generic resolves. On IL2CPP, generated activators register direct new T(...) delegates before construction plans are built, so the resolve path avoids ConstructorInfo.Invoke on AOT builds too. There is no builder.Build() ceremony before a resolve, and the container has no engine coupling (Onity.DI is noEngineReferences: true).

Backend note. Editor-Mono numbers should not be projected onto IL2CPP. The current IL2CPP player benchmark proves the AOT activator path runs and beats VContainer and Zenject on this benchmark graph, but it is still one Windows machine and one graph shape. Re-run the player benchmark for your target device before treating the ordering as a target-platform result.

2. Build / registration speed

Building (preparing and registering) a complex graph was substantially faster in Onity on the benchmark machine:

Scenario Onity (Baked) Onity (Reflection) VContainer Zenject
Prepare & Register Complex (Editor/Mono) ~54,996 ns ~40,613 ns ~139,246 ns ~188,865 ns
Prepare & Register Complex (IL2CPP Player) ~24,490 ns ~21,128 ns ~34,888 ns ~59,567 ns

Onity avoids VContainer’s separate builder object and shares activation metadata across the whole process. The baked mode adds a lean dense-id map during Build() so explicit bindings can resolve without a dictionary lookup; it reuses the same providers as the reflection path instead of compiling a second dependency graph. Caveat: the very first build that compiles a type pays the Expression.Compile cost; a process that builds many distinct graphs once each will see less of this advantage, and on IL2CPP the generated AOT activator must exist for each hot implementation type to avoid the reflection fallback.

3. Steady-state allocation

Onity’s resolve machinery is designed to avoid per-call managed allocation beyond the constructed instances themselves: generated/compiled activators, a [ThreadStatic] pooled argument array, and cached construction plans mean a singleton resolve from a warm container should not allocate, while a transient resolve still allocates the instance it returns and a 6-level graph allocates roughly one object per level. One-time operations such as the first compile of an activator also allocate.

Allocation behavior depends on lifetime and graph shape: singleton resolves reuse the cached instance, while transient resolves allocate the object graph they return. Onity keeps the container machinery off the allocator so the returned instances are the meaningful cost to optimize.

4. DI feature breadth

Onity’s DI now covers the feature axes most Unity projects need:

  • Fluent binding (Bind<T>().To<C>().AsSingle()/.AsTransient()/.NonLazy()), self-bind shorthand, BindInstance, BindInterfacesAndSelfTo / BindInterfacesTo, and BindFactory<...> (0/1/2-parameter IFactory<...>).
  • [Inject] on constructor, field, property, or method.
  • Collection injectionIEnumerable<T>, IReadOnlyList<T>, IReadOnlyCollection<T>, IList<T>, ICollection<T>, List<T>, T[].
  • Open-generic registrationBind(typeof(IRepo<>)).To(typeof(Repo<>)), closing the type on first resolve of IRepo<Foo>.
  • Child containers as the scoped lifetime; sync Build() and async BuildAsync(ct) startup.

Where the competitors are still ahead on raw feature count: Zenject in particular offers conditional / contextual binds (WhenInjectedInto, WithId, FromSubContainerResolve), Unbind/Rebind, and a deep memory-pool / factory system. Onity deliberately omits conditional/id binds and Unbind — they fight the predictable single-model and allocation-conscious hot-path goals — and answers “two implementations of one interface” with collection injection or a typed factory instead. VContainer offers a first-class Lifetime.Scoped keyword; Onity models scope as a child container. If your project depends on Zenject’s conditional binding or sub-container resolve features, Onity does not have a drop-in equivalent.

5. Entry-point lifecycle

This is an axis where Onity is ahead of VContainer and on par with Zenject. Implement IOnityInitializable, IOnityTickable, IOnityFixedTickable, or IOnityLateTickable on a bound singleton and the container wires it up automatically — Initialize() runs at the end of Build(), and the Unity context pumps Tick / FixedTick / LateTick from Update / FixedUpdate / LateUpdate. No manual entry-point registration is required.

VContainer requires you to register entry points explicitly (RegisterEntryPoint<T>()); Zenject auto-collects IInitializable / ITickable much like Onity does. So Onity matches Zenject’s ergonomics here and improves on VContainer’s manual wiring.

6. IL2CPP / AOT

Onity’s speed lead on Mono comes from Expression.Compile. On ahead-of-time runtimes (IL2CPP, console AOT), runtime expression compilation can be unavailable, interpreter-backed, or target-dependent, so Onity does not assume that a compiled delegate is safe. It now has a generated AOT activator registry for hot DI types and keeps the runtime probe (RuntimeCompileSupport) as the fallback gate:

  • If a generated activator is registered for the selected constructor, Onity uses that direct new T(...) delegate first on every runtime.
  • On JIT runtimes without a generated activator, the probe succeeds and the compiled fast path is used.
  • On AOT/IL2CPP or restricted runtimes, if the probe detects a failed compiled delegate, the container falls back to reflection-based activation — slower per call, but allocation-comparable and guaranteed to run instead of crashing. Each compiler also wraps Compile() in try/catch for per-member safety.
  • OnityContainer.ForceReflectionActivation lets you force the reflection path on a JIT runtime to pre-flight a graph under the exact strategy IL2CPP uses.

The fallback is covered by AOT fallback tests. The Windows IL2CPP player benchmark now also proves that the benchmark graph runs in a player build, registers 19 generated activators, records timings, and beats the local VContainer/Zenject baselines in every measured scenario. Also note that runtime open-generic registration relies on MakeGenericType, so the closed type must survive IL2CPP stripping (reference it statically or preserve it).

7. Unified scope — DI + Reactive + Events

VContainer and Zenject are DI containers only. A typical project pairs them with a separate reactive library (R3 / UniRx) and a separate message bus (MessagePipe), giving four installs, four mental models, and four disposal idioms.

Onity is one package spanning all three:

  • IMessageBroker and OnityEventHub are auto-bound in every scope — no AddMessagePipe()-style setup line.
  • broker.Observe<T>() returns the same IOnityObservable<T> as Subject<T> and ReactiveProperty<T>, so any event flows directly into the reactive operator chain with no hand-written adapter.
  • Everything disposes the same way: Subscribe returns IDisposable, scoped with AddTo(this) (Unity) or AddTo(CompositeDisposable) (plain C#).

If you only need a DI container and already have a reactive/event stack you like, this axis is irrelevant to you. If you want one coherent stack, it is the main structural difference between Onity and a VContainer/Zenject + R3 + MessagePipe combination — and the clearest reason a project would pick Onity over assembling the three libraries separately.

8. AI-friendliness and compile-time analyzer

Onity ships a verified, machine-readable AI usage guide (every snippet compiles against the current public API) and a Roslyn analyzer pack (ONITY001ONITY006) with code fixes. The rules catch:

  • ONITY001Resolve inside Update / FixedUpdate / LateUpdate.
  • ONITY002 — binding/resolving after Build().
  • ONITY003 — a Subscribe result dropped without AddTo(...).
  • ONITY004 — multiple [Inject] constructors.
  • ONITY005 — an [Inject] member that cannot be injected (get-only property, indexer, generic method, static member).
  • ONITY006 — manual new on a type the same file binds/resolves through Onity.

Neither VContainer nor Zenject bundles a usage-guide-plus-analyzer pair like this. Zenject offers a ValidateAll runtime/edit validation pass, which catches missing bindings but is not a compile-time analyzer with inline fixes. Onity is the only one of the three with this pairing; it matters most for AI-assisted or large-team development, and is largely irrelevant to a solo developer who uses neither AI assistance nor frequent onboarding.

When to choose which

  • Choose Onity if you want one coherent package for DI + Reactive + Events, value a resolve path designed to avoid per-call managed allocation on Mono, want automatic entry-point lifecycle without manual registration, want a compile-time analyzer and an AI-readable usage guide.
  • Choose VContainer if you want a DI-only container and are fine pairing it with a separate reactive/event stack.
  • Choose Zenject / Extenject if you depend on its conditional/contextual binding, sub-container resolve, memory-pool, or Unbind/Rebind features, and resolve performance is not your binding constraint.

The numbers and feature claims here reflect the current state of Onity and are intended to be revised as target-device IL2CPP coverage and benchmark coverage expand.