All project posts

Saneject

Editor-time dependency injection for Unity that resolves bindings in the Editor, writes them into serialized fields – including interfaces – and ships zero runtime container or reflection.

Saneject logo with tagline { Unity.EditorTime.DI; } over a dark circuit board background with glowing lines.

Introduction

  • Overview: Saneject is a middle-ground between hand-wired references and heavyweight runtime DI. You declare bindings in code using scopes, resolve them in the Editor, and Saneject writes the results straight into serialized fields – including interfaces. At runtime you get plain Unity objects with normal Awake and Start order. No container, no reflection, no startup hit.
  • Motivation: While working on Tails (a VR Snake-like game), I noticed how much my artist collaborator appreciated being able to see exactly how everything was wired in the Inspector in serialized fields. Having worked with DI-heavy codebases extensively before myself, I missed the structure, declarative bindings, the simplicity of classes not managing their own dependencies, as well as not having to plug in things manually in the Inspector. I built a small prototype of an editor-time DI approach and quickly saw how naturally the pattern fit Unity’s workflows, so I decided to spin it up into a full, production-ready system.

Key features

  • Unity 2022.3.12f1+ support: Fully functional from this version onward, the first Unity release where both Roslyn source generators and analyzers work reliably.
  • Editor-time injection: Fields marked [Inject] are populated in the Editor and serialized into the scene or prefab. Nested serializable classes are supported.
  • Fluent, scope-aware bindings: Bind by interface or concrete type, with locators like FromTargetSelf, FromAncestors, FromAnywhereInScene, and project asset locators like FromResources.
  • Powerful filters: Narrow down matches with filters for name, tag, layer, enabled state, sibling index, hierarchy position, or custom predicates — all chainable for precise control.
  • Collections first-class: Inject arrays or lists with the same filters and binding IDs you use for single types. Collections and singles are validated so mismatches are caught early.
  • Serialize interfaces in the Inspector: [SerializeInterface] turns interface fields into visible, assignable slots by generating backing Object fields and sync hooks.
  • Prefab and cross-scene references: InterfaceProxyObject<T> is a serializable proxy asset that forwards interface calls to a real runtime instance. Works with a few resolve strategies including GlobalScope.
  • GlobalScope for singletons: Promote scene components to cross-scene singletons with BindGlobal<T> and fetch them with dictionary lookups at runtime.
  • Inspector polish: A global MonoBehaviourFallbackInspector draws fields in declaration order and renders injected fields read-only. You can easily override it and call SanejectInspector directly from your own editor.
  • Non-blocking validation: Injection runs a full pass and reports all missing or conflicting bindings in one go. You decide what to fix next – iteration stays fast.
  • One-click injection: Buttons in the Scope inspector, hierarchy context menu, and main menu handle scene and prefab passes. Prefab scopes are skipped during scene passes on purpose.
  • User settings: Toggle injected field visibility, confirm dialogs, and logging for injection stats, skipped prefabs, proxy resolves, and global register events.
  • 225 automated CI unit tests: Runs on GitHub Actions across 6 Unity versions, covering minimum supported version, oldest/newest of each supported major version, and latest 6.1 and 6.2 releases.
  • UPM support: Install the latest version of Saneject in your Unity project by adding git URL in the Unity Package Manager: https://github.com/alexanderlarsen/Saneject.git?path=UnityProject/Saneject/Assets/Plugins/Saneject

Key challenges & solutions

1. Interfaces do not serialize in Unity

  • Challenge: Unity’s serializer only stores concrete Object references. Interface fields are invisible and get dropped.
  • Solution: A Roslyn source generator emits hidden backing Object fields and an ISerializationCallbackReceiver that syncs the interface to and from the backing field. Arrays and lists of interfaces are supported. In the Editor, the interface value mirrors into the backing field so the Inspector always shows accurate data.

2. DI structure without a runtime container

  • Challenge: Typical DI setups build graphs at runtime using reflection and allocation – flexible, but opaque and not free.
  • Solution: Resolve completely in the Editor. Scope bindings populate [Inject] fields and serialize them. Play mode runs on Unity’s default lifecycle with no extra bootstrapping. Where a proxy is used, the first call resolves and caches the instance.

3. Scoping that matches scene and prefab reality

  • Challenge: Prefabs need to be self-contained because they can’t serialize references to scene objects.
  • Solution: A single Scope component works for both scenes and prefabs, but the dependency injector auto-detects the context, so prefab scopes are skipped during scene injection, even if the prefab is currently present in the scene. This keeps prefabs self-contained while scenes can still resolve dependencies from their own scope or from globals.

4. Binding ergonomics and determinism

  • Challenge: Binding APIs need expressive filters without introducing ambiguity.
  • Solution: Builders expose precise locators and filter chains – by name, tag, layer, enabled state, hierarchy position, target type, and IDs. Saneject enforces binding uniqueness by key (type + id + single vs collection + global flag + target filter) and reports duplicates early.

5. Cross-scene and prefab references

  • Challenge: Unity cannot serialize scene references into prefabs or across scenes, preventing cross-context dependencies by default.
  • Solution: InterfaceProxyObject<T> is a Roslyn-generated ScriptableObject that implements all interfaces from T and forwards to a resolved instance. You can resolve from GlobalScope, search loaded scenes, spawn from a prefab, or register manually. Calls include a cached null check and are trivial in cost. The InterfaceProxyObject<T> can be injected and assigned to any serialized field — including [SerializeInterface] fields — acting as a loose reference until it resolves its concrete target cheaply at runtime.

6. Inspector ordering and compatibility

  • Challenge: Because generated interface backing fields exist in a partial class, the sink to the bottom of the Inspector, breaking the mental map of Inspector showing declaration order.
  • Solution: A global MonoBehaviourFallbackInspector and SanejectInspector (editor API) draw all serializable members in declaration order, and interface backing fields go to the position of the real interface. MonoBehaviourFallbackInspector is marked as fallback editor, so it doesn’t conflict with other editors. The SanejectInspector also has other responsibilities such as graying out injected fields and collections. SanejectInspector is built as a modular API, so it can easily be integrated into the user’s own Inspectors.

7. Keeping iteration tight with sane diagnostics

  • Challenge: Runtime DI often halts on the first missing dependency, leaving the rest of the graph unresolved. This forces a slow fix–validate–fix loop.
  • Solution: The Saneject injector validates the entire scene or prefab in a single, non-blocking pass and logs a complete summary: scopes processed, fields injected, globals added, unused bindings, and conflicts. Optional logs also note when prefab scopes are skipped during scene injection, making the behavior explicit. You get a full list of issues to address in one run, instead of stopping to fix them one by one.

8. Game demo

  • Challenge: Without a working scene, DI examples can be difficult to relate to real Unity workflows.
  • Solution: A three-scene game demo demonstrates practical use in a simple game, covering scopes for scenes and prefabs, serialized interfaces, cross-scene proxies, with a basic game loop.

9. Cross-version reliability without tedious manual testing

  • Challenge: Ensuring Saneject works across multiple Unity versions is critical, but manually testing each version is slow and error-prone.
  • Solution: Wrote 225 automated unit tests covering the API surface, and set up GitHub Actions with GameCI to run them automatically on pull requests against six Unity versions: the minimum supported (2022.3.12f1), oldest and newest of each major supported version (excluding Unity 2023 tech stream/beta), plus the latest Unity 6.1 and 6.2.

Impact

Saneject is in public beta on GitHub and has attracted a bit of early interest, with 16 stars at the time of writing and a few positive comments across community platforms. While direct usage feedback hasn’t come in yet, the attention suggests curiosity from developers looking for a middle ground between manual wiring and heavy runtime DI frameworks.

Future development

Planned improvements toward 1.0.0 include:

  • Wider platform verification: iOS, WebGL, and consoles tested end-to-end.
  • More reliable proxy editor tools: InterfaceProxyObject creation menu tool uses EditorPrefs to store creation state across domain reload. This can sometimes be unreliable and needs a more robust implementation.

More project posts

Hire a senior Unity developer for XR, games & apps

9+ years experience building scalable, performant Unity solutions, XR, games, enterprise apps, prototypes, custom tools, and complex systems.

Available for freelance, consulting, or full project development. I reply within 1 business day.

Get in touch