Lifecycle and Scopes

Use this page to decide how long each object lives and who stops it. In Onity a scope is an OnityContainer with its children. The scope owns the services it created, the subscriptions tied to it, the async work started with its token, and the pools it created, and it disposes them together in a fixed order. You never write teardown code that tracks these one by one.

Contents:

One lifetime model

Every pillar attaches to the scope the same way:

  • Services bound with AsSingle() or AsScoped(), and the primitives created by the binding helpers, are disposed by the scope when they implement IDisposable.
  • Subscriptions are retained with AddTo(scope) (Onity.DI), which disposes them when the scope ends.
  • Async work takes the scope token (IOnityScopeLifetime.Token, OnityContainer.LifetimeToken, OnityContext.LifetimeToken or component.GetScopeCancellationToken()), which is canceled when the scope ends.
  • Pools created by BindPooledFactory(prefab, ...) are disposed by the scope.

Dispose() runs in this order: it cancels LifetimeToken (which ends token-bound async work and disposes every AddTo(scope) registration), disposes the child scopes installed by sub-container exports, disposes the scoped instances, and then disposes the singletons and other owned providers in reverse registration order. Async work therefore stops before the services it uses are torn down. In a scene, the context calls Dispose() from OnDestroy.

The canonical HealthService from Getting Started shows all three attachments in one class: damage.Subscribe(OnDamaged).AddTo(scope), RegenerateAsync(m_scope.Token), and a ReactiveProperty<int> the scope created with BindReactiveProperty.

Lifetimes

Lifetime Instances Disposed by
AsSingle() one per declaring scope, shared with its children the declaring scope
AsScoped() one per resolving scope, built with that scope’s dependencies the resolving scope
AsTransient() a new instance per resolve nobody; the caller owns it

A child container inherits the parent’s bindings. A binding declared on the child shadows the parent’s binding only inside the child. An AsScoped() binding declared on the parent gives each resolving child its own instance:

using Onity.DI;                 // OnityContainer

using OnityContainer parent = new OnityContainer();
parent.Bind<IClock>().To<GameClock>().AsSingle();
parent.Bind<IMatchState>().To<MatchState>().AsScoped();
parent.Build();

using OnityContainer child = new OnityContainer(parent);
child.Bind<IClock>().To<ReplayClock>().AsSingle();     // shadows IClock in the child only
child.Build();

// child.Resolve<IClock>()       -> ReplayClock
// parent.Resolve<IClock>()      -> GameClock
// child.Resolve<IMatchState>()  -> one child-owned MatchState on repeated calls
// parent.Resolve<IMatchState>() -> a separate parent-owned MatchState

Disposing the child disposes its own singletons and scoped instances and cancels its own token; it does not dispose the parent. Disposing the parent cancels the child’s token too, because the tokens are linked.

Mapping from VContainer:

VContainer Onity
Lifetime.Singleton on the root scope AsSingle() on the root container
Lifetime.Scoped AsScoped(); resolve through the child scope that should own the instance
Lifetime.Transient AsTransient()

Scope lifetime token

Every container has a lifetime token: OnityContainer.LifetimeToken is a CancellationToken that Dispose() cancels first, before it disposes child scopes, scoped instances and singletons. The token source is created on the first request, so a container whose token is never requested allocates nothing for it.

Member Namespace What it does
OnityContainer.LifetimeToken Onity.DI Canceled when the container is disposed; a disposed container returns a canceled token. A child’s token is linked to its parent’s, so disposing the parent cancels the child’s token too.
IOnityScopeLifetime (Token, IsDisposed) Onity.DI The scope as an injectable contract. OnityContainer implements it, and every Unity context binds it to its own container, so a service that injects it receives the scope that owns it.
disposable.AddTo(scope) Onity.DI Disposes the IDisposable when the scope ends, immediately if it already ended. The scope keeps the disposable until then, so dispose short-lived subscriptions yourself.
OnityContext.LifetimeToken Onity.Unity.Contexts The context’s container token, canceled when the context is destroyed or its scene unloads. Before Awake creates the container it is the context’s destroy token.
component.GetScopeCancellationToken() Onity.Unity.Async The LifetimeToken of the nearest OnityContext on the component’s GameObject or its parents; without one, the component’s destroy token. Main thread only; cache the result instead of calling it every frame.

A service that subscribes and runs a loop for the life of its scope:

using System.Threading;
using Onity.DI;                 // IOnityInitializable, IOnityScopeLifetime, AddTo(scope)
using Onity.Messaging;          // IMessageBroker, Subscribe<T>
using Onity.Unity.Async;        // OnityTask, OnityTaskVoid

public sealed class RespawnTimer : IOnityInitializable
{
    private const float k_respawnSeconds = 5f;

    private readonly IOnityScopeLifetime m_scope;

    public RespawnTimer(IOnityScopeLifetime scope, IMessageBroker broker)
    {
        m_scope = scope;
        broker.Subscribe<PlayerDied>(OnPlayerDied).AddTo(scope);   // unsubscribed when the scope ends
    }

    public void Initialize()
    {
        RunAsync(m_scope.Token).Forget();                         // canceled when the owning context is destroyed
    }

    private async OnityTaskVoid RunAsync(CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            await OnityTask.Delay(k_respawnSeconds, token);
            // Respawn work.
        }
    }

    private void OnPlayerDied(PlayerDied message)
    {
        // Start the countdown.
    }
}

Outside a Unity context, bind the scope yourself with container.BindInstance<IOnityScopeLifetime>(container) or pass container.LifetimeToken directly. Async build callbacks receive the scope token automatically; see Build and async startup.

Disposal ownership

Dispose() disposes the scope’s singletons, scoped instances and installed child scopes. Who owns what:

  • Objects the container constructed (AsSingle(), AsScoped()): the scope disposes them when they implement IDisposable.
  • Objects the binding helpers created: the scope owns and disposes them. This covers the primitives of BindReactiveProperty, BindSubject, DeclareMessage, DeclareAsyncMessage and BindAsyncReactiveProperty, and the prefab pool of BindPooledFactory(prefab, ...). Disposing that pool destroys its inactive clones; instances still checked out stay with their owners, and a later Release into it throws ObjectDisposedException.
  • Objects you pass to BindInstance: caller-owned. The container does not dispose them. The same holds for a pool you build and pass to BindPooledFactory(pool); tie it to the scope with pool.AddTo(container) when the scope should dispose it. See Pool lifetime.
  • Transient instances: caller-owned.

In a scene the context disposes its container in OnDestroy, so you rarely call Dispose() by hand.

The automatic lifecycle

Implement a lifecycle interface (Onity.DI) on a singleton, a scoped binding or a bound instance, bind the type, and the owning container collects it at Build(). There is no separate entry-point registration. Transient bindings are not collected, because there is no single instance to drive. Child scopes installed by FromSubContainerResolve run their own build and receive ticks from the requesting scope.

Interface Method When it runs
IOnityInitializable Initialize() once, at the end of Build(), in binding-registration order; every dependency is resolvable
IOnityAsyncInitializable InitializeAsync(CancellationToken) once, in BuildAsync() after the async build callbacks, one at a time in binding-registration order; a context runs it from Start and becomes ready after it completes
IOnityTickable Tick() once per frame, from the context’s Update
IOnityFixedTickable FixedTick() once per physics step, from the context’s FixedUpdate
IOnityLateTickable LateTick() once per frame after all Tick() work, from the context’s LateUpdate
using Onity.DI;                 // IOnityInitializable, IOnityTickable
using Onity.Messaging;          // IPublisher<T>

public sealed class WaveDirector : IOnityInitializable, IOnityTickable
{
    private readonly IPublisher<WaveStarted> m_waves;

    public WaveDirector(IPublisher<WaveStarted> waves)
    {
        m_waves = waves;
    }

    public void Initialize()
    {
        m_waves.Publish(new WaveStarted(1));   // runs once after Build(); dependencies are ready
    }

    public void Tick()
    {
        // Runs every frame while the owning context is alive.
    }
}

Binding it is the whole wiring (fragment): container.BindInterfacesAndSelfTo<WaveDirector>().AsSingle();. BindInterfacesAndSelfTo binds the lifecycle interfaces along with the concrete type, so one instance is both injectable and driven. Lifecycle singletons are created at Build(); other singletons stay lazy. A rebound tickable stops the old instance and starts the replacement. Outside a Unity context, drive the ticks yourself with container.Tick(), FixedTick() and LateTick(); Build() runs Initialize(), and await container.BuildAsync() runs InitializeAsync().

For per-frame work in a MonoBehaviour, subscribe to OnityUnityObservable.EveryUpdate() (Onity.Unity.Reactive) and retain it with TakeUntilDisable(this); see Reactive. A bound service ticks through IOnityTickable, which costs no subscription.

Async initialization

IOnityAsyncInitializable is the awaitable startup interface. The container collects it like IOnityInitializable and awaits it in BuildAsync:

using System.Threading;
using System.Threading.Tasks;
using Onity.DI;                 // IOnityAsyncInitializable
using Onity.Unity.Async;        // OnityTask

public sealed class SaveLoader : IOnityAsyncInitializable
{
    private readonly ISaveStore m_store;

    public SaveLoader(ISaveStore store)
    {
        m_store = store;
    }

    public async ValueTask InitializeAsync(CancellationToken cancellationToken)
    {
        await m_store.LoadAsync(cancellationToken);      // canceled when the scope is destroyed first
        await OnityTask.NextFrame(cancellationToken);    // OnityTask awaits work here too
    }
}
  • Order: Build() (sync callbacks and Initialize()), then the async build callbacks, then each async initializer one at a time in binding-registration order.
  • Thread: each callback and initializer starts on the context the build started on, the main thread in a Unity context, even when the previous one completed on a worker thread.
  • Token: the scope’s LifetimeToken, linked with the BuildAsync caller’s token when one is passed. Destroying the context mid-initialization cancels it, leaves IsReady false and cancels ReadyTask without logging an error.
  • Failure and retry: a fault ends BuildAsync and the context logs it. The next BuildAsync() call resumes at the first initializer that has not completed; completed initializers do not run again. An initializer bound after a completed BuildAsync runs on the next call.
  • Readiness: OnityContext.IsReady, ReadyTask (a BCL Task) and WaitReadyAsync(cancellationToken) (an OnityTask that resumes on the main thread) all cover the async initializers. The token passed to WaitReadyAsync cancels that wait only; the build keeps running.

Unity contexts

A context (Onity.Unity.Contexts) is a MonoBehaviour that owns one container. In Awake it creates the container (with the discovered parent context’s container as parent), binds the defaults, runs the installers in its Installers list, calls Build(), and, when Auto Inject Hierarchy is on, member-injects every MonoBehaviour under its own GameObject that is not owned by a nearer context. In Start it runs BuildAsync(LifetimeToken). Update, FixedUpdate and LateUpdate pump the ticks. OnDestroy disposes the container, which cancels the scope token first. Destroying a context while its async build runs cancels the build without logging an error and leaves ReadyTask canceled.

Every context binds: the container, IResolver, IOnityScopeLifetime (the container), the context itself, MessageBroker with its interfaces, and OnityEventHub. That is why a service can inject OnityEventHub, IMessageBroker or IOnityScopeLifetime with no installer line; see Events and Messaging.

Context Role Execution order Parent discovery
ProjectContext Global root that persists across scene loads; ProjectContext.Instance, DontDestroyOnLoad -10000 none
SceneContext One scope per scene -9800 the Project Context field, else ProjectContext.Instance
GameObjectContext One scope per prefab or object subtree -9700 the nearest OnityContext above it in the hierarchy, else ProjectContext.Instance

Serialized fields:

Field On Default Effect
Installers every context empty the MonoInstaller components run in order during Awake
Parent Context every context none an explicit parent; leave empty for automatic discovery
Auto Inject Hierarchy every context on member-inject the MonoBehaviours under the root in Awake; installers and contexts are skipped
Run Async Build Callbacks every context on run BuildAsync() in Start; when off, Start marks the context ready without running anything async
Project Context SceneContext none an explicit ProjectContext; leave empty to use ProjectContext.Instance
Find Parent In Hierarchy GameObjectContext on search the parent hierarchy for the nearest context
Fallback To Project Context GameObjectContext on use ProjectContext.Instance when no parent context is found

Add a context by searching its name (for example Scene Context) in Add Component; there is no Onity submenu. A context’s InjectGameObject(root) injects objects you instantiate at runtime under it.

Where each installer goes

The context decides a service’s lifetime, so put each installer on the context whose lifetime matches the service:

  • Project-scope services go in an installer on the ProjectContext prefab: anything that must live for the whole session and survive scene loads, such as settings, saves, currency, inventory, audio and scene flow.
  • Per-scene collaborators go on that scene’s SceneContext: a match’s rules, presentation and spawn factories, per-screen controllers. A SceneContext is a child of the project context, so a scene installer depends on project services without rebinding them.
  • Per-object graphs go on a GameObjectContext on the prefab root: an enemy’s brain and its parts, a UI panel and its presenters.
  • Never put a project-scope installer on a SceneContext. The scene scope is rebuilt on every scene load, so its singletons do not persist.

ProjectContext is loaded before the first scene by ProjectContextBootstrap, a RuntimeInitializeOnLoadMethod that runs before scene load and instantiates the prefab at Resources/Onity/ProjectContext when no ProjectContext exists yet. Create that prefab once with the menu Onity/Contexts/Create ProjectContext Prefab, which saves it at Assets/Resources/Onity/ProjectContext.prefab, then add the project installers to its Installers list. A SceneContext finds ProjectContext.Instance on its own, or through its Project Context field.

ProjectContext (Assets/Resources/Onity/ProjectContext.prefab)
    Installers: [ProjectInstaller]                 // persists across scenes
SceneContext
    Installers: [GameInstaller]                    // per scene; resolves the project services above

Scene transitions

Onity.Unity.SceneFlow can hide a scene change behind a screen cover and can keep the next scene built in the background. Both outlive the scene that asks for them, so bind them on the ProjectContext:

public sealed class SceneFlowInstaller : MonoInstaller
{
    [SerializeField] private OnityScreenFadeView m_fade;   // on the ProjectContext prefab

    public override void InstallBindings(OnityContainer container)
    {
        container.BindInstance<IOnitySceneCover>(m_fade);
        container.Bind<IOnitySceneReadiness>().To<OnityActiveSceneReadiness>().AsSingle();
        container.BindInterfacesAndSelfTo<OnityCoveredSceneTransition>().AsSingle();
        container.BindInterfacesAndSelfTo<OnityScenePreloader>().AsSingle();
    }
}

OnityCoveredSceneTransition.RunAsync(change, showCover, token) shows the cover, runs the change, waits until the new active scene’s SceneContext.ReadyTask completes (plus the settle frames of OnitySceneRevealPolicy, with a maximum wait), then hides the cover. With showCover: false the same flow runs with no cover; RunAsync(change, cover, token) uses another cover for one change, and a null cover means none. The change receives the transition’s own lifetime token, because the scene that asks is usually the one the change unloads; the caller’s token ends only the caller’s wait. One change runs at a time: a request while another runs, or after disposal, returns false at once. A change that throws hides the cover over the old scene again and fails the task with its exception.

bool ran = await m_transition.RunAsync(
    token => OnitySceneFlow.TransitionAsync(m_profile, OnitySceneFlowStateId.Gameplay, request, token),
    showCover: true,
    cancellationToken);

Covers are IOnitySceneCover implementations. OnitySceneCoverView is the base for a cover drawn by one element of a UIDocument: it owns the timing (unscaled, a serialized duration and curve each way), the cancellation, input blocking and hiding, and a derived cover only implements ApplyProgress(progress) (0 is clear, 1 is covered). Two ship with templates under Runtime/Unity/UI/SceneFlow:

  • OnityScreenFadeView with OnityScreenFade.uxml: fades the USS color (black), or its optional image. Give each image its own fade view and pass the one you want to RunAsync.
  • OnityIrisCoverView with OnityIrisCover.uxml: a circle wipe drawn with Painter2D, closing on its Center (normalized panel position).

Put the cover’s UIDocument on the ProjectContext prefab with a PanelSettings whose sort order is above every other panel. A visible cover blocks presses below it; a clear one is display: none. The animation allocates nothing per frame.

OnityScenePreloader keeps one scene ready: PrepareAsync(sceneName, prepareData, token) loads it additively behind the active scene, and TryStartAsync(sceneName, startData, token) starts it in one frame (it becomes active, the old scene hides with OnitySceneVisuals.Hide and unloads). The prepared scene learns it is being prepared from OnityScenePreloader.TryGetPrepareData<T>(gameObject.scene, out T) while it installs, keeps itself hidden, and binds an IOnityPreparedScene that decides which start requests it accepts (CanStart) and shows itself in Activate. Prepare data is kept per loading scene, never in the shared OnitySceneTransitionStore slot, so a scene-flow transition running at the same time cannot take it. TryStartAsync returns false when nothing matching is prepared; load the scene normally then. The preloader knows nothing about covers, so the two compose in the change:

await m_transition.RunAsync(async token =>
{
    if (await m_preloader.TryStartAsync("Game", request, token) == false)
    {
        await OnitySceneFlow.TransitionAsync(m_profile, OnitySceneFlowStateId.Gameplay, request, token);
    }
}, showCover: useCover, cancellationToken);

OnitySceneScopes.TryFind(scene, out SceneContext context) finds a loaded scene’s context; call it once per scene change, never per frame.

The scene service

IOnitySceneService is one injectable entry point that composes the pieces above and owns every scene change from the request to the ready destination. Bind it once on the ProjectContext; scene scopes resolve the same instance from their parent. Every argument is optional:

public sealed class SceneFlowInstaller : MonoInstaller
{
    [SerializeField] private OnitySceneFlowProfile m_profile;   // routes Single loads through its Loading scene
    [SerializeField] private OnityScreenFadeView m_fade;        // the default cover

    public override void InstallBindings(OnityContainer container)
    {
        OnityScenePreloader preloader = new OnityScenePreloader().AddTo(container);
        container.BindInstance(preloader);
        container.BindSceneService(m_profile, m_fade, preloader);
    }
}

public sealed class LevelLauncher
{
    private readonly IOnitySceneService m_scenes;

    public LevelLauncher(IOnitySceneService scenes)
    {
        m_scenes = scenes;
    }

    public async OnityTask<bool> StartLevelAsync(int level, CancellationToken cancellationToken)
    {
        OnitySceneResult result = await m_scenes.LoadAsync(
            OnitySceneRequest.Single("Gameplay", new LevelEnterData(level)),
            cancellationToken);
        return result.IsSuccess;
    }
}
  • Completion. The task completes once the destination scene’s SceneContext has built (a scene without one is ready at once), its cover has lifted and the scenes it replaced have unloaded. When the profile routes through its Loading scene, that scene’s OnityLoadingSceneInitiator shows the progress and minimum visible time while the service loads the target behind it, covers the screen before the target activates and keeps waiting for the target.
  • One operation at a time. A request while one runs returns Busy at once and touches nothing. The caller’s token ends only its own wait; an already cancelled token starts nothing. Dispose (the scope ends) completes the running operation with Disposed and rejects later requests; a scene activation Unity already holds still completes.
  • Results. Succeeded, Busy, InvalidTarget (no such scene, a name shared by several Build Settings scenes: request it by path, or options that cannot combine), NotLoaded, Disposed, LoadFailed, ReadinessFailed (the build failed, the context was destroyed or the scene unloaded first) and ReadinessTimedOut (not ready within OnitySceneRevealPolicy.MaxWaitSeconds). The cover lifts after a readiness failure too; only the result tells them apart. Scene is the exact destination.
  • Requests. OnitySceneRequest.Single(scene, data) uses the default cover; OnitySceneRequest.Additive(scene, data, setActive) uses none and keeps the active scene unless setActive is true. WithCover(cover), WithoutCover() and WithDefaultCover() change the cover. WithPreparedScene() starts the scene the preloader keeps prepared under the same name when it accepts the data as its start data, and loads it normally otherwise; an additive request cannot. UnloadAsync(scene) unloads exactly that instance, never another one with the same name.
  • Enter data. The destination reads it for its own handle while it installs or builds: OnitySceneTransitionStore.TryGetEnterData<LevelEnterData>(gameObject.scene, out LevelEnterData data). It is removed when the operation ends, so no later scene receives it. Scenes written against TryConsumeActiveEnterData receive it too.
  • Observation. State publishes the step (Covering, Loading, Initializing, Revealing, Unloading) and progress only when they change, with no allocation per progress update. Completed publishes each started operation’s result once, after the service is idle: start the next load from there or from the awaited result, not from a State callback. ActiveScene reads SceneManager on every call; loads made around the service are seen but not serialized with its operations.

OnitySceneFlow.TransitionAsync keeps its behavior (it completes when the entry scene has loaded), and OnityCoveredSceneTransition and OnityScenePreloader stay usable on their own.

See also