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 aSetValidator creates one itself:
GameSession.
With DI:
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:- 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.
- Performance. VContainer generates IL at startup rather than using reflection on every resolve call, making runtime overhead negligible.
- 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
Constructor Injection for Pure C# Classes
Domain and Application classes use plain constructor injection with no Unity or VContainer attributes needed:The Composition Root: Bootstrap Scene
The composition root is aLifetimeScope 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.
Injecting into MonoBehaviours
MonoBehaviours cannot receive constructor injection because Unity controls their instantiation lifecycle. Instead, VContainer supports a[Inject]-attributed method, conventionally named Construct:
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: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 inSET.InfrastructureorSET.Presentation) - Concrete implementation placed in the correct layer (
SET.Infrastructurefor external adapters,SET.Presentationfor Unity-dependent presenters) - Binding added to
GameLifetimeScope.Configure()— the only file where concrete types are named - Correct
Lifetimechosen:Singletonfor stateless,Scopedfor per-match state,Transientfor independent-state objects - No captive dependencies (no
Transientinjected intoSingleton) - MonoBehaviour views use
[Inject]method namedConstruct, not constructor injection - Injected fields accessed only in
Start(or later), never inAwake - Unit tests construct classes directly without the DI container
- No
FindObjectOfType,GetComponentacross objects, orstatic Instanceanywhere 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.
Related Pages
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.