Editor-time resolved serialized field dependency injection for Unity - keep your references visible, classes clean, ditch the runtime container.
⚠️ Beta Notice
Saneject is currently in beta. While the core functionality is stable, I’m looking for testers to help catch edge cases, bugs and polish the tooling. If you find issues or have feedback, please open an issue or reach out.
- What Is This?
- Why Another DI Tool?
- Features
- Quick Start
- Demo Game
- Deep Dive
- What Is Dependency Injection?
- How Runtime DI Typically Works
- How Saneject DI Works
- Runtime DI vs Saneject Comparison
- Scopes & Resolution Order
- Binding Methods
- Component Locator Methods
- Object Locator Methods
- Component Filter Methods
- Object Filter Methods
- SerializeInterface
- Interface Proxy Object
- Global Scope
- Roslyn Tools in Saneject
- UX
- User Settings
- Limitations / Known Issues
- Credits / Contribution
- License
Saneject is a lightweight middle-ground between hand-wiring references and a full runtime DI container. It resolves dependencies in the Unity Editor using familiar DI syntax and workflows, writes them straight into serialized fields (including interfaces), and keeps your classes free of GetComponent
, singletons, manual look-ups, etc. At runtime it’s just regular, serialized Unity objects - no reflection, no container, no startup hit.
Pain Point | How Saneject Helps |
---|---|
We want structured dependency management but don’t want to commit to a full runtime DI workflow. | Saneject offers DI-style binding syntax and organisation without a runtime container - you keep editor-time determinism, default Unity lifecycle and Inspector visibility. |
“We want to see what’s wired where in the Inspector.” | All references are regular serialized fields. Nothing is hidden behind a runtime graph. |
Interfaces can’t be dragged into the Inspector. | Saneject’s Roslyn generator adds safe interface-backing fields with Inspector support. [SerializeInterface] IMyInterface myInterface shows up as a proper serialized field. |
Runtime DI lifecycles can feel opaque or fight Unity’s own Awake/Start order. | Everything is set and serialized in the editor. Unity’s normal lifecycle stays untouched. |
Large reflection-heavy containers add startup cost. | Saneject resolves once in the editor - zero reflection or allocation at runtime. |
Can’t serialize references between scenes or from a scene into prefabs. | InterfaceProxyObject , a Roslyn generated ScriptableObject , can be referenced anywhere like any asset. At runtime, it resolves and forwards to a real scene instance with minimal overhead. |
Mixed teams (artists/designers) struggle with code-only installers. | Bindings live in Scope scripts as simple, declarative C#. Fields are regular serialized fields marked with [Inject] , and field visibility can be toggled from settings. |
Saneject isn’t meant to replace full runtime frameworks like Zenject or VContainer. It’s an alternative workflow for projects that value determinism, Inspector visibility, and minimal runtime overhead.
- Editor-time, deterministic injection: Bindings are resolved in the editor, stored directly in serialized fields, including nested serialized classes.
- Fluent, scope-aware binding API: Search hierarchy or project, filter by tag/layer/name, bind by type or ID.
- Fail-fast validation: Flags missing/conflicting bindings and invalid setups (e.g., global inside prefabs) during editor injection pass.
- Unified Scope component: One Scope type handles both scenes and prefabs, with automatic context detection.
- Built-in caching & safety nets: Redundant injections are cached, prefab scopes are skipped in scene injection, and unused bindings are reported.
- Interface serialization with Roslyn:
[SerializeInterface] IMyInterface
fields show up in the Inspector. - Cross-scene / prefab references:
InterfaceProxyObject
ScriptableObjects
allow serialized references to objects Unity normally can’t link. - Global Scope container: Scene dependencies can be promoted to global singletons and resolved statically by proxies.
- No runtime reflection: Everything is injected and serialized in the editor. At runtime, it's just data Unity already serialized.
- Proxy resolution: Proxies resolve their targets once, then cache them. Minimal overhead (dictionary lookup or simple search).
- Native UI/UX: Designed to feel like it belongs in Unity - polished inspectors, minimal ceremony, and contextual behavior that matches Unity workflows.
- User-friendly tooling: One-click scene resolve, right-click proxy generation, correct inspector interface ordering, automatic Scope context handling.
- Inspector polish:
[Inject]
fields grayed out (or hidden), interface proxies show implemented types, help boxes on components. - User Settings panel: Toggle injected field visibility, logging, and more.
Requirement | Description |
---|---|
Unity Version | Unity 6000.0.23f1 LTS or newer. Relies on C# Roslyn source generators. Earlier versions may work but are currently untested. |
Scripting Backend | Mono or IL2CPP |
Platforms | Editor-only tooling; runtime code is plain C#, so it runs on any platform Unity 6 supports |
⚠️ Platform notice
Saneject’s runtime is just plain C# (no reflection, no dynamic code).
It's tested on Windows + Android (Mono & IL2CPP) builds without issues, but other IL2CPP targets (iOS, WebGL, consoles) are not yet verified.The only non-standard Unity moving parts are:
- The Roslyn-generated partial classes compiled into your assemblies.
ISerializationCallbackReceiver
setting interface fields after deserialization.Both should work everywhere Unity does, but if you run into stripping/AOT quirks, please open an issue.
Install Method | Instruction |
---|---|
Unity Package | Grab the latest unitypackage from the Releases page → double-click → import. |
Clone + Copy | 1. Clone the repo somewhere outside your projectgit clone https://github.com/alexanderlarsen/Saneject.git 2. Copy Saneject plugin folder into your own project cp -r Saneject/UnityProject/Saneject/Assets/Plugins/Saneject <YourUnityProjectRoot>/Assets/Plugins |
Unity Package Manager (UPM) | Currently unsupported but I'll get it up and running later. |
- Create a
GameObject
namedRoot
in the scene. - Add a
GameObject
namedPlayer
under theRoot
and attachPlayer.cs
and aCharacterController
to it:
public class Player : MonoBehaviour
{
// Interface field, marked for injection, shows up in the Inspector
[Inject, SerializeInterface]
private IGameStateObservable gameStateObservable;
// Concrete component field, marked for injection, living on the same GameObject
[Inject, SerializeField]
private CharacterController controller;
}
- Add
GameManager.cs
somewhere in the scene:
// Will satisfy the IGameStateObservable binding
public class GameManager : MonoBehaviour, IGameStateObservable
{ }
public interface IGameStateObservable
{ }
- Add
GameScope.cs
to theRoot
GameObject
:
// One place to declare where things come from
public class GameScope : Scope
{
public override void Configure()
{
// Look anywhere in the loaded scene for a GameManager and bind by interface.
// Resolves via FindObjectsByType<GameManager>(FindObjectsInactive.Include, FindObjectsSortMode.None)) under the hood.
RegisterComponent<IGameStateObservable, GameManager>().FromAnywhereInScene();
// Grab CharacterController on the injection target (Player) transform.
// Resolves via player.transform.GetComponent<CharacterController>() under the hood.
RegisterComponent<CharacterController>().FromTargetSelf();
}
}
- Run dependency injection using either method:
- Scope Inspector: Inject Scene Dependencies button
- Hierarchy Context Menu: Right-click hierarchy panel → Inject Scene Dependencies
- Unity Main Menu Bar: Saneject → Inject Scene Dependencies
Saneject fills in the serialized fields. Press Play - no runtime container required.
A small three-scene demo game lives in Assets/Plugins/Saneject/Demo
. It shows how to use Saneject in a real (but simple) game setup:
- Cross-scene and prefab references using
SerializeInterface
and interface proxies - Scene and prefab
Scopes
with different bindings - Global scope usage
- Basic UI and game loop
Gameplay: chase enemies as they evade you. Catch them all to win and restart.
To run it:
- Add the following scenes to Build Settings (in this order):
StartScene
(bootstrap)GameScene
UIScene
- Open
StartScene
- Press Play.
Requires TextMesh Pro essentials for the UI to work.
The demo should be fairly self-explanatory and can be used as a code/setup study to understand how to structure bindings, scopes, and proxies.
Dependency Injection (DI) is a design pattern where objects receive their dependencies from an external source, rather than creating or locating them themselves. Instead of calling new
or searching the scene themselves, an external system supplies the needed objects.
In most DI frameworks like Zenject or VContainer, you declare bindings in code or installers. At runtime, a container resolves and injects all dependencies before or during startup. This provides flexibility, supports unit testing, and allows dynamic setups. However, it introduces complexity and runtime overhead (reflection, allocations), and the wiring can feel less transparent to non-programmers, since it's not always visible in the Inspector.
Saneject flips the model: you still declare bindings in code (via a Scope
), but all bindings are resolved in the Unity Editor. The results are written directly to serialized fields. There's no container or injection step at runtime - Unity loads the scene with references already assigned. This has both benefits and trade-offs compared to runtime DI, outlined below.
Approach | Runtime DI (Zenject, etc.) | Saneject |
---|---|---|
Injection timing | Runtime (container init) | Editor-time (stored as serialized fields) |
Lifecycle | More complex - adds a second lifecycle on top of Unity's | Regular Unity lifecycle (Awake, Start, etc.) |
Performance | Some startup cost (reflection, allocations) | Zero reflection or container at runtime (small runtime lookup if using global dependencies) |
Inspector visibility | Limited - container handles wiring | All dependencies are visible in the Inspector |
Flexibility | High - bindings can change at runtime | Lower - wiring is fixed after injection |
Testing / mocking | Strong - easy to substitute in unit tests | Less ergonomic - requires setting serialized data |
Visual Debuggability | Can be opaque - dynamic graph | Fully deterministic - just look at the fields for missing dependencies |
Plain C# Classes | Full support - constructor injection, POCO creation | No constructor injection and POCO creation. Does support injection into serialized nested POCOs. |
Concept | What it Means in Saneject |
---|---|
Scope component | A MonoBehaviour that declares bindings for how to resolve dependencies in Components below its Transform . |
Root-scope scan | No matter which Scope you start the injection on, Saneject walks up to the top-most Scope first, then injects downward once. |
Resolution fallback | When a binding isn’t found in the current Scope , the injector climbs upward through parent Scopes until it finds one (or fails). |
Scene Scope | Lives on a (non-prefab) scene GameObject . Can bind to any Component or Object in the scene or project folder, including prefabs. |
Prefab Scope | Lives on a prefab. Can bind to any Component or Object in the prefab itself or project folder. Prefab Scopes present in the scene are skipped during scene injection, to keep the prefab self-contained.Need a scene reference inside a prefab? Use an InterfaceProxyObject ScriptableObject and inject that instead. |
Scene vs Prefab Scope | Same Component but the DI system treats them as different contexts. |
An example of how scoped resolution works (code below):
flowchart TD
subgraph Editor
PrefabDI["Prefab injection pass"]
DI["Scene injection pass"]
end
subgraph Scene
PrefabDI -->|Inject| UIScope["UIScope (Prefab)"]
DI -->|Skip injection| UIScope
DI -->|Inject| EnemyAudioService["Enemy.audioService (IAudioService)"]
DI -->|Inject| EnemyAIController["Enemy.aiController (AIController)"]
EnemyAudioService -->|"Try resolve IAudioService → Binding not found"| EnemyScope
EnemyAIController -->|"Try resolve AIController → Binding found"| EnemyScope
EnemyScope -->|"Try resolve IAudioService → Binding found"| RootScope
end
⚠️ Last time I checked, Mermaid diagrams don’t render in the GitHub mobile app. Use a browser to view them properly.
DependencyInjector
(injection passes) first queries EnemyScope
for IAudioService
but a binding isn't defined there, so the request bubbles up to RootScope
which has the binding and provides the Object
.
AIController
is resolved directly from EnemyScope
, so no fallback is needed.
Any scopes that live on prefabs (like UIScope
above) are skipped during a scene-wide injection pass - they get their own dependencies when the prefab is injected in isolation, or you can inject scene objects into a prefab via an InterfaceProxyObject
.
public class RootScope : Scope
{
public override void Configure()
{
RegisterObject<IAudioService>().FromResources("Audio/Service");
}
}
public class EnemyScope : Scope
{
public override void Configure()
{
// Enemy-local AIController only; no IAudioService here.
RegisterComponent<AIController>().FromScopeSelf();
}
}
After injection:
public class Enemy : MonoBehaviour
{
[Inject, SerializeInterface]
IAudioService audioService; // Resolved from RootScope
[Inject]
AIController aiController; // Resolved from EnemyScope
}
ℹ️
Scope
usesHideFlags.DontSaveInBuild
to strip it from builds, to prevent accidental usage at runtime.
To start binding dependencies, create a new custom Scope
and use one of the following Register
methods to start a fluent builder.
Bind Components from scene/prefab hierarchy. Methods return a ComponentBindingBuilder<T>
to define a locate strategy.
Method | Description |
---|---|
RegisterComponent<T>() |
Bind a Component in the scene or prefab by its concrete type. |
RegisterComponent<TInterface, TConcrete>() |
Bind by interface and resolve with TConcrete . |
Bind Project folder assets, e.g., prefabs, ScriptableObjects
. Methods return an ObjectBindingBuilder<T>
to define a locate strategy.
Method | Description |
---|---|
RegisterObject<T>() |
Bind a UnityEngine.Object asset in the Project folder by concrete type. |
RegisterObject<TInterface, TConcrete>() |
Bind by interface and resolve with TConcrete asset. |
Bind cross-scene singletons. Methods return a ComponentBindingBuilder<T>
to define a locate strategy.
Method | Description |
---|---|
RegisterGlobalComponent<T>() |
Promote a scene Component into SceneGlobalContainer .Registered in the global scope at startup for global resolution (e.g., via InterfaceProxyObject . |
RegisterGlobalObject<T>() |
Promote a scene Object into SceneGlobalContainer .Registered in the global scope at startup for global resolution (e.g., via InterfaceProxyObject . |
Methods in ComponentBindingBuilder<T> where T : UnityEngine.Component
. All methods return a FilterableComponentBindingBuilder<T>
to filter found Components
.
Looks for the Component
from the Scope
transform and its hierarchy.
Method | Description |
---|---|
FromScopeSelf() FromSelf() |
Component on the Scope’s own Transform . |
FromScopeParent() FromParent() |
Component on the Scope’s direct parent. |
FromScopeAncestors() FromAncestors() |
First match on any ancestor of the Scope (recursive upward). |
FromScopeFirstChild() FromFirstChild() |
Component on the Scope’s first direct child. |
FromScopeLastChild() FromLastChild() |
Component on the Scope’s last direct child. |
FromScopeChildWithIndex(int index) FromChildWithIndex(int index) |
Component on the child at the given index. |
FromScopeDescendants(bool includeSelf = true) FromDescendants(bool includeSelf = true) |
Any descendant of the Scope. |
FromScopeSiblings() FromSiblings() |
Any sibling of the Scope (other children of the same parent). |
Looks for the Component
from the Scope.transform.root
transform and its hierarchy.
Method | Description |
---|---|
FromRootSelf() |
Component on the scene root object itself. |
FromRootFirstChild() |
Component on the root’s first direct child. |
FromRootLastChild() |
Component on the root’s last direct child. |
FromRootChildWithIndex(int index) |
Component on the root’s child at the given index. |
FromRootDescendants(bool includeSelf = true) |
Any descendant of the root object. |
Looks for the Component
from the injection target transform and its hierarchy. Injection target is the Component
of a field/property marked with [Inject]
, i.e., the Component
requesting injection.
Method | Description |
---|---|
FromTargetSelf() |
Component on the target’s own Transform . |
FromTargetParent() |
Component on the target’s parent. |
FromTargetAncestors() |
First match on any ancestor of the target. |
FromTargetFirstChild() |
Component on the target’s first child. |
FromTargetLastChild() |
Component on the target’s last child. |
FromTargetChildWithIndex(int index) |
Component on the target’s child at index. |
FromTargetDescendants(bool includeSelf = true) |
Any descendant of the target. |
FromTargetSiblings() |
Any sibling of the target. |
Looks for the Component
from the specified Transform
and its hierarchy.
Method | Description |
---|---|
From(Transform target) |
Component on a specific Transform . |
FromParentOf(Transform target) |
Component on the target’s parent. |
FromAncestorsOf(Transform target) |
First match on any ancestor of the target. |
FromFirstChildOf(Transform target) |
Component on the target’s first child. |
FromLastChildOf(Transform target) |
Component on the target’s last child. |
FromChildWithIndexOf(Transform target, int index) |
Component on the target’s child at index. |
FromDescendantsOf(Transform target, bool includeSelf = true) |
Any descendant of the target. |
FromSiblingsOf(Transform target) |
Any sibling of the target. |
Method | Description |
---|---|
FromAnywhereInScene() |
Finds the first matching component anywhere in the loaded scene. |
FromInstance(T instance) |
Binds to an explicit instance. |
FromMethod(Func<T> method) |
Uses a custom factory method to supply the instance. |
WithId(string id) |
Assigns a custom ID for fields marked with [Inject(id: "StringID")] . |
Methods in ObjectBindingBuilder<T> where T : UnityEngine.Object
. All methods return a FilterableObjectBindingBuilder<T>
to filter found Components
.
Method | Description |
---|---|
FromInstance(T instance) |
Bind to an explicit Object instance. |
From(Func<T> factory) |
Use a factory delegate to produce the instance at injection time. |
FromResources(string path) |
Load a single asset from a Resources/ path via Resources.Load . |
FromResourcesAll(string path) |
Load all assets of type T at that path via Resources.LoadAll . |
FromAnywhereInScene() |
Find the first matching object of type T anywhere in the loaded scene. |
FromAssetLoad(string assetPath) |
Load an asset at the given path via AssetDatabase.LoadAssetAtPath<T>() (Editor only). |
WithId(string id) |
Assign a custom binding ID to match [Inject(Id = "...")] fields. |
Methods in FilterableComponentBindingBuilder<T> where T : UnityEngine.Component
. Returns itself to allow chaining multiple filters.
Method | Description |
---|---|
WhereTargetIs<TTarget>() |
Apply binding only when the injection target is of type TTarget . |
WhereTagIs(string tag) |
Include components whose GameObject.tag equals tag . |
WhereNameIs(string name) |
Include components whose GameObject.name equals name . |
WhereNameContains(string substring) |
Include components whose GameObject.name contains substring . |
WhereLayerIs(int layer) |
Include components on the specified layer . |
WhereActiveInHierarchy() |
Include components with active GameObject s. |
WhereInactiveInHierarchy() |
Include components with inactive GameObject s. |
WhereSiblingIndexIs(int index) |
Include components whose transform has the given sibling index. |
WhereIsFirstSibling() |
Include components that are the first sibling. |
WhereIsLastSibling() |
Include components that are the last sibling. |
Where(Func<T,bool> predicate) |
Include components satisfying a custom predicate. |
Methods in FilterableObjectBindingBuilder<T> where T : UnityEngine.Object
. Returns itself to allow chaining multiple filters.
Method | Description |
---|---|
WhereNameIs(string name) |
Include objects whose name equals name . |
WhereNameContains(string substring) |
Include objects whose name contains substring . |
Where(Func<T,bool> predicate) |
Include objects satisfying a custom predicate. |
WhereIsOfType<TO>() |
Include only objects of subtype TO . |
Serializing an interface in the literal sense doesn't make much sense, because an interface is just a type contract, not a tangible object. Unity’s serializer only stores concrete UnityEngine.Object
references, so a field typed as an interface has nothing for the serializer to write and gets skipped completely.
For every [SerializeInterface]
field the generator emits a hidden, serializable backing Object
in a partial class that implements ISerializationCallbackReceiver
. The partial class copies the reference into the real interface field after deserialization:
// User written class.
// Needs to be partial for source generator to extend it with another partial.
public partial class SomeClass : MonoBehaviour
{
[SerializeInterface]
ISomeInterface someInterface;
}
// Auto-generated partial.
// Slightly simplified code for demonstration purposes.
public partial class SomeClass : ISerializationCallbackReceiver
{
[SerializeField, InterfaceBackingField(typeof(someInterface))]
private Object __someInterface; // Real serialized field
public void OnAfterDeserialize()
{
// When Unity deserialization occurs, the Object is assigned to the actual interface field.
someInterface = __someInterface as ISomeInterface;
}
}
In the Inspector this appears as a standard object picker labelled “Some Interface (ISomeInterface)” that be handled manually - or used for dependency injection if you add [Inject]
alongside [SerializeInterface]
. If you plug in an Object
that doesn’t implement the interface, the property drawer rejects it and clears the field.
Formats that label, disables injected fields, and validates the selection.
It also syncs the interface field with the Object
backing field - which isn’t ideal to do in a PropertyDrawer
. But the deserialization assignment only goes one way, so if you assign the interface field directly in code, the backing field would not update and the change wouldn't be reflected in the Inspector. So, having the PropertyDrawer
sync in the other direction was the most practical solution I could come up with that didn't require user boilerplate.
Now, if you assign the interface reference in code, the backing field and Inspector field will reflect the change automatically.
Unity lists fields from generated partials last, which would push every backing field to the bottom of the Inspector.
A custom Inspector SerializeInterfaceInspector
reorders them so each backing field appears directly beneath its corresponding interface field, preserving the original/intended layout.
⚠️ SerializeInterfaceInspector
draws allMonoBehaviour
inspectors, which you may find too intrusive and it may override your own custom inspectors. If that’s the case, you can safely delete it and callSanejectInspectorUtils.DrawAllSerializedFieldsWithCorrectInterfaceOrder(SerializedObject, Object)
inside your own custom inspector instead.
InterfaceProxyObject<T>
is a Roslyn-generated ScriptableObject
that:
- Implements every interface on
T
at compile-time. - Forwards calls, property gets/sets, and event subscriptions to a concrete
T
instance resolved at runtime.
Why? Unity can’t serialize a scene object reference between scenes (or from a scene into a prefab). The proxy asset is serializable, so you assign it in the Editor and at runtime it locates the real instance the first time it’s used.
flowchart TD
Proxy["GameManagerProxy : IGameManager (ScriptableObject)"]
PauseMenuUI["PauseMenuUI (Prefab)"]
subgraph "Scene A"
GameManager["GameManager : IGameManager (Scene instance)"]
end
subgraph "Scene B"
EnemySpawner["EnemySpawner (Scene instance)"]
end
EnemySpawner -->|References IGameManager| Proxy
PauseMenuUI -->|References IGameManager| Proxy
Proxy -->|Forwards calls| GameManager
⚠️ Last time I checked, Mermaid diagrams don’t render in the GitHub mobile app. Use a browser to view them properly.
- Right-click any
MonoScript
that implements one or more interfaces (or code the stub manually as shown below). - Choose Generate Interface Proxy.
- Click Yes on both dialogs (creates the partial class and an asset instance).
Example:
public interface IGameManager
{
bool IsGameOver { get; }
void RestartGame();
}
public class GameManager : MonoBehaviour, IGameManager
{
public bool IsGameOver { get; private set; }
public void RestartGame() { }
}
Generated stub (generated once by editor script):
[GenerateInterfaceProxy]
public partial class GameManagerProxy : InterfaceProxyObject<GameManager> { }
Roslyn-generated proxy forwarding:
public partial class GameManagerProxy : IGameManager
{
public bool IsGameOver
{
get
{
if (!instance) instance = ResolveInstance();
return instance.IsGameOver;
}
}
public void RestartGame()
{
if (!instance) instance = ResolveInstance();
instance.RestartGame();
}
}
Now drag the GameManagerProxy
asset into any [SerializeInterface] IGameManager
field, in any scene or prefab or inject it.
Resolve method | What it does |
---|---|
FromGlobalScope |
Pulls the instance from GlobalScope . Register it via RegisterGlobalComponent / RegisterGlobalObject in a Scope . No reflection, just a dictionary lookup. |
FindInLoadedScenes |
Uses FindFirstObjectByType<T>(FindObjectsInactive.Include) across all loaded scenes. |
FromComponentOnPrefab |
Instantiates the given prefab and returns the component. |
FromNewComponentOnNewGameObject |
Creates a new GameObject and adds the component. |
ManualRegistration |
You call proxy.RegisterInstance(instance) at runtime before the proxy is used. |
The proxy resolves on first access and caches the instance. If the instance becomes null on scene load or otherwise, the proxy will try resolving it again on next access.
Each forwarded call includes a null-check, making it roughly 8x slower than a direct call - but we’re talking nanoseconds per call.
In testing, one million proxy calls in one frame to a trivial method cost ~5 ms on a desktop PC.
If you’re in a very tight loop, extract the real instance via proxy.GetInstanceAs<TConcrete>()
and call it directly.
The GlobalScope
is a static service locator that InterfaceProxyObject
can fetch from at near-zero cost (dictionary lookup).
Use it to register scene objects or assets as cross-scene singletons. The GlobalScope
can only hold one instance per unique type.
Bindings are added via RegisterGlobalComponent<T>()
or RegisterGlobalObject<T>()
inside a Scope
. This stores the binding into a SceneGlobalContainer
component.
At runtime, on Awake()
(with [DefaultExecutionOrder(-10000)]
), the SceneGlobalContainer
adds all its references to the GlobalScope
.
Only one SceneGlobalContainer
is allowed per scene - it's created automatically during scene injection and manual creation is not allowed.
Register global singletons in the Scope
using the following methods.
Method | Description |
---|---|
RegisterGlobalComponent<T>() |
Adds a scene Component to the global scope via SceneGlobalContainer . |
RegisterGlobalObject<T>() |
Adds a scene Object (e.g., ScriptableObject ) to the global scope. |
Method | Description |
---|---|
GlobalScope.Register<T>(T) |
Registers an instance globally. Only one per type. |
GlobalScope.Get<T>() |
Returns the registered instance (or null ). |
GlobalScope.Unregister<T>() |
Removes a registered instance of type T . |
GlobalScope.IsRegistered<T>() |
Returns true if an instance of type T is in the global scope. |
GlobalScope.Clear() |
Clears all global registrations (Play Mode only). |
If you're using InterfaceProxyObject
, global registration is one of the ways to resolve its target instance.
💡 You can toggle logging for global registration under Saneject → User Settings → Logging.
Roslyn tools enhance your compile-time experience using C#'s powerful compiler APIs.
- A Roslyn analyzer inspects code and reports diagnostics (like errors or suggestions).
- A Roslyn code fix provides a quick-fix action for an analyzer error.
- A Roslyn source generator adds new C# code to your project during compilation.
Saneject ships with three Roslyn tool DLLs (in Saneject/RoslynLibs
):
DLL | Type | Purpose |
---|---|---|
SerializeInterfaceGenerator.dll | Source Generator | Generates hidden backing fields and serialization hooks for [SerializeInterface] members. |
InterfaceProxyGenerator.dll | Source Generator | Emits proxy classes that forward interface calls to backing fields (via InterfaceProxyObject<T> ). |
AttributesAnalyzer.dll | Analyzer + CodeFix | Validates field decoration rules for [Inject] , [SerializeField] , and [SerializeInterface] . Includes context-aware quick fixes in supported IDEs (like Rider). |
Unity’s official Roslyn analyzer documentation (including setup instructions):
https://docs.unity3d.com/Manual/roslyn-analyzers.html
Use that guide if you want to plug in custom Roslyn tooling or integrate Saneject’s tools in other project structures.
Roslyn source code is in the RoslynTools folder.
A few helpful touches built into the inspector experience:
- Injected fields are grayed out to show they’re auto-filled at runtime.
- A single
Scope
component handles both scene and prefab injection. Context is detected automatically. - The
Scope
type (Scene, Prefab) is shown in its inspector for clarity. - Interface proxies list which interfaces they implement.
- Help boxes on all Saneject components explain what they do.
- Right-click any
MonoScript
to generate anInterfaceProxyObject
. Scope
usesHideFlags.DontSaveInBuild
so it won’t end up in builds by mistake.- Internal
Scope
methods are hidden from IntelliSense using[EditorBrowsable(EditorBrowsableState.Never)]
. - Provides a
[ReadOnly]
attribute (unrelated to DI) you can use to gray out non-DI fields in the Inspector.
Found under Saneject → User Settings, these let you customize editor and logging behavior:
- Injection Prompts: Ask before scene or prefab injection runs.
- Inspector Tweaks: Toggle visibility of injected fields and help boxes.
- Play Mode Logging: Log when a proxy resolves or global scope changes.
- Editor Logging: Show injection stats, warn about unused bindings, or skipped prefab injection.
- Unity version support: Confirmed working on Unity 6000.0.23f1 LTS and newer. Older versions aren’t currently supported.
- Uses newer APIs like
FindObjectsByType<T>(FindObjectsInactive, FindObjectsSortMode)
, which don’t exist in older versions. - Roslyn source generators didn’t run properly in 2022.2 when tested, despite Unity 2021.1+ claiming support. No deeper investigation has been done yet.
- Preprocessor fallback paths haven’t been added yet.
- Uses newer APIs like
- Platform coverage: so far tested on Windows (Mono + IL2CPP) and Android IL2CPP builds only.
- Proxy-creation menu can be flaky. It relies on
SessionState
keys to survive a domain reload, and occasionally Unity clears them before the follow-up dialog appears. If that happens, the.cs
proxy file is generated but no.asset
is created, just run Generate Interface Proxy again on the script to finish the flow. - Circular dependency detection is not yet implemented.
- Unity's object picker cannot filter by interface types in the Inspector.
- Collection bindings (e.g. injecting
IEnumerable<T>
) is not supported yet. - Unity Package Manager (UPM) support is not yet available. Use
.unitypackage
or clone + copy for now. Planned for post-beta once the API and structure stabilize.
Inspired by:
Contributions are welcome. If you find bugs or have suggestions, feel free to open an issue. PRs are appreciated too. There’s no formal roadmap right now, but I’ll do my best to review what comes in.
MIT License
Copyright (c) 2025 Alexander Larsen
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.