Skip to main content
Every system in SET: 3D Edition — GameSession, SetValidator, NakamaMultiplayerService, every ViewModel — declares the things it needs as constructor parameters. It never reaches out and grabs them. This is Dependency Injection (DI), and it is the mechanism that makes Clean Architecture actually work at runtime rather than just on paper. VContainer is the DI framework the project uses. This page explains why, how registrations work, what the lifetime rules are, and which patterns are permanently off-limits.
The VContainer composition root and all registration code described here are planned for the pre-production implementation phase.

Why Dependency Injection?

Without DI, a class that needs a SetValidator creates one itself:
This makes the class untestable (you cannot swap in a mock) and couples it tightly to one specific implementation. You cannot change how validation works without editing GameSession. With DI:
Now GameSession depends on the abstraction ISetValidator, not any concrete class. In tests you pass a mock. In production the DI container passes the real SetValidator. GameSession itself never changes.

Why VContainer?

VContainer was chosen over alternatives (Zenject, manual DI, service locator) for three reasons:
  1. Constructor injection as the default. VContainer resolves pure C# classes entirely through their constructors, with no magic attributes required. This aligns perfectly with Domain and Application classes that have no Unity dependencies.
  2. Performance. VContainer generates IL at startup rather than using reflection on every resolve call, making runtime overhead negligible.
  3. Explicit registration. All bindings live in one place (the LifetimeScope). There is no ambient service locator — you cannot accidentally resolve something from a random call site.

The Non-Negotiable Rule

Never use FindObjectOfType, GetComponent across objects, or static singleton Instance properties. Every dependency must enter a class through its constructor or a [Inject]-attributed method. If you cannot inject it, the design needs to change — not the rule.

Constructor Injection for Pure C# Classes

Domain and Application classes use plain constructor injection with no Unity or VContainer attributes needed:
VContainer reads the constructor signature and resolves each parameter from its registered bindings automatically. No attribute annotations, no base class, no magic.

The Composition Root: Bootstrap Scene

The composition root is a LifetimeScope MonoBehaviour that lives in the Bootstrap.unity scene. It is the only place in the entire codebase where concrete types are named. Everything outside this file sees only interfaces. Because SET.Presentation cannot reference SET.Infrastructure at compile time (see Assembly Definitions), the Bootstrap LifetimeScope lives in a dedicated SET.Bootstrap assembly (or a subfolder assembly that explicitly references both SET.Presentation and SET.Infrastructure). This is the single controlled seam where infrastructure types are permitted to be named.
The Bootstrap scene is loaded first and persists for the entire session. Other scenes (MainMenu, GameBoard) are loaded additively and inherit the container.

Registered Service Interfaces

The composition root binds all cross-layer interfaces. The following are the key planned registrations and their signatures: IMultiplayerService — bound to NakamaMultiplayerService:
ILeaderboardService — bound to NakamaLeaderboardService:
ILocalSaveService — bound to LocalSaveService:
IAudioService — bound to AudioService:
IViewPresenter — bound to ScreenPresenter in Presentation:

Lifetime Rules

Choosing the wrong lifetime is a common source of subtle bugs. Use this table as a guide: Rules of thumb:
  • Stateless services with no mutable fields → Singleton.
  • Objects that own match state and must be shared across multiple consumers within a match → Scoped.
  • Objects that should not share state between consumers → Transient.
Do not register a Transient service into a Singleton. The singleton will capture a single transient instance at construction time and hold it forever, effectively making it a singleton anyway. This is called a captive dependency and causes stale state bugs.

Injecting into MonoBehaviours

MonoBehaviours cannot receive constructor injection because Unity controls their instantiation lifecycle. Instead, VContainer supports a [Inject]-attributed method, conventionally named Construct:
VContainer calls Construct after instantiation but before Start, so by the time Unity calls Start all dependencies are already set. Never use Awake to access injected fields — injection may not have occurred yet.

How Views Get Their ViewModels

The flow from container registration to a running View looks like this:

Testing With DI: No Container Needed

One of the biggest benefits of constructor injection is that you never need the DI container in unit tests. Simply instantiate the class directly and pass in mocks:
No LifetimeScope, no GameObject, no Play Mode required. Because Domain and Application classes have no Unity dependencies, the test runs in milliseconds inside the Unity Test Runner’s EditMode runner.

Implementation Checklist

Use this checklist when adding a new service or registering a new type with VContainer:
  • New service interface declared in SET.Application (never in SET.Infrastructure or SET.Presentation)
  • Concrete implementation placed in the correct layer (SET.Infrastructure for external adapters, SET.Presentation for Unity-dependent presenters)
  • Binding added to GameLifetimeScope.Configure() — the only file where concrete types are named
  • Correct Lifetime chosen: Singleton for stateless, Scoped for per-match state, Transient for independent-state objects
  • No captive dependencies (no Transient injected into Singleton)
  • MonoBehaviour views use [Inject] method named Construct, not constructor injection
  • Injected fields accessed only in Start (or later), never in Awake
  • Unit tests construct classes directly without the DI container
  • No FindObjectOfType, GetComponent across objects, or static Instance anywhere in the codebase

Banned Patterns and Their Replacements


Common Mistakes

Forgetting [Inject] on a MonoBehaviour method. VContainer will not call the method, the field stays null, and you get a NullReferenceException in Start. Always verify that the method has the attribute. Resolving from the wrong scope. If MatchViewModel is registered as Scoped but you resolve it from the root scope (which has Singleton-equivalent lifetime), you get a captive dependency. Create child scopes for per-match objects. Calling injected fields in Awake. VContainer runs injection after Unity calls Awake but before Start. Access injected dependencies only in Start or later, or in the Construct method itself. Registering both an interface and its implementation separately with different lifetimes. For example, registering GameSession as Transient and IMatchOrchestrator as Singleton pointing to GameSession will create two separate instances. Use .As<>() chaining on a single registration to ensure all interfaces resolve the same object.

Assembly Definitions

How asmdefs enforce the compile-time boundary that makes this DI pattern safe.

Reactive UI with R3

How ViewModels subscribe to GameSession observables and drive UI updates.

Clean Architecture Layers

The full responsibilities of each layer and what belongs where.

Engineering Standards & Patterns

The complete banned-pattern catalogue and code review checklist.