Runtime proxy
Runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. is one of the more advanced Saneject features. The API is small, but it helps to understand the runtime model before using it.
At a high level, a runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. is a bridge for references Unity cannot serialize directly, such as scene-to-scene, scene-to-prefab, and prefab-to-prefab component references.
Why runtime proxy exists
Saneject is primarily editor-time DI. It resolves dependencies in the Editor and writes them into serialized members so they persist in scenes and prefabs.
That works well when Unity can serialize the real dependency directly. It breaks down when the dependency is in another runtime contextContextSerialization boundary Saneject uses during injection to decide scope traversal and candidate eligibility. that is not directly serializable.
Runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. solves this by injecting a serializable placeholder asset at editor time, then replacing that placeholder with the real instance during early runtime startupRuntime startupPhase after entering Play Mode when scopes initialize, global registrations are populated, and runtime proxy references can be swapped to real instances..
Mental model
A runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. is a serialized placeholder asset (ScriptableObject) that temporarily stands in for a real component dependency.
Think about it as a lifecycle, not just a type:
- At editor time, injection writes a runtime proxyRuntime proxy
ScriptableObjectplaceholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. asset into an interface member. - That runtime proxyRuntime proxy
ScriptableObjectplaceholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. asset is serialized, so the reference can persist across scene/prefab boundaries that Unity cannot serialize directly. - At runtime startupRuntime startupPhase after entering Play Mode when scopes initialize, global registrations are populated, and runtime proxy references can be swapped to real instances.,
Scope.Awake()(execution order-10000) automatically swaps the runtime proxyRuntime proxyScriptableObjectplaceholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. with the real instance. - After swap, gameplay code uses the resolved instance through the same interface member.
Type-wise, the runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. is based on RuntimeProxy<TComponent> and is generated in two parts:
- A generated runtime proxyRuntime proxy
ScriptableObjectplaceholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. script stub that inheritsRuntimeProxy<TComponent>and is marked with[GenerateRuntimeProxy]. - A Roslyn-generated partial implementation that makes the runtime proxyRuntime proxy
ScriptableObjectplaceholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. type implement all public non-generic interfaces onTComponent.
If a runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. is accessed directly before swap, its generated interface members throw InvalidOperationException. That behavior is intentional, because proxies are placeholders and are expected to be swapped automatically during startup.
Binding a runtime proxy
[Runtime runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. bindings](../reference/glossary.md#runtime-proxy-binding) start from a component bindingComponent bindingBinding declared with BindComponent... or BindComponents... that resolves Component instances from transforms, hierarchies, scenes, or explicit instances., but the global registrationGlobal registrationEntry added to GlobalScope at runtime, keyed by the component's concrete type and owned by the caller that registered it. and runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. consumer are usually in different contextsContextSerialization boundary Saneject uses during injection to decide scope traversal and candidate eligibility..
Bootstrap contextContextSerialization boundary Saneject uses during injection to decide scope traversal and candidate eligibility.:
using Plugins.Saneject.Runtime.Scopes;
public class BootstrapScope : Scope
{
protected override void DeclareBindings()
{
BindGlobal<GameManager>()
.FromScopeSelf();
}
}
Consumer contextContextSerialization boundary Saneject uses during injection to decide scope traversal and candidate eligibility. (another scene or prefab contextContextSerialization boundary Saneject uses during injection to decide scope traversal and candidate eligibility.):
using Plugins.Saneject.Runtime.Scopes;
public class HudScope : Scope
{
protected override void DeclareBindings()
{
BindComponent<IGameManager, GameManager>()
.FromRuntimeProxy()
.FromGlobalScope();
}
}
If you stop at FromRuntimeProxy() and do not call a resolve method, the default resolve method is FromGlobalScope().
Consumer component:
using Plugins.Saneject.Runtime.Attributes;
using UnityEngine;
public partial class HudController : MonoBehaviour
{
[Inject, SerializeInterface]
private IGameManager gameManager;
}
Runtime proxiesRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. only work through interfaces. The generated runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. type stands in for interface members, not concrete component members, which is why runtime proxy bindingsRuntime proxy bindingComponent binding configured with FromRuntimeProxy() that injects a proxy asset at editor time and swaps it for a real runtime instance during scope initialization. require BindComponent<TInterface, TConcrete>().
[SerializeInterface] is the standard way to make interface members serializable and to generate the SwapProxiesWithRealInstances() hook used during runtime swapping. See Serialized interface for more details.
API reference:
Editor/runtime flow
Runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. flow has two phases. Editor-time prepares serialized placeholders and runtime finalizes them into real instances.
Editor phase
- You declare a bindingBindingInstruction declared in a
Scopethat tells Saneject what to resolve, how to inject it, and where to search. withBindComponent<TInterface, TConcrete>().FromRuntimeProxy(...). - Roslyn and the Unity Editor generate the required runtime proxyRuntime proxy
ScriptableObjectplaceholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. scripts (stub plus generated partial implementation). - During injection, Saneject injects a runtime proxyRuntime proxy
ScriptableObjectplaceholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. asset into the interface member instead of a concrete component reference. - If that member contains a runtime proxyRuntime proxy
ScriptableObjectplaceholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. and the owner implementsIRuntimeProxySwapTarget, Saneject registers that component as a proxy swap targetProxy swap targetComponent that implementsIRuntimeProxySwapTargetand is asked at runtime startup to replace proxy references with resolved real instances. on the nearest upwardsScope.
flowchart TD
subgraph Editor["Editor"]
E1["1. Binding declaration (`FromRuntimeProxy`)"]
E2["2. Proxy script generation"]
E3["3. Editor-time injection writes proxy asset into interface member"]
E4["4. Proxy swap target registration in nearest `Scope`"]
E1 --> E2 --> E3 --> E4
end
Runtime phase
- Enter Play Mode
- Each
ScoperunsAwake()at execution order-10000and loops over its registered proxy swap targetsProxy swap targetComponent that implementsIRuntimeProxySwapTargetand is asked at runtime startup to replace proxy references with resolved real instances.. - For each target, the generated
SwapProxiesWithRealInstances()method checks serialized single interface members and callsResolveInstance()for anyRuntimeProxyBasefound. - The resolved concrete instance is assigned back to the interface member, so normal gameplay code reads the real object for the rest of execution.
If the bindingBindingInstruction declared in a Scope that tells Saneject what to resolve, how to inject it, and where to search. resolves via FromGlobalScope(), the required global registrationGlobal registrationEntry added to GlobalScope at runtime, keyed by the component's concrete type and owned by the caller that registered it. is already done in scopeScopeMonoBehaviour that declares bindings for a part of your hierarchy. startup before runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. swapping runs.
This startup path is lightweight because swapping uses generated member assignments, not reflection.
flowchart TD
subgraph Runtime["Runtime"]
R1["1. Enter Play Mode"]
R2["2. Scope loops over registered swap targets (`Awake`, order `-10000`)"]
R3["3. Generated swap resolves proxy (`ResolveInstance`) and assigns member"]
R4["4. Interface now references the concrete runtime instance"]
R1 --> R2 --> R3 --> R4
end
Resolve methods
FromRuntimeProxy() returns RuntimeProxyBindingBuilder, which configures how the runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. resolves the real instance:
| Method | Runtime behavior | Notes |
|---|---|---|
FromGlobalScope() |
Dictionary lookup in GlobalScope. |
Fastest option. Requires the concrete component to be registered in GlobalScope, usually via BindGlobal<TComponent>(). |
FromAnywhereInLoadedScenes() |
Uses FindFirstObjectByType<TComponent>(FindObjectsInactive.Include). |
Searches loaded scenes, including inactive objects. Returns the first match. |
FromComponentOnPrefab(prefab, dontDestroyOnLoad) |
Instantiates the prefab and resolves TComponent from the instantiated object. |
Prefab must contain TComponent. |
FromNewComponentOnNewGameObject(dontDestroyOnLoad) |
Creates a new GameObject and adds TComponent. |
Useful for runtime-only service components. |
Example with prefab creation:
using Plugins.Saneject.Runtime.Scopes;
using UnityEngine;
public class AudioScope : Scope
{
[SerializeField]
private GameObject audioServicePrefab;
protected override void DeclareBindings()
{
BindComponent<IAudioService, AudioService>()
.FromRuntimeProxy()
.FromComponentOnPrefab(audioServicePrefab, dontDestroyOnLoad: true)
.AsSingleton();
}
}
Instance mode
AsTransient() and AsSingleton() are available for creation-based methods:
FromComponentOnPrefab(...)FromNewComponentOnNewGameObject(...)
Behavior:
AsTransient(): Creates a new instance for each resolve call.AsSingleton(): Reuses one instance and caches it inGlobalScope. Also enforcesdontDestroyOnLoad: true.
FromGlobalScope() and FromAnywhereInLoadedScenes() are lookup-based methods, so they do not expose instance mode configuration.
Proxy generation
The runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. system is almost fully automated. Scripts and assets are generated automatically when declaring bindingsBindingInstruction declared in a Scope that tells Saneject what to resolve, how to inject it, and where to search. and when injecting. Runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. generation has three stages.
Script generation
1) Proxy type discovery (Roslyn)
At compile time, Saneject scans DeclareBindings() methods in Scope subclasses. It finds chains that include BindComponent<TInterface, TConcrete>() with .FromRuntimeProxy(), then emits an assembly manifest of required concrete runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. targets.
2) Proxy script generation (Unity Editor)
On domain reload, Saneject reads those manifests and generates missing runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. script stubs.
- Controlled by project settingProject settingsSaneject settings shared at project scope, stored in
ProjectSettings/Saneject/ProjectSettings.jsonand available fromSaneject/Settings -> Project Settings.GenerateProxyScriptsOnDomainReload. - If disabled, generate manually from
Saneject/Runtime Proxy/Generate Missing Proxy Scripts. - Scripts are created under
ProjectSettings.ProxyAssetGenerationFolder(default:Assets/SanejectGenerated/RuntimeProxies).
3) Proxy script partial generation (Roslyn)
During compilation, Roslyn generates a partial implementation for each runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. stub marked with [GenerateRuntimeProxy]. The generated partial makes the runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. implement the target component's public non-generic interfaces and emits stub members (events, properties, methods) that throw InvalidOperationException until the runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. is swapped.
Unused runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. scripts and assets can be cleaned from:
Saneject/Runtime Proxy/Clean Up Unused Scripts And Assets
Proxy asset generation and reuse
During injection, runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. assets are created automatically in the same configured output folder used for generated runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. scripts.
For each runtime proxy bindingRuntime proxy bindingComponent binding configured with FromRuntimeProxy() that injects a proxy asset at editor time and swaps it for a real runtime instance during scope initialization., Saneject checks whether an existing runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. asset already matches both:
- The runtime proxyRuntime proxy
ScriptableObjectplaceholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. target type (TConcrete) - The runtime proxyRuntime proxy
ScriptableObjectplaceholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. configuration (RuntimeProxyConfig, including resolve method, prefab, instance mode, anddontDestroyOnLoad)
If a match exists, it is reused. If not, a new asset is created. This keeps runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. asset count lower across scenes and prefabs while still producing the exact configuration each bindingBindingInstruction declared in a Scope that tells Saneject what to resolve, how to inject it, and where to search. needs.
Generated proxy anatomy
Auto-generated stub script:
using Plugins.Saneject.Runtime.Proxy;
using Plugins.Saneject.Runtime.Attributes;
[GenerateRuntimeProxy]
// GameManager implements IGameObservable, IGameStarter
public partial class GameManagerProxyC5D11084 : RuntimeProxy<GameManager>
{
}
Roslyn-generated partial:
using System;
// Roslyn implements all interfaces of the target GameManager
public partial class GameManagerProxyC5D11084 : IGameObservable, IGameStarter
{
private const string ProxyAccessExceptionMessage =
"Saneject: RuntimeProxy instances are serialized placeholders and should not be accessed directly.";
// Inherited from IGameObservable and generated by Roslyn
public event Action OnGameStarted
{
add => throw new InvalidOperationException(ProxyAccessExceptionMessage);
remove => throw new InvalidOperationException(ProxyAccessExceptionMessage);
}
// Inherited from IGameObservable and generated by Roslyn
public bool IsGameRunning
{
get => throw new InvalidOperationException(ProxyAccessExceptionMessage);
}
// Inherited from IGameStarter and generated by Roslyn
public void StartGame()
{
throw new InvalidOperationException(ProxyAccessExceptionMessage);
}
}
Rules and constraints
Runtime proxy bindingsRuntime proxy bindingComponent binding configured with FromRuntimeProxy() that injects a proxy asset at editor time and swaps it for a real runtime instance during scope initialization. must follow these constraints:
- Must be component bindingsComponent bindingBinding declared with
BindComponent...orBindComponents...that resolvesComponentinstances from transforms, hierarchies, scenes, or explicit instances., not asset bindingsAsset bindingBinding declared withBindAsset...orBindAssets...that resolvesUnityEngine.Objectassets from project content instead of scene or hierarchy components.. - Must declare both interface and concrete type:
BindComponent<TInterface, TConcrete>(). - Must be single-value bindingsBindingInstruction declared in a
Scopethat tells Saneject what to resolve, how to inject it, and where to search., not collections. TConcretemust derive fromUnityEngine.Component.- Runtime proxy bindingRuntime proxy bindingComponent binding configured with
FromRuntimeProxy()that injects a proxy asset at editor time and swaps it for a real runtime instance during scope initialization. path does not expose filter methods (Where...).
BindComponent<TConcrete>().FromRuntimeProxy() can compile, but it is invalidated during injection because runtime proxy bindingsRuntime proxy bindingComponent binding configured with FromRuntimeProxy() that injects a proxy asset at editor time and swaps it for a real runtime instance during scope initialization. require an interface type to stand in for.
In practice, you usually let Saneject generate and manage runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. scripts and runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. assets. Hand-authoring runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. types is possible, but not the intended workflow.
Runtime lifecycle considerations
Runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. resolution can fail if runtime preconditions are not met. Typical examples:
FromGlobalScope()with no registered instance inGlobalScope.FromAnywhereInLoadedScenes()before the target scene/object is loaded.FromComponentOnPrefab()when the prefab does not contain the target component.- Target component destroyed before runtime proxyRuntime proxy
ScriptableObjectplaceholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. resolution, for example because its scene or owning prefab instancePrefab instanceInstantiated prefab placed in a scene or nested inside another prefab. was unloaded. - Proxy used directly before swap, which throws
InvalidOperationException.
This is expected for runtime systems: load order and lifetime now matter. Most Saneject features run at editor time and avoid those concerns, but runtime proxyRuntime proxyScriptableObject placeholder asset (RuntimeProxy<TComponent>) injected into interface members at editor time and swapped to the real instance during scope startup. intentionally crosses into runtime lifecycle for dependencies that can't avoid it.