Saneject
Saneject is an open source Unity DI framework that resolves dependencies in the Unity Editor instead of Play Mode and writes them directly into serialized fields so they stay visible in the Inspector, including interfaces. No runtime container, no startup cost, no hidden wiring, and no extra lifecycle on top of Unity.













Introduction
- Overview: Saneject is an editor-time dependency injection and wiring framework for Unity. The core idea is simple: declare bindings in
Scopecomponents, run injection in the Editor, and let the framework write resolved dependencies directly into serialized members in scenes and prefabs before Play Mode starts. In practice, that grew into a fairly deep system with scoped bindings, context-aware scene and prefab handling, interface serialization through Roslyn-generated code, runtime proxy bridging for the cases Unity cannot serialize directly, custom inspectors, batch tooling, analyzers, and a full documentation surface. The common thread through all of it is that the runtime stays as close as possible to normal Unity. Most dependencies are already serialized by the time the game starts, so there is no runtime container rebuilding graphs or owning a second object lifecycle. - Motivation: I started Saneject because I wanted the structure of DI in my own game, but one of the first reactions from my friend and collaborator was basically, “I do not want a framework that hides everything from the Inspector.” That stuck with me, not because Inspector visibility itself was the main issue for me, but because it reminded me of how often runtime DI in Unity ends up fighting the engine’s normal way of doing things with second lifecycles, initialization order concerns, and all the framework ceremony before the game can even start. I like the way DI makes dependencies structured and explicit in code, keeps classes focused, and avoids a lot of
GetComponentand manual drag and drop, but I also think Unity workflows tend to get worse when getting that structure means working around the engine’s normal serialized, component-driven model and default lifecycle. So I started prototyping an editor-time approach that injects into serialized fields instead. The prototype worked well enough to prove the idea almost immediately. The hard part was everything that came after: making it hold up across scenes and prefabs, making interfaces feel native, keeping bindings deterministic, building tooling around it, and eventually admitting that the first implementation had to be rewritten after 6 months of work if I wanted the project to be solid.
Key features
- Editor-time, deterministic wiring: Saneject resolves dependencies in Edit Mode and writes the results into serialized members before runtime. In the common case, what you see in the Inspector is already the runtime result, which keeps startup light and avoids a runtime container initialization or reflection-driven startup pass for ordinary wiring.
- Scoped binding model with simple fallback: Bindings are declared in
Scope.DeclareBindings(), and resolution walks from the nearest reachable scope upward until it finds a match. This allows local overrides while still letting shared bindings live higher in the hierarchy. - Graph-based injection pipeline: The current system builds an injection graph for the selected hierarchies first, then filters the active set, validates bindings, resolves dependencies, injects values, prepares runtime handoff data, and logs errors, all in distinct phases. That separation made the framework much easier to evolve and debug once it grew beyond a prototype.
- Multiple binding families: Saneject supports component bindings, asset bindings, global bindings, and runtime proxy bindings. Dependencies can come from hierarchy traversal, scene-wide search,
Resources, project folders and paths, explicit instances,GlobalScope, or runtime-created objects depending on the binding type and locator strategy. - Precise qualifiers and filters:
ToID,ToTarget,ToMember, plus chainableWhere...filters, make it possible to be specific without dropping into one-off custom logic. The same general model works whether binding a single dependency or narrowing down a collection. - Collections as first-class injection targets: Arrays and
List<T>are treated as part of the normal API rather than as a special case. Collections follow the same overall matching and resolution model as single-value injection. - Field, property, method, and nested object injection:
[Inject]works on fields,[field: Inject]auto-property backing fields,[Inject]methods, and nested[Serializable]class instances inside a component. This keeps nested serialized data and setup methods inside the same injection model. - Serialized interfaces that stay visible in the Inspector:
[SerializeInterface]uses Roslyn-generated backing members and sync hooks so interface fields, arrays, lists, and auto-properties can participate in Unity serialization. This keeps interface-based code compatible with Unity serialization and Inspector workflows without wrapper classes. - Context-aware scenes and prefabs: Scene objects, prefab instances, and prefab assets are handled as different contexts during injection. Context filtering and optional context isolation support focused runs and help prevent accidental cross-context wiring that would make prefabs harder to reuse.
- A small runtime bridge: For the cases Unity cannot serialize directly, Saneject uses
GlobalScopeandRuntimeProxyas a narrow bridge rather than turning runtime into a full DI container. Runtime remains limited to early global registration and proxy swapping before normal Unity lifecycle takes over. - Tooling built around native Unity workflows: Injection is exposed through toolbar buttons, scene and hierarchy context menus, prefab mode commands, and selected-asset batch commands. These entry points keep injection close to normal Unity authoring and validation workflows.
- Batch Injector for project-wide passes: The Batch Injector supports repeatable scene and prefab injection with persistent asset lists, per-asset context filters, per-asset injection status, sorting, selection-based runs, and consolidated batch summaries. This makes wider validation passes practical once the project grows beyond a handful of scenes and prefabs.
- Custom inspectors and settings: The custom
MonoBehaviourinspector restores logical member ordering for generated interface-backed fields, keeps injected members read-only, and works with nested serialized classes. The package also includes scope and runtime proxy inspectors, project and user settings, and console filtering helpers, all designed to feel native to the Unity Editor. - Validation, logging, and maintenance tools: Saneject validates bindings before resolution, finishes the run instead of failing on the first issue, and logs invalid bindings, missing bindings, missing dependencies, unused bindings, and per-run or batch summaries. It also includes proxy generation and cleanup tools for managing generated proxy scripts and assets.
- Roslyn analyzers and code fixes: The framework ships analyzers for common mistakes like invalid
[Inject]serialization setup or incorrect[SerializeInterface]usage. These diagnostics shift common errors into code analysis instead of injection-time failures. - Docs, sample game, and CI coverage: Saneject has a full docs site, a sample game that exercises scopes, globals, runtime proxies, and runtime-created services, and automated testing across supported Unity version lines from
2022.3.12f1upward. Together, these provide reference material, working examples, and verification across supported versions.
Key challenges & solutions
1. The first implementation had no clean separation of concerns
- Challenge: The original version did work, and that is what made it tricky. It proved the idea, but the core of it was basically one large injection loop that traversed the scene and tried to do everything at once. Validation, candidate lookup, resolution, field assignment, and runtime preparation were all intertwined, and there was no graph model underneath it. As soon as more features started depending on the same flow, the code became harder and harder to change without touching several unrelated concerns at once.
- Solution: I rewrote the core around an explicit graph-based pipeline with distinct stages. First build the structural model, then decide what is active for the run, validate bindings, resolve dependencies, inject values, and finally prepare the small amount of runtime handoff data if needed. That did not just clean up the implementation. It gave the whole framework a clearer model, which made later features easier to add without turning the core into another pile of special cases.
2. Unity’s boundaries are not one search space
- Challenge: My early mental model was too flat. In hierarchy terms, a scene object, a prefab instance, and a prefab asset can all feel close to one another, but Unity does not treat them as one shared serialized world. If an editor-time injector ignores that, it becomes very easy to create dependencies that might technically work while authoring but will break at runtime or make prefabs less portable and harder to reason about later.
- Solution: Saneject now has an explicit context model and two separate controls around it: context filtering and context isolation. Filtering decides which parts of the hierarchy participate in a run, while isolation decides whether scope traversal and candidate selection are allowed to cross boundaries during resolution. Splitting those concepts apart made the behavior much more predictable and let me support focused scene or prefab runs without quietly weakening prefab boundaries.
3. Interfaces had to feel native, not like a workaround
- Challenge: Interface-based code is one of the core benefits of using DI, so it had to work properly in Saneject. Unity does not serialize interface members by default, which was a real blocker rather than a small gap in the feature set. If the answer had been “just use wrappers” or “you cannot really see interface dependencies in the Inspector,” then the whole value proposition would have felt compromised. The problem also turned out to be broader than a single interface field. Arrays, lists, auto-properties, Inspector assignment, and runtime proxy swap all had to work together.
- Solution: I pushed that bridge into Roslyn-generated code.
[SerializeInterface]now generates hidden backing members, sync hooks, and proxy swap methods so interface members can behave much closer to normal serialized data from the user’s point of view. Combined with a custom inspector, that makes interface-heavy code feel like a supported part of the workflow.
4. The binding API needed power without ambiguity
- Challenge: I wanted bindings to cover real Unity use cases: components, assets, collections, target-specific wiring, cross-context runtime bridges, and filtered searches through hierarchies. That kind of flexibility gets messy fast if every feature introduces another fuzzy matching rule. The danger was ending up with an API that looked powerful but became hard to trust, because it was no longer obvious why a given member resolved from one binding instead of another.
- Solution: The binding system is intentionally strict. Matching is based on requested type, single versus collection shape, and explicit qualifiers such as target type, member name, and ID. Binding families have clear responsibilities, invalid declarations are rejected before resolution, duplicates are excluded, and scope fallback stays simple: the nearest upwards matching scope wins. That made the API more opinionated, but also much easier to reason about once projects get larger.
5. Top-level field injection was not enough
- Challenge: In real Unity code, dependencies are not confined to a few neat serialized fields on a
MonoBehaviour. They can live in nested[Serializable]objects, auto-properties, and setup methods, including cases where values need to be injected into native Unity or third party components you can’t add[Inject]to, rather than just stored on a field. If Saneject only supported simple top-level field injection, it would have covered only the cleanest cases and broken down quickly in real project code. - Solution: Saneject’s injection model was expanded to cover
[Inject]fields,[field: Inject]auto-property backing fields,[Inject]methods, and nested[Serializable]class instances inside a component. The execution order is deliberate as well: globals first, then fields and properties, then methods last, so method-based setup runs after ordinary dependencies have already been assigned.
6. The runtime bridge had to stay narrow
- Challenge: Some dependencies simply cannot be serialized directly by Unity, especially across scene and prefab boundaries. Every time one of those cases showed up, the tempting answer was to make runtime smarter and let it solve more of the framework dynamically. But if I kept going in that direction, Saneject would slowly drift toward the same kind of runtime container I was trying to avoid in the first place.
- Solution: I drew a harder line around what runtime is allowed to do.
BindGlobal<T>()resolves a component during edit-time injection and serializes it onto the local declaringScope. At runtime, that scope registers the stored component intoGlobalScope, which is essentially a static service locator used by runtime proxies and direct runtime access when needed.RuntimeProxyis a generatedScriptableObjectplaceholder that can be injected in place of a real interface dependency when the real object lives in another context. The proxy stores how to resolve the real instance at runtime, and duringScope.Awake()the nearest scope swaps known proxy placeholders for their resolved real instances. Runtime still does not rebuild the injection graph, re-run validation, or reinterpret normal scope resolution. It only activates precomputed state and handles the cases Unity cannot serialize directly.
7. Validation and diagnostics had to scale with the project
- Challenge: A fail-fast injector sounds clean until a scene or prefab setup gets large enough that you are fixing one missing dependency per run. Runtime DI often has this problem by design, because if a dependency is missing while the object graph is being composed at startup, composition stops at the first failure and prevent the rest of the graph from being built. In a larger project, that turns validation into a slow loop where each run only reveals the next blocker instead of every error at once.
- Solution: Saneject validates bindings before any dependency lookup starts, but an error does not stop the injection run. It continues through the full pass, injects everything it can, records errors for everything it cannot, and logs the complete result at the end. Missing bindings, missing dependencies, invalid bindings, unused bindings, suppressed errors, proxy creation, and summary counts are all reported together. This plays well with batch injection. An entire project can be injected and all errors can be surfaced in one go, with logs scoped to each scene and prefab asset.
8. It had to feel trustworthy in a real game project
Challenge: The core injection system could work and the framework could still feel unfinished if everything around it was weak. Tooling, documentation, examples, and overall usability all shape how people judge whether something is safe to use. If setup is unclear or the workflow feels inconsistent, teams hesitate to rely on it in production.
Solution: I approached that as part of the engineering work, not something to fix later. UPM makes installation straightforward. A proper docs site and a sample game make the intended workflow clear. The tooling is designed to feel native to Unity and stay out of the way, so working with the framework feels natural instead of forced. CI tests across supported Unity versions ensures that systems work as expected. The result is a framework that is stable, does what it is designed to do, and something I feel confident maintaining over time.
Where it landed
Saneject started as a narrow answer to a specific frustration in my own Unity workflow, but it has turned into a much more complete project than that. The original goal was to keep the structural benefits of DI without inheriting so much of the runtime machinery that tends to fight Unity’s normal way of doing things. That still feels like the core result of the project. Saneject lets me work with scopes, explicit dependencies, interfaces, and cleaner object relationships while still leaning on serialized data, normal Inspector workflows, and Unity’s own lifecycle instead of layering a second one on top.
At this point Saneject is not just an injector that writes fields correctly. It has its own binding model, editor pipeline, generated-code layer, runtime bridge, inspectors, tooling, analyzers, documentation, sample game, and test matrix. The project now feels coherent from API to Inspector to docs, which was important to me because a framework like this only becomes useful once the surrounding experience is strong enough to support the core idea.
It has also started to get a bit of attention on GitHub, with 63 stars and regular clones, which at least suggests that the problem it is trying to solve resonates with other Unity developers too.
The more important change has been that the project is now structurally sound. The first version proved the idea, but the current one has a much stronger foundation for future work.