Binding
A binding is a rule declared in a Scope that tells Saneject:
- What dependency type should be resolved
- Where candidates come from
- Which injection sites may use the binding
- Optional filters that reject candidates
Bindings are collected from Scope.DeclareBindings() during editor injection, validated, then used to resolve
[Inject] fields, properties and method parameters.
Binding rules
- Every binding must specify a locator strategy with a
From...call. - Type matching is strict:
- Specifying an interface is optional; concrete-only bindings are valid.
BindComponent<TConcrete>()andBindAsset<TConcrete>()only matchTConcreteinjection sites.BindComponent<TInterface>()only matchesTInterfaceinjection sites.BindComponent<TInterface, TConcrete>()andBindAsset<TInterface, TConcrete>only matchTInterfaceinjection sites, with aTConcreteobject that implementsTInterface.
- Single and collection bindings do not mix:
- Single bindings (
BindComponent,BindAsset) resolve single fields/properties, parameters. - Collection bindings (
BindComponents/BindAssets/BindMultiple...) resolve arrays andList<>.
- Single bindings (
- Bindings are local to the declaring scope. If the nearest scope to an injection site has no matching binding, Saneject walks up parent scopes until it finds one.
- Invalid bindings are excluded from valid resolution and logged.
See Scope for more information
Fluent binding flow
Most bindings follow this general pattern or a combination of these:
BindComponent<IAudioService, AudioManager>()
.ToID("hud") // Optional qualifier that matches [Inject("hud")]
.ToTarget<CombatHud>() // Optional qualifier that matches injection target objects of type CombatHud
.ToMember("audioService") // Optional qualifier that matches members named "audioService"
.FromTargetSelf() // Required locator strategy that looks for the AudioManager on the transform of the CombatHud
.WhereComponent(c => c.isActiveAndEnabled); // Optional filter that only includes enabled components
This resolves candidates in three phases:
- Match binding by binding qualifiers (if any).
- Locate candidates with a
From...method. - Apply
Where...filters (if any).
Binding families
Component bindings
Component bindings resolve UnityEngine.Component dependencies from transforms, hierarchy traversal, scene-wide search,
or explicit instances.
protected override void DeclareBindings()
{
// Interface + concrete mapping.
BindComponent<IAudioService, AudioManager>()
.FromScopeSelf();
// Collection binding.
BindComponents<EnemyController>()
.FromScopeDescendants(includeSelf: true)
.WhereComponent(c => c.gameObject.activeInHierarchy);
}
Full component API:
Global component bindings
Global bindings are component bindings declared with BindGlobal<TComponent>(). The resolved component is serialized
into the declaring scope at editor time and registered in GlobalScope during that scope's Awake().
protected override void DeclareBindings()
{
BindGlobal<AudioManager>()
.FromScopeSelf()
.WhereGameObject(go => go.activeInHierarchy);
}
Global bindings support locator and filter methods, but not binding qualifiers and not runtime proxy methods.
Full global binding API:
Asset bindings
Asset bindings resolve UnityEngine.Object assets from Resources, AssetDatabase paths/folders, or explicit
instances.
protected override void DeclareBindings()
{
BindAsset<IGameConfig, GameConfigAsset>()
.ToID("default")
.FromResources("Configs/GameConfig");
BindAssets<AudioClip>()
.FromFolder("Assets/Game/Audio/Sfx")
.Where(clip => clip.name.StartsWith("Enemy_"));
}
Full asset API:
Runtime proxy bindings
Runtime proxy bindings are configured from component bindings via FromRuntimeProxy(). They inject a proxy asset at
editor time, then swap to a real runtime instance in the scope's Awake().
Rules:
- Must be
BindComponent<TInterface, TConcrete>(). - Must be single-value (not collection).
- Binding qualifiers are supported.
- Filters are not supported.
protected override void DeclareBindings()
{
BindComponent<ICombatService, CombatService>()
.ToID("combatService")
.FromRuntimeProxy()
.FromGlobalScope();
}
protected override void DeclareBindings()
{
BindComponent<ICombatService, CombatService>()
.FromRuntimeProxy()
.FromComponentOnPrefab(combatServicePrefab, dontDestroyOnLoad: true)
.AsSingleton();
}
Full runtime proxy API:
Binding qualifiers
Binding qualifiers restrict which injection sites (fields, properties, methods) can be resolved from a binding.
| Qualifier | Injection site match |
|---|---|
ToID("someId") |
Fields, properties, methods marked with [Inject("someId")] |
ToTarget<TTarget>() |
Fields, properties, methods owned by TTarget objects and derived types |
ToMember("someMemberName") |
Fields, properties, methods with name "someMemberName" |
Important behavior:
- Binding qualifiers are additive, so all specified qualifiers must match.
- Injection sites without an ID match bindings without
ToID. Injection sites with an ID only match bindings with the sameToID. - If
ToTargetorToMemberis not set on the binding, that qualifier does not restrict where the binding applies. ToTarget<TTarget>()matches the actual object that owns the injected member. This can be a component or a nested serializable object, and a binding targeted to a base type also matches derived types.- Binding qualifiers apply to component, asset, and runtime proxy bindings.
- Binding qualifiers do not apply to global bindings.
Example:
protected override void DeclareBindings()
{
BindAsset<IGameConfig, GameConfigAsset>()
.ToID("menu")
.ToTarget<MainMenuController>()
.ToMember("config")
.FromResources("Configs/Menu");
}
Binding filters
Binding filters run after locator search and before final assignment. A candidate (potentially injected dependency) must pass all filters on the binding.
Component filter example:
protected override void DeclareBindings()
{
// First component anywhere in the scene, that is active/enabled and is a descendant of a Transform tagged "GameplayRoot".
BindComponent<EnemyController>()
.FromAnywhere()
.WhereComponent(c => c.isActiveAndEnabled)
.WhereAnyAncestor(t => t.CompareTag("GameplayRoot"));
}
Asset filter example:
protected override void DeclareBindings()
{
// All assets in the "Assets/Game/Audio/Music" folder with names starting with "Boss_".
BindAssets<AudioClip>()
.FromFolder("Assets/Game/Audio/Music")
.Where(clip => clip.name.StartsWith("Boss_"));
}
| Binding Family | Filter Support |
|---|---|
| Component bindings | Yes |
| Asset bindings | Yes |
| Global bindings | Yes (same filter API as component bindings). |
| Runtime proxy bindings | No |
If a binding filter throws an exception, Saneject logs a binding filter error for that binding.
Binding uniqueness
Saneject enforces unambiguous bindings within each Scope. When two bindings are considered duplicate or ambiguous, Saneject logs an error and excludes the conflicting binding from the injection run.
Duplicate and ambiguity checks use these criteria:
- Same scope.
- Same binding family (
ComponentBindingNode,AssetBindingNode, orGlobalComponentBindingNode). - Same primary type:
TInterfacewhen present.- Otherwise
TConcrete.
- Same single/collection shape.
- Qualifier ambiguity:
- If the criteria above do not separate two bindings, they conflict unless at least one qualifier rule below separates them.
ToIDseparates bindings by ID. A binding withoutToIDdoes not overlap a binding withToID, and two bindings withToIDoverlap only when their IDs overlap.ToTargetqualifiers overlap when their target type hierarchies overlap.ToMemberqualifiers separate bindings only when both bindings specify non-overlapping values.- Empty
ToTargetandToMemberqualifier sets are unrestricted and do not separate bindings.
Examples:
// Duplicate or ambiguous: same scope, same family, same type, same shape, no qualifiers.
BindComponent<AudioManager>()
.FromScopeSelf();
BindComponent<AudioManager>()
.FromScopeParent(); // Invalid duplicate.
// Distinct: different qualifier sets.
BindAsset<IGameConfig, GameConfigAsset>()
.ToTarget<MainMenuController>()
.ToMember("config")
.ToID("menu")
.FromResources("Configs/Menu");
BindAsset<IGameConfig, GameConfigAsset>()
.ToTarget<GameplayController>()
.ToMember("config")
.ToID("gameplay")
.FromResources("Configs/Gameplay");
// Distinct: one binding matches ID "menu" and the other matches injection sites without an ID.
BindAsset<IGameConfig, GameConfigAsset>()
.ToID("menu")
.FromResources("Configs/Menu");
BindAsset<IGameConfig, GameConfigAsset>()
.ToTarget<MainMenuController>()
.FromResources("Configs/Menu");
// Ambiguous: both bindings can resolve the config member on MainMenuController.
BindAsset<IGameConfig, GameConfigAsset>()
.ToMember("config")
.FromResources("Configs/Menu");
BindAsset<IGameConfig, GameConfigAsset>()
.ToTarget<MainMenuController>()
.FromResources("Configs/Menu");
Global bindings have an extra rule: only one global binding per concrete component type is allowed across active
bindings across all scopes. A second BindGlobal<AudioManager>() is invalid even if declared in another scope.
Binding validation
While the fluent API prevents most invalid bindings, it's still possible to create invalid bindings that will compile. However, the injection run has a validation step that catches invalid bindings, excludes them from the run and logs them as errors.
Current validation checks include:
- Duplicate binding in the same scope.
- Duplicate global binding by concrete type.
- Runtime proxy binding constraints:
- Interface type required.
- Concrete type required.
- Collection mode not allowed.
- Component binding concrete type must derive from
UnityEngine.Component. - Asset binding concrete type must not derive from
UnityEngine.Component. - Interface type must actually be an interface.
- Concrete type must implement the declared interface.
- Locator strategy must be set.
Examples of invalid but compilable bindings:
// Invalid: no locator strategy specified.
BindAsset<GameConfigAsset>();
// Invalid: runtime proxy bindings cannot be collection bindings.
BindComponents<ICombatService, CombatService>()
.FromRuntimeProxy();
Validation runs before dependency resolution, so invalid bindings are never used to satisfy [Inject] members.