15 minutes
Combining Reflex DI with Factories: Runtime Creation Without Container Soup
Factories and dependency injection solve different parts of the same mess.
Dependency injection answers: where do stable collaborators come from? A combat director needs a random source, a telemetry sink, a damage rules service, and maybe a catalog of room definitions. It should receive those things instead of finding them through globals.
Factories answer: how do runtime objects get created after the graph already exists? A dungeon room needs enemies, pickups, traps, doors, VFX, and UI prompts. Those objects are not all created at boot. They appear later, usually because the player entered a room, a wave advanced, or a designer-authored definition asked for them.
The mistake is treating one tool like it replaces the other. A DI container should not become the thing every gameplay class asks whenever it wants a goblin. A factory should not rebuild half the service graph by hand. Used together, they give a cleaner split: Reflex wires the long-lived dependencies at the scene boundary, and factories create short-lived runtime objects from explicit requests.
This article uses one context the whole way through: a small dungeon room system. A room definition says which encounter archetype to run. The room runtime creates a matching family of systems: enemy spawns, loot, traps, and room UI. Reflex provides the scene container. A factory keeps room creation policy out of the gameplay loop. An abstract factory handles the cases where a room mode needs a consistent family of related objects.
The context: dungeon rooms, not loose examples
Imagine a top-down action RPG with procedural dungeon rooms. Each room is authored as data:
public enum RoomArchetype
{
Skirmish,
Treasure,
TrapGauntlet,
Boss
}
[CreateAssetMenu(menuName = "Dungeon/Room Definition")]
public sealed class RoomDefinition : ScriptableObject
{
public RoomArchetype Archetype;
public EnemyPackId EnemyPack;
public LootTableId LootTable;
public TrapLayoutId TrapLayout;
public int ThreatLevel;
}
When the player enters a room, the game needs to build a runtime object that can manage that room. That runtime object may spawn enemies, start music, create rewards, arm traps, update the HUD, and report analytics.
Bad code usually starts like this:
public sealed class DungeonRoomController : MonoBehaviour
{
[SerializeField] private RoomDefinition definition;
private void Start()
{
var enemyCatalog = EnemyCatalog.Instance;
var lootCatalog = LootCatalog.Instance;
var audio = AudioManager.Instance;
var rng = GlobalRandom.Instance;
// Then a pile of branching creation code begins.
}
}
This looks fast to write. It is not fast to maintain. The room controller now knows where every service lives, how every runtime object is created, and which branches belong to each room archetype. It will keep growing until nobody can tell whether they are changing room rules, enemy construction, trap construction, or UI setup.
The better split is boring:
- Reflex wires stable services and factories into the scene container.
DungeonRoomControllerreceives one room factory.- The room factory creates a room runtime from a
RoomDefinition. - The runtime owns room behavior after creation.
The controller becomes a Unity boundary, not a service locator with a transform.
Reflex is the wiring edge
Reflex is a minimal dependency injection framework for Unity. The important pieces for this discussion are enough to keep straight:
ContainerBuilderregisters values, types, and factories.Lifetime.Singleton,Lifetime.Scoped, andLifetime.Transientcontrol reuse.Resolution.LazyandResolution.Eagercontrol when non-transient bindings are created.ContainerScopecan install scene bindings throughIInstallercomponents.[Inject]can inject fields, properties, or methods on Unity-facing objects.Container.Construct<T>()can construct plain C# classes with constructor injection.
The exact API may evolve, but the architectural rule should not: Reflex belongs at the composition boundary. It should build the object graph. It should not become a random object vending machine called from every enemy, pickup, and projectile.
A simple scene installer for the dungeon might look like this:
using Reflex.Core;
using Reflex.Enums;
public sealed class DungeonInstaller : MonoBehaviour, IInstaller
{
[SerializeField] private EnemyCatalog enemyCatalog;
[SerializeField] private LootCatalog lootCatalog;
[SerializeField] private TrapCatalog trapCatalog;
[SerializeField] private DungeonHudView hudView;
public void InstallBindings(ContainerBuilder builder)
{
builder.RegisterValue(enemyCatalog, new[] { typeof(EnemyCatalog) });
builder.RegisterValue(lootCatalog, new[] { typeof(LootCatalog) });
builder.RegisterValue(trapCatalog, new[] { typeof(TrapCatalog) });
builder.RegisterValue(hudView, new[] { typeof(DungeonHudView) });
builder.RegisterType(
typeof(UnityRandom),
new[] { typeof(IRandom) },
Lifetime.Singleton,
Resolution.Lazy);
builder.RegisterType(
typeof(UnityDungeonAudio),
new[] { typeof(IDungeonAudio) },
Lifetime.Singleton,
Resolution.Lazy);
builder.RegisterType(
typeof(PrefabEnemyFactory),
new[] { typeof(IEnemyFactory) },
Lifetime.Scoped,
Resolution.Lazy);
builder.RegisterType(
typeof(PrefabLootFactory),
new[] { typeof(ILootFactory) },
Lifetime.Scoped,
Resolution.Lazy);
builder.RegisterType(
typeof(PrefabTrapFactory),
new[] { typeof(ITrapFactory) },
Lifetime.Scoped,
Resolution.Lazy);
builder.RegisterType(
typeof(DungeonRoomFactory),
new[] { typeof(IDungeonRoomFactory) },
Lifetime.Scoped,
Resolution.Lazy);
}
}
That is not the only valid Reflex style. Some teams prefer RegisterFactory for bindings that need custom setup. Some wrap common registration calls in tiny helpers. The important part is not the exact registration ceremony. The important part is that the installer describes the scene graph in one place.
The room controller does not know any of this.
public sealed class DungeonRoomController : MonoBehaviour
{
[SerializeField] private RoomDefinition definition;
private IDungeonRoomFactory roomFactory = null!;
private DungeonRoomRuntime? runtime;
[Inject]
public void Construct(IDungeonRoomFactory roomFactory)
{
this.roomFactory = roomFactory;
}
private void OnEnable()
{
runtime = roomFactory.Create(definition, transform);
runtime.Enter();
}
private void OnDisable()
{
runtime?.Dispose();
runtime = null;
}
}
The MonoBehaviour gets one dependency. That dependency says exactly what this object needs: a way to create room runtime objects. Not the whole container. Not every catalog. Not every service in the project.
The first factory: create one room runtime
The room factory converts data plus runtime context into a runtime object.
public interface IDungeonRoomFactory
{
DungeonRoomRuntime Create(RoomDefinition definition, Transform roomRoot);
}
The request is small on purpose. The factory receives the room definition and a root transform for spawned objects. Stable collaborators come from DI. Runtime values come from the method call.
public sealed class DungeonRoomFactory : IDungeonRoomFactory
{
private readonly IRoomArchetypeFactorySelector archetypes;
private readonly IRandom random;
private readonly IDungeonAudio audio;
public DungeonRoomFactory(
IRoomArchetypeFactorySelector archetypes,
IRandom random,
IDungeonAudio audio)
{
this.archetypes = archetypes;
this.random = random;
this.audio = audio;
}
public DungeonRoomRuntime Create(RoomDefinition definition, Transform roomRoot)
{
if (definition == null)
throw new ArgumentNullException(nameof(definition));
var family = archetypes.Select(definition.Archetype);
var encounter = family.CreateEncounter(definition, roomRoot);
var rewards = family.CreateRewards(definition, roomRoot);
var hazards = family.CreateHazards(definition, roomRoot);
var hud = family.CreateHud(definition);
return new DungeonRoomRuntime(
definition,
encounter,
rewards,
hazards,
hud,
random,
audio);
}
}
This is where DI and factories meet cleanly.
The constructor contains dependencies that are stable for the lifetime of the factory: the archetype selector, random service, and audio service. The Create method contains values that change per room: the definition and root transform.
Do not pass RoomDefinition through the container. Do not register the current room root in Reflex just so another class can resolve it. That would turn per-room data into ambient state. The room definition is not a service. It is a request.
The abstract factory: one room archetype, one product family
The previous article covered the reason abstract factories exist: they create related objects that must agree with each other. A dungeon room archetype is a useful example because a boss room, treasure room, and trap gauntlet do not just vary one object. They vary a family of related runtime pieces.
public interface IRoomArchetypeFactory
{
RoomArchetype Archetype { get; }
IEncounterRuntime CreateEncounter(RoomDefinition definition, Transform root);
IRewardRuntime CreateRewards(RoomDefinition definition, Transform root);
IHazardRuntime CreateHazards(RoomDefinition definition, Transform root);
IRoomHudPresenter CreateHud(RoomDefinition definition);
}
A skirmish room and a treasure room can now produce different but consistent families.
public sealed class SkirmishRoomFactory : IRoomArchetypeFactory
{
private readonly IEnemyFactory enemies;
private readonly ILootFactory loot;
private readonly ITrapFactory traps;
private readonly DungeonHudView hudView;
public RoomArchetype Archetype => RoomArchetype.Skirmish;
public SkirmishRoomFactory(
IEnemyFactory enemies,
ILootFactory loot,
ITrapFactory traps,
DungeonHudView hudView)
{
this.enemies = enemies;
this.loot = loot;
this.traps = traps;
this.hudView = hudView;
}
public IEncounterRuntime CreateEncounter(RoomDefinition definition, Transform root)
{
return new WaveEncounterRuntime(enemies, definition.EnemyPack, root);
}
public IRewardRuntime CreateRewards(RoomDefinition definition, Transform root)
{
return new DeferredLootRuntime(loot, definition.LootTable, root);
}
public IHazardRuntime CreateHazards(RoomDefinition definition, Transform root)
{
return new EmptyHazardRuntime();
}
public IRoomHudPresenter CreateHud(RoomDefinition definition)
{
return new CombatRoomHudPresenter(hudView, definition.ThreatLevel);
}
}
A trap gauntlet uses the same product slots but different products.
public sealed class TrapGauntletRoomFactory : IRoomArchetypeFactory
{
private readonly ILootFactory loot;
private readonly ITrapFactory traps;
private readonly DungeonHudView hudView;
public RoomArchetype Archetype => RoomArchetype.TrapGauntlet;
public TrapGauntletRoomFactory(
ILootFactory loot,
ITrapFactory traps,
DungeonHudView hudView)
{
this.loot = loot;
this.traps = traps;
this.hudView = hudView;
}
public IEncounterRuntime CreateEncounter(RoomDefinition definition, Transform root)
{
return new EmptyEncounterRuntime();
}
public IRewardRuntime CreateRewards(RoomDefinition definition, Transform root)
{
return new ChestRewardRuntime(loot, definition.LootTable, root);
}
public IHazardRuntime CreateHazards(RoomDefinition definition, Transform root)
{
return new TrapLayoutRuntime(traps, definition.TrapLayout, root);
}
public IRoomHudPresenter CreateHud(RoomDefinition definition)
{
return new TrapRoomHudPresenter(hudView, definition.ThreatLevel);
}
}
The abstract factory boundary prevents mismatched room families. A trap gauntlet does not accidentally get a wave combat HUD unless that is intentionally encoded in its factory. The room runtime does not switch on archetype. It receives a complete set of matching pieces.
This is the point where an abstract factory earns its cost. The variation is not one class. It is a coordinated bundle.
Let Reflex inject the factory family
Reflex can resolve multiple bindings for the same contract through All<T>(). That is a good fit for a selector that maps each room archetype to its abstract factory.
public interface IRoomArchetypeFactorySelector
{
IRoomArchetypeFactory Select(RoomArchetype archetype);
}
public sealed class RoomArchetypeFactorySelector : IRoomArchetypeFactorySelector
{
private readonly IReadOnlyDictionary<RoomArchetype, IRoomArchetypeFactory> factories;
public RoomArchetypeFactorySelector(IEnumerable<IRoomArchetypeFactory> factories)
{
this.factories = factories.ToDictionary(factory => factory.Archetype);
}
public IRoomArchetypeFactory Select(RoomArchetype archetype)
{
if (factories.TryGetValue(archetype, out var factory))
return factory;
throw new ArgumentException($"No room factory registered for {archetype}", nameof(archetype));
}
}
The selector is intentionally dull. It turns a collection into a dictionary and fails loudly for missing archetypes. That failure matters. A missing room factory should not become an empty room, a default room, or a null reference three systems later.
The Reflex installer registers the concrete factories under the shared contract:
builder.RegisterType(
typeof(SkirmishRoomFactory),
new[] { typeof(IRoomArchetypeFactory) },
Lifetime.Scoped,
Resolution.Lazy);
builder.RegisterType(
typeof(TrapGauntletRoomFactory),
new[] { typeof(IRoomArchetypeFactory) },
Lifetime.Scoped,
Resolution.Lazy);
builder.RegisterType(
typeof(RoomArchetypeFactorySelector),
new[] { typeof(IRoomArchetypeFactorySelector) },
Lifetime.Scoped,
Resolution.Eager);
Eager resolution for the selector is a useful development choice. If a factory has a missing dependency, the scene fails while building the graph instead of during the first unlucky room. For production, the same rule still applies: fail as close to startup as practical for wiring mistakes.
The factories themselves can stay lazy. There is no reason to instantiate every room-family factory until something needs the selector, and the selector already forces that early if that is what the team wants.
Use RegisterFactory for adapter construction, not gameplay requests
Reflex also has RegisterFactory<T>(Func<Container, T> factory, ...). That is useful when a binding needs custom construction.
For example, maybe the enemy factory needs a prefab parent created from a scene object and a catalog from serialized data:
builder.RegisterFactory<IEnemyFactory>(
container => new PrefabEnemyFactory(
container.Resolve<EnemyCatalog>(),
container.Resolve<EnemyPool>(),
enemyRoot),
Lifetime.Scoped,
Resolution.Lazy);
That is fine. The factory delegate is still part of the composition root. It builds a stable service: IEnemyFactory.
The mistake is registering per-room products this way:
// Wrong shape for room-specific runtime data.
builder.RegisterFactory<DungeonRoomRuntime>(
container => new DungeonRoomRuntime(/* which room definition? which root? */),
Lifetime.Transient,
Resolution.Lazy);
The container does not know which room definition the player entered. If you force it to know, you will usually smuggle that data through mutable globals, temporary registrations, or a current-room service. That is container soup.
Per-request data belongs in a method argument:
DungeonRoomRuntime Create(RoomDefinition definition, Transform roomRoot);
The DI container builds the factory. The factory handles the runtime request. Small distinction, large difference.
The product factories stay narrow
The abstract factory coordinates the family, but it should not contain every prefab lookup and spawn rule itself. That would turn it into the same giant switch with a nicer name.
Keep lower-level product factories narrow:
public interface IEnemyFactory
{
EnemyRuntime Spawn(EnemyPackId packId, Transform root, int threatLevel);
}
public interface ILootFactory
{
LootRuntime Create(LootTableId tableId, Transform root);
}
public interface ITrapFactory
{
TrapRuntime CreateLayout(TrapLayoutId layoutId, Transform root);
}
Each one owns one kind of creation. The room-family factories compose them.
This gives each layer a reasonable job:
PrefabEnemyFactoryknows enemy catalogs, pools, and initialization.PrefabLootFactoryknows loot tables and pickup prefabs.PrefabTrapFactoryknows trap layouts and arming rules.SkirmishRoomFactoryknows which products a skirmish room needs.DungeonRoomFactoryknows how to turn a room definition into one runtime.DungeonRoomControllerknows when the room starts and stops.
No class is magical. Good. Magical code is where bugs breed.
Lifetime rules matter more than pattern names
Factories make lifetime bugs easy to hide if the API is dishonest.
A room runtime is short-lived. It should be disposed or stopped when the room unloads. A factory may be scene-scoped. Catalogs may be scene-scoped or project-scoped. Pools may be scene-scoped because they own spawned Unity objects. Pure rules services may be singleton. These choices matter more than whether the class has Factory in the name.
A room runtime should make cleanup explicit:
public sealed class DungeonRoomRuntime : IDisposable
{
private readonly IEncounterRuntime encounter;
private readonly IRewardRuntime rewards;
private readonly IHazardRuntime hazards;
private readonly IRoomHudPresenter hud;
private readonly IDungeonAudio audio;
public DungeonRoomRuntime(
RoomDefinition definition,
IEncounterRuntime encounter,
IRewardRuntime rewards,
IHazardRuntime hazards,
IRoomHudPresenter hud,
IRandom random,
IDungeonAudio audio)
{
this.encounter = encounter;
this.rewards = rewards;
this.hazards = hazards;
this.hud = hud;
this.audio = audio;
}
public void Enter()
{
hud.Show();
hazards.Arm();
encounter.Start();
audio.PlayRoomMusic();
}
public void Dispose()
{
encounter.Dispose();
hazards.Dispose();
rewards.Dispose();
hud.Hide();
}
}
If lower-level factories use pools, expose release behavior where needed. Do not pretend pooled objects are garbage-collected ordinary objects.
public interface IEnemyFactory
{
EnemyRuntime Spawn(EnemyPackId packId, Transform root, int threatLevel);
void Release(EnemyRuntime enemy);
}
Yes, Release makes the interface less cute. It also prevents hidden lifetime rules. Cute APIs do not get points when they leak enemies between rooms.
Testing the room logic without Reflex
One of the nicer outcomes of this split is that most tests do not need Reflex at all.
The room factory can be tested with fakes:
[Test]
public void BossRoomUsesBossFamily()
{
var bossFamily = new FakeRoomArchetypeFactory(RoomArchetype.Boss);
var selector = new FakeSelector(bossFamily);
var factory = new DungeonRoomFactory(
selector,
new FixedRandom(),
new SilentDungeonAudio());
var room = factory.Create(bossDefinition, fakeRoot);
Assert.That(bossFamily.CreatedEncounter, Is.True);
Assert.That(bossFamily.CreatedHud, Is.True);
}
A skirmish factory can be tested by passing fake product factories:
[Test]
public void SkirmishRoomCreatesWaveEncounterFromEnemyPack()
{
var enemies = new RecordingEnemyFactory();
var roomFactory = new SkirmishRoomFactory(
enemies,
new FakeLootFactory(),
new FakeTrapFactory(),
new FakeHudView());
roomFactory.CreateEncounter(skirmishDefinition, fakeRoot);
Assert.That(enemies.LastPackId, Is.EqualTo(skirmishDefinition.EnemyPack));
}
The Reflex installer deserves a smaller integration check: build the scene container and verify the key contracts resolve. That check is about wiring. It should not test the whole dungeon.
[Test]
public void DungeonInstallerRegistersRoomFactory()
{
var builder = new ContainerBuilder();
installer.InstallBindings(builder);
using var container = builder.Build();
Assert.That(container.Resolve<IDungeonRoomFactory>(), Is.Not.Null);
Assert.That(container.All<IRoomArchetypeFactory>().Count(), Is.GreaterThan(0));
}
That division keeps tests honest. Business logic tests use normal constructors. Container tests check container registration. Unity play mode tests check prefab and scene integration.
Common mistakes when combining Reflex and factories
The first mistake is injecting Container everywhere.
Reflex lets code resolve from a container. That does not mean every runtime object should receive one. Passing the container into gameplay code gives that code unlimited access to the graph. It can now hide dependencies, resolve services at random times, and make tests harder than they need to be.
If a class needs audio, inject IDungeonAudio. If it needs a room factory, inject IDungeonRoomFactory. If it needs enemies, inject IEnemyFactory. Do not inject the toolbox when the class needs one wrench.
The second mistake is making every product an abstract factory product.
If all room archetypes use the same loot creation path, keep ILootFactory shared. The abstract factory can decide whether to create immediate loot, deferred loot, or no loot, but it does not need a separate loot factory type for every archetype unless the variation is real.
The third mistake is hiding runtime state inside singletons.
The current room definition, current room root, current threat level, and current player entry direction should not be global services. They are request data. Pass them through Create calls. If many values travel together, create a request object.
public readonly record struct RoomCreationRequest(
RoomDefinition Definition,
Transform RoomRoot,
Vector3 EntryPoint,
int DungeonDepth);
That is clearer than sprinkling four temporary values through the container.
The fourth mistake is using abstract factories before the family exists.
If the only thing that changes between room types is enemy pack selection, use an enemy factory and data. Do not create SkirmishRoomFactory, TreasureRoomFactory, and TrapRoomFactory just to return the same three objects with one parameter changed. Data is cheaper than type hierarchies.
Abstract factory becomes useful when the family is real: encounter, rewards, hazards, and HUD all vary together.
A practical rule of thumb
Use Reflex for stable object graph construction:
- catalogs
- pools
- services
- scene adapters
- factories
- selectors
- long-lived presenters
- configuration objects
Use factories for runtime creation:
- enemies spawned during a room
- loot created after victory
- traps armed when the player enters
- room runtimes built from definitions
- HUD presenters that depend on room mode
- temporary effects and gameplay objects
Use abstract factories only when several products must stay consistent:
- boss room encounter, boss HUD, boss reward table, boss music cues
- trap gauntlet hazards, warning UI, delayed rewards
- treasure room empty encounter, chest rewards, relaxed HUD
That last list is narrow on purpose. The abstract factory should describe a coherent room family, not the entire game.
The final architecture
The final shape is not complicated:
Scene Container
-> stable services
-> product factories
-> room-archetype factories
-> room factory
DungeonRoomController
-> IDungeonRoomFactory.Create(definition, roomRoot)
DungeonRoomRuntime
-> encounter + rewards + hazards + HUD
The container is used at the edge. The factory receives runtime data. The abstract factory keeps related room products together. Product factories handle prefab and pool details. The room runtime owns behavior and cleanup.
That is the clean combination.
Reflex is not there to make gameplay code ask for whatever it wants whenever it wants. It is there to make the graph visible and injectable. Factories are not there to make object creation sound fancy. They are there to put creation policy in one small place. Abstract factories are not there for every branch. They are there when several created objects must belong to the same family.
The dungeon room example works because each tool has a boundary:
- Reflex wires the scene.
DungeonRoomFactorycreates one room runtime.IRoomArchetypeFactorycreates a matching family for one archetype.- Enemy, loot, and trap factories handle lower-level products.
- Runtime data stays in request arguments.
- Cleanup stays explicit.
That is the part worth copying into a real project. Not the exact class names. Not the number of interfaces. The boundary.
Start with direct construction. When creation starts spreading, introduce a small factory. When several related products must change together, introduce an abstract factory. When the factory needs stable collaborators, let Reflex inject them at the scene boundary. Then stop. The point is to keep room code readable, testable, and honest about what it creates. Anything beyond that is pattern collecting, and pattern collecting is how a simple dungeon room turns into a committee meeting with hit points.
Related reading: Dependency Injection in Unity covers the base DI rule for Unity code. Dependency Injection for Game Code covers composition roots and wiring at the edges. Factory and Abstract Factory Patterns covers the factory pattern itself before Reflex enters the picture.