Dependency Injection

Use OnityContainer (Onity.DI) to bind contracts to implementations, choose how long each instance lives, and let the container construct your classes with their dependencies. The container is sealed, engine-free and parent-scoped: a Unity context owns one per scene, prefab or project, and a test creates one in a using declaration. The binding vocabulary follows Zenject, so Bind<T>().To<C>().AsSingle() reads as expected, and the lifetime model is the same one the reactive, messaging and async pillars use.

Contents:

Create a container

The smallest working container binds one contract, builds, and resolves. This runs in an EditMode test with no scene:

using Onity.DI;                 // OnityContainer

using OnityContainer container = new OnityContainer();
container.Bind<IScoreService>().To<ScoreService>().AsSingle();
container.Build();

IScoreService score = container.Resolve<IScoreService>();

new OnityContainer(parent) creates a child scope that inherits the parent’s bindings. Dispose() cancels the scope token, then disposes everything the scope owns; see Lifecycle and Scopes.

In a scene you do not create the container yourself. A context creates it and runs your installers; the canonical installer below is the one the Getting Started scene uses, and the fragments in the rest of this page use container. as their receiver:

using Onity.Composition;        // BindReactiveProperty
using Onity.DI;                 // OnityContainer
using Onity.Unity.Installers;   // MonoInstaller, BindPooledFactory
using Onity.Unity.Messaging;    // BindMessageChannel
using UnityEngine;

public sealed class GameInstaller : MonoInstaller
{
    [SerializeField] private HitMarker m_hitMarkerPrefab;
    [SerializeField] private Transform m_hitMarkerRoot;

    public override void InstallBindings(OnityContainer container)
    {
        // ReactiveProperty<int> and IReadOnlyReactiveProperty<int>; the scope disposes it.
        container.BindReactiveProperty(initialValue: 100);

        // IPublisher<PlayerDamaged> and ISubscriber<PlayerDamaged> on the scope's message broker,
        // the same channel OnityEvent and OnityEventHub use.
        container.BindMessageChannel<PlayerDamaged>();

        // IFactory<HitMarker> to spawn and IPool<HitMarker> to release; the scope disposes the pool.
        container.BindPooledFactory(m_hitMarkerPrefab, m_hitMarkerRoot, defaultCapacity: 8, maxSize: 32);

        // The container constructs it, runs Initialize() at Build() and disposes it with the scope.
        container.BindInterfacesAndSelfTo<HealthService>().AsSingle();
    }
}

Binding

A binding names a contract, an implementation and a lifetime. The lifetime call registers the binding; Bind<T>() and To<C>() alone register nothing. Fragment:

container.Bind<IScoreService>().To<ScoreService>().AsSingle();        // one instance in this scope
container.Bind<IPathfinder>().To<AStarPathfinder>().AsTransient();   // a new instance per resolve
container.Bind<IMatchState>().To<MatchState>().AsScoped();           // one instance per resolving scope
container.Bind<IClock>().To<GameClock>().AsSingle().NonLazy();       // created during Build() instead of on first use

container.Bind<ScoreService>().AsSingle();                           // self-bind: To defaults to the contract type
  • AsSingle() creates the instance on first resolve (or at Build() with NonLazy()) and reuses it for every resolve in the declaring scope and its children.
  • AsScoped() creates one instance per resolving scope. A child that resolves a scoped binding declared on its parent gets its own instance, built with the child’s dependencies and disposed with the child.
  • AsTransient() creates a new instance per resolve. Transient instances are not collected by the lifecycle and are not disposed by the container.
  • NonLazy() must follow the lifetime call; calling it first throws OnityBindingException. It is not available on a conditional binding or on an open generic binding.

Every binding entry point and what it returns:

Call Namespace Returns Then
Bind<TContract>() Onity.DI TypeBindingBuilder<TContract> optional To<TConcrete>(), WithId(id), WhenInjectedInto<TConsumer>(); then AsSingle(), AsScoped(), AsTransient() or FromSubContainerResolve(install); optional NonLazy()
Bind(Type contractType) Onity.DI RuntimeTypeBindingBuilder same, with To(Type); accepts open generic definitions
BindInterfacesAndSelfTo<TConcrete>() Onity.DI MultiTypeBindingBuilder optional WithId, WhenInjectedInto; then AsSingle(), AsScoped() or AsTransient(); optional NonLazy()
BindInterfacesTo<TConcrete>() Onity.DI MultiTypeBindingBuilder same; the concrete type itself is not bound
BindInstance<TContract>(instance) Onity.DI void registers at once; the instance stays caller-owned
BindInstance<TContract>(instance, id) Onity.DI void the identified form
BindFactory<TValue, TFactory>() and the one- and two-parameter forms Onity.DI void binds the factory with BindInterfacesAndSelfTo<TFactory>().AsSingle()
Rebind<TContract>(id = null), Rebind(Type, id = null) Onity.DI a builder replaces the local binding when the new lifetime is selected
Unbind<TContract>(id = null), Unbind(Type, id = null) Onity.DI bool removes the local binding

Identified and conditional bindings

WithId(object id) registers a second binding of the same contract under an identifier; ids compare by value. WhenInjectedInto<TConsumer>() restricts a binding to one consumer type and its derived types. Both are configured before the lifetime call. Fragment:

container.Bind<IClock>().To<GameClock>().AsSingle();
container.Bind<IClock>().To<ReplayClock>().WithId("replay").AsSingle();
container.Bind<IClock>().To<EditorClock>().WhenInjectedInto<PreviewController>().AsSingle();

IClock replay = container.Resolve<IClock>("replay");
container.Rebind<IClock>("replay").To<RecordedClock>().AsScoped();   // replaces only the "replay" binding
container.Unbind<IClock>("replay");                                 // removes only this scope's "replay" binding

Consume an identified binding with [Inject(Id = "replay")] on a field, a property, or an individual constructor or method parameter, or with Resolve<T>(id) and TryResolve<T>(id, out T). A missing identifier does not fall back to an implicit construction.

Rules the container applies:

  • A conditional binding that matches the consumer wins over the unconditional binding of the same contract and id in the same scope. Two conditional bindings that both match throw OnityResolveException.
  • An unconditional binding declared in a child scope shadows conditional bindings declared in an ancestor.
  • Unbind and Rebind change the local scope only; ancestor registrations stay in effect. Both work after Build(). A singleton removed by Unbind stops ticking but stays owned, and is disposed with the scope. A rebound tickable stops the old instance and starts the replacement.

Sub-container exports

FromSubContainerResolve exports one contract from a child container that the installer builds on demand, once per requesting scope. It replaces To<C>(); the two cannot be combined. Fragment:

container.Bind<IEnemyAI>().FromSubContainerResolve(scope =>
{
    scope.Bind<IEnemyAI>().To<BossAI>().AsScoped();
    scope.Bind<IBossRules>().To<BossRules>().AsSingle();
});

The exported contract must be bound inside the child; an installer that does not bind it throws OnityBindingException at the first resolve. The child binding chooses the service lifetime. The requesting scope owns the child, forwards Tick, FixedTick and LateTick to it, and disposes it with itself.

Sharing one instance across contracts

Two separate Bind<IFoo>().To<C>() and Bind<IBar>().To<C>() calls create two C instances, one per binding. To share one instance across a class and its interfaces, bind them together. Fragment:

container.BindInterfacesAndSelfTo<HealthService>().AsSingle();   // HealthService, IOnityInitializable, ...
container.BindInterfacesTo<ScoreService>().AsSingle();           // IScoreService and the other interfaces; not ScoreService itself

BindInterfacesTo<T>() throws OnityBindingException when T implements no interfaces. Both forms accept WithId and WhenInjectedInto before the lifetime, and NonLazy() after it.

Pre-built instances

BindInstance<TContract>(instance) registers an object you already have. The container never disposes it; the caller that created it owns it. A null instance throws OnityBindingException. Fragment:

container.BindInstance<IGameConfig>(m_config);
container.BindInstance<IOnityScopeLifetime>(container);   // what a context binds for you; needed in a bare container

To tie a caller-owned disposable to the scope anyway, call disposable.AddTo(container) (Onity.DI); the scope disposes it when the token is canceled.

Shared reactive and messaging primitives

These helpers create one primitive, bind it under every contract it satisfies, and hand ownership to the scope: the container disposes the primitive when it is disposed, after it cancels the scope token. Each returns the primitive so an installer or test can seed or use it inline.

Call Namespace Resolves as Returns
container.BindReactiveProperty(initialValue) Onity.Composition ReactiveProperty<T>, IReadOnlyReactiveProperty<T> ReactiveProperty<T>
container.BindSubject<T>() Onity.Composition Subject<T> Subject<T>
container.DeclareMessage<T>() Onity.Composition MessageChannel<T>, IPublisher<T>, ISubscriber<T> MessageChannel<T>
container.DeclareAsyncMessage<T>() Onity.Composition AsyncMessageChannel<T>, IAsyncPublisher<T>, IAsyncSubscriber<T> AsyncMessageChannel<T>
container.BindAsyncReactiveProperty(initialValue) Onity.Unity.Async OnityAsyncReactiveProperty<T>, IOnityAsyncReactiveProperty<T>, IOnityReadOnlyAsyncReactiveProperty<T> OnityAsyncReactiveProperty<T>
container.BindMessageChannel<T>() Onity.Unity.Messaging IPublisher<T>, ISubscriber<T> void

DeclareMessage<T> and DeclareAsyncMessage<T> create standalone channels; BindMessageChannel<T> binds the publisher and subscriber of the scope’s IMessageBroker, so only the latter is reached by OnityEvent and OnityEventHub. In a scene bind the broker’s channel; in an engine-free test, where no broker exists unless you bind MessageBroker yourself, DeclareMessage<T> is the stand-in. See Events and Messaging.

Open generics

Bind an open generic definition once and resolve any closed form. The first resolve of a closed contract builds and caches the closed implementation as a normal binding, so later resolves take the fast path. Fragment:

container.Bind(typeof(IRepository<>)).To(typeof(InMemoryRepository<>)).AsTransient();

IRepository<PlayerProfile> profiles = container.Resolve<IRepository<PlayerProfile>>();

AsSingle() on an open generic gives each distinct closed contract its own singleton. NonLazy() is not supported here, because the closed type is unknown until resolve. On IL2CPP the closed type must survive stripping: reference it statically or preserve it.

Collection injection

Bind the same contract more than once and request a collection to receive every explicit binding of it, from this scope and its ancestors, ancestors first. The container synthesizes seven shapes: IEnumerable<T>, IReadOnlyList<T>, IReadOnlyCollection<T>, IList<T>, ICollection<T>, List<T> and T[]. Fragment:

container.Bind<IDamageRule>().To<CritRule>().AsSingle();
container.Bind<IDamageRule>().To<ArmorRule>().AsSingle();

IReadOnlyList<IDamageRule> rules = container.Resolve<IReadOnlyList<IDamageRule>>();

A constructor or [Inject] member typed as one of these shapes receives the same collection. A plain Resolve<IDamageRule>() still returns the last binding, so collection resolution is opt-in by the type you request. An explicit binding of the collection type itself wins over synthesis, and implicitly auto-resolved concrete classes are not members.

Resolving

Inject dependencies where you can; resolve by hand only in factories and glue code. Fragment:

IScoreService score = container.Resolve<IScoreService>();                 // throws OnityResolveException when unresolvable
object service = container.Resolve(typeof(IScoreService));               // runtime-type form
IClock replay = container.Resolve<IClock>("replay");                      // identified form

if (container.TryResolve<IPathfinder>(out IPathfinder pathfinder)) { }    // false instead of throwing
if (container.TryResolve(typeof(IPathfinder), out object found)) { }
if (container.TryResolve<IClock>("replay", out IClock clock)) { }

bool resolvable = container.CanResolve(typeof(IScoreService));           // no construction
container.Inject(existingObject);                                         // member-inject an object you created

OnityContainer and IResolver always resolve to the active scope, so a factory injects IResolver to resolve collaborators. Do not resolve inside Update; inject once and keep the reference.

Injection sites

The container injects through four sites. Prefer the constructor; use [Inject] (Onity.DI) on fields, properties and methods where the engine constructs the object or a constructor cannot do the work:

using Onity.DI;                 // Inject

public sealed class CombatService
{
    private readonly IDamageCalculator m_damage;

    // Constructor injection. A single [Inject] constructor wins; otherwise the public constructor
    // with the most parameters is chosen. A non-public constructor is used only when no public one exists.
    public CombatService(IDamageCalculator damage)
    {
        m_damage = damage;
    }

    [Inject] private IClock m_clock;                       // field injection; private is fine
    [Inject] public IScoreService Score { get; set; }      // property injection; a setter is required

    [Inject]                                                // method injection; runs after fields and properties
    private void Configure(IGameConfig config)              // cannot be generic
    {
    }
}

Member injection runs base class first, then derived class, and within a type fields, then properties, then methods. Static members are never injected. These throw OnityBindingException: more than one [Inject] constructor, an [Inject] property without a setter, an [Inject] indexer, and a generic [Inject] method. A MonoBehaviour is injected by its context during the context’s Awake, or by context.InjectGameObject(root) and container.Inject(target) for objects created later.

Factories

To pass a runtime value into a constructed object, author an IFactory<...> (Onity.Factory) and register it with BindFactory; the container constructs the factory and injects its collaborators. There is no Instantiate(args) and no fluent factory body. Fragment:

container.BindFactory<EnemySpawnRequest, Enemy, EnemyFactory>();   // EnemyFactory : IFactory<EnemySpawnRequest, Enemy>

BindFactory has zero-, one- and two-parameter forms matching IFactory<TValue>, IFactory<TParam, TValue> and IFactory<TParam1, TParam2, TValue>. Prefab pools, IPool<T>, IPoolHooks and the pooled factory adapters are in Factories and Pooling.

Build and async startup

Build() runs the synchronous build callbacks, finalizes the bindings, collects the lifecycle instances and runs every IOnityInitializable.Initialize(). It is idempotent. BuildAsync runs Build(), then the async build callbacks, then every IOnityAsyncInitializable one at a time. Fragment:

container.RegisterBuildCallback(resolver => resolver.Resolve<IGameLoopRunner>().Start());
container.RegisterBuildCallbackAsync(async (resolver, token) => await resolver.Resolve<ISaveLoader>().PrimeAsync(token));

container.Build();                                // sync callbacks, Initialize(); idempotent
await container.BuildAsync(cancellationToken);   // Build(), async callbacks, InitializeAsync(); result cached

Callbacks cannot be registered after the build is finalized; both RegisterBuildCallback overloads throw OnityBindingException then. Each async step starts on the context the build started on, so a callback that completes on a worker thread does not move the next step off the Unity main thread. The token the callbacks and initializers receive is the scope’s LifetimeToken, linked with the caller’s token when the caller passes one; disposing the container mid-run ends BuildAsync as canceled. A canceled or faulted run is not cached: the next BuildAsync call runs the async callbacks again unless all of them completed, and resumes at the first async initializer that has not completed. See Async initialization and Scope lifetime token.

Documented behaviors

Tests lock these behaviors; rely on them and avoid the traps.

Behavior Rule
Implicit transients An unbound concrete class resolves as a new instance per resolve. It is not shared state; bind it AsSingle() when it must be.
Unbound abstractions An unbound interface, abstract class or open generic definition throws OnityResolveException.
Last binding wins Binding the same contract twice replaces the first binding; there is no duplicate-binding error.
Distinct singletons Two separate Bind<I>().To<C>() calls create two C instances. Use BindInterfacesAndSelfTo<C>().AsSingle() to share one.
Constructor selection A single [Inject] constructor wins; otherwise the public constructor with the most parameters. A second [Inject] constructor throws OnityBindingException.
Circular dependency Constructor and member cycles throw OnityResolveException at resolve time, with the full chain in the message. Break the cycle with a factory or IResolver.
Conditional and identified bindings WhenInjectedInto and WithId are configured before the lifetime. A missing non-null id does not construct a service implicitly.
Post-build bindings Unbind and Rebind work after Build(); build callbacks do not.

Unity wiring

In a scene, bindings live in a MonoInstaller (Onity.Unity.Installers). A context (ProjectContext, SceneContext or GameObjectContext in Onity.Unity.Contexts) creates the container, binds the defaults (the container, IResolver, IOnityScopeLifetime, the context itself, MessageBroker and OnityEventHub), runs the installers in its Installers list, calls Build(), and injects the MonoBehaviours under its own GameObject. The full context model, execution orders and serialized fields are in Lifecycle and Scopes.

Two Unity-only helpers belong in installers. BindScriptableObject(asset) and BindScriptableObject<TContract, TAsset>(asset) (Onity.Unity.Installers) member-inject a ScriptableObject and bind it as an instance, so designer-authored configuration reaches services as a read-only contract. BindMessageChannel<T>() (Onity.Unity.Messaging) binds the broker’s publisher and subscriber for one message type. Fragment:

container.BindScriptableObject<IGameConfig, GameConfig>(m_config);   // inject the asset, bind it as IGameConfig
container.BindMessageChannel<PlayerDied>();                           // IPublisher<PlayerDied>, ISubscriber<PlayerDied>

See also