Factories and Pooling
Use a factory when an object needs a value that is only known at the call site, and a pool when the same objects are created and discarded many times. The factory contracts (IFactory<...>) live in the engine-free Onity.Factory assembly; the pools (OnityObjectPool<T>, PrefabComponentPool<T>, IPool<T>, IPoolHooks) in Onity.Pooling; and the one-line prefab binding (BindPooledFactory) in Onity.Unity.Installers. A pool the scope created is disposed with the scope, like every other object it owns.
Contents:
- Choose a factory or pool
- Runtime-argument factory
- Prefab pooled factory
- Pool lifetime
- Prewarm and fixed capacity
- Parameterized pooled spawn
- Plain C# object pool
- Compared with Zenject and VContainer
Choose a factory or pool
| You need | Use | Registers | Notes |
|---|---|---|---|
| A new plain object per call from 0, 1 or 2 runtime arguments | your IFactory<...> class and BindFactory<...>() | IFactory<...> and the factory type, one shared instance | no pooling; you write Create |
| Reusable prefab instances, no arguments | BindPooledFactory(prefab, parent, defaultCapacity, maxSize) in a MonoInstaller | IPool<TComponent> and IFactory<TComponent> | defaults 16 and 512; no prewarm, fixed-size or name option; the scope disposes the pool |
| A prefab pool with prewarm, fixed capacity or a diagnostics name | new PrefabComponentPool<T>(...), then BindPooledFactory(pool) | the same two bindings | caller-owned: pool.AddTo(container) or dispose it yourself |
| A pool you already built, any item type | BindPooledFactory(IPool<T>) | the same two bindings | caller-owned; the container disposes it only after pool.AddTo(container) |
| Pooled spawn with 1 or 2 runtime arguments | new PooledFactory<TParam, T>(pool, initialize) or the two-parameter form, bound with BindInstance<IFactory<TParam, T>>(...) | what you bind | needs an IParameterizedPool<T>; initialize runs before the get hooks and activation |
Reusable plain C# objects, no GameObject | new OnityObjectPool<T>(...) (T : class), optionally BindPooledFactory(pool) | what you bind | reset in actionOnGet and actionOnRelease; IPoolHooks is not called |
Runtime-argument factory
Author an IFactory<TParam, TValue> when the created object needs a value from the call site. The container constructs the factory, so it can inject services or IResolver; only the runtime values travel through Create. There is no Instantiate(args) and no fluent factory body:
using Onity.DI; // IResolver
using Onity.Factory; // IFactory<TParam, TValue>
public readonly struct EnemySpawnRequest
{
public readonly string Id;
public readonly int Level;
public EnemySpawnRequest(string id, int level)
{
Id = id;
Level = level;
}
}
public sealed class Enemy
{
public Enemy(string id, int level, IClock clock)
{
Id = id;
Level = level;
Clock = clock;
}
public string Id { get; }
public int Level { get; }
public IClock Clock { get; }
}
public sealed class EnemyFactory : IFactory<EnemySpawnRequest, Enemy>
{
private readonly IClock m_clock;
public EnemyFactory(IClock clock)
{
m_clock = clock;
}
public Enemy Create(EnemySpawnRequest request)
{
return new Enemy(request.Id, request.Level, m_clock);
}
}
Register and use it (fragment):
container.Bind<IClock>().To<GameClock>().AsSingle();
container.BindFactory<EnemySpawnRequest, Enemy, EnemyFactory>();
IFactory<EnemySpawnRequest, Enemy> enemies = container.Resolve<IFactory<EnemySpawnRequest, Enemy>>();
Enemy elite = enemies.Create(new EnemySpawnRequest("elite", 5));
BindFactory binds the factory with BindInterfacesAndSelfTo<TFactory>().AsSingle(), so it resolves as IFactory<...> and as its own type, one instance per scope. The three forms match the three contracts, and each constrains TFactory to the contract it binds (where TFactory : class, IFactory<...>): BindFactory<TValue, TFactory>() for an IFactory<TValue>, BindFactory<TParam, TValue, TFactory>() for an IFactory<TParam, TValue> (the form above), and BindFactory<TParam1, TParam2, TValue, TFactory>() for an IFactory<TParam1, TParam2, TValue>. A factory type compiles only with the form of the contract it implements; EnemyFactory above fits the one-parameter form alone.
Two parameters is the largest arity; wrap more values in a struct, as EnemySpawnRequest does. The object a factory creates is caller-owned: the container does not dispose it.
Prefab pooled factory
For projectiles, hit markers, pickups and list items, bind the prefab once from a MonoInstaller. One call registers IPool<TComponent> to release and IFactory<TComponent> to spawn. This is the canonical HitMarker from Getting Started. The installer line:
using Onity.DI; // OnityContainer
using Onity.Unity.Installers; // MonoInstaller, BindPooledFactory
using UnityEngine;
public sealed class GameInstaller : MonoInstaller
{
[SerializeField] private HitMarker m_hitMarkerPrefab;
[SerializeField] private Transform m_hitMarkerRoot;
public override void InstallBindings(OnityContainer container)
{
// IFactory<HitMarker> to spawn and IPool<HitMarker> to release; the scope disposes the pool.
container.BindPooledFactory(m_hitMarkerPrefab, m_hitMarkerRoot, defaultCapacity: 8, maxSize: 32);
}
}
The pooled component implements IPoolHooks for its reset logic. PrefabComponentPool<T> activates the GameObject and then calls OnPoolGet() on get; on release it calls OnPoolRelease() and then deactivates the GameObject:
using Onity.Pooling; // IPoolHooks
using UnityEngine;
public sealed class HitMarker : MonoBehaviour, IPoolHooks
{
public void Show(int amount)
{
// Position the marker and display the amount.
}
public void OnPoolGet()
{
// Reset per-use state when the pool hands this instance out.
}
public void OnPoolRelease()
{
// Clear references when the instance returns to the pool.
}
}
The consumer injects the factory to spawn and the pool to release. Here the release happens after a delay, on the scope token, so a marker is never released into a pool the scope has already disposed:
using System.Threading;
using Onity.DI; // Inject
using Onity.Factory; // IFactory<T>
using Onity.Pooling; // IPool<T>
using Onity.Unity.Async; // OnityTask, OnityTaskVoid, GetScopeCancellationToken
using UnityEngine;
public sealed class HealthHud : MonoBehaviour
{
private const float k_hitMarkerSeconds = 0.5f;
// The health subscription and the OnEnable that calls ShowHit are in Getting Started.
[Inject] private IFactory<HitMarker> m_hitMarkers;
[Inject] private IPool<HitMarker> m_hitMarkerPool;
private void ShowHit(PlayerDamaged message)
{
HitMarker marker = m_hitMarkers.Create(); // taken from the pool
marker.Show(message.Amount);
ReleaseLaterAsync(marker, this.GetScopeCancellationToken()).Forget();
}
private async OnityTaskVoid ReleaseLaterAsync(HitMarker marker, CancellationToken token)
{
await OnityTask.Delay(k_hitMarkerSeconds, token); // ends with the scope
m_hitMarkerPool.Release(marker); // back to the pool, exactly once
}
}
Clones are instantiated under an inactive hidden root and moved to parent inactive, so a clone of an active prefab does not run OnEnable before the pool activates it on get. Do not depend on runtime state in Awake; set it in OnPoolGet() or through the parameterized spawn below. Release every instance exactly once; a second release of the same instance is an error.
Pool lifetime
BindPooledFactory(prefab, ...) creates the pool, so the scope owns it: the pool is disposed when the container is disposed, after the scope token is canceled. A context disposes its container when it is destroyed. Disposing a prefab pool destroys its inactive instances only. Instances that are still checked out stay with their owners, so release them before the scope ends; Get, TryGet, Release, Prewarm and Clear throw ObjectDisposedException afterwards. See Disposal ownership.
A pool you build yourself and pass to BindPooledFactory(pool) stays caller-owned: the container never disposes it. Tie it to the scope with pool.AddTo(container) (Onity.DI), or dispose it yourself (fragment):
PrefabComponentPool<HitMarker> pool = new PrefabComponentPool<HitMarker>(
m_hitMarkerPrefab, m_hitMarkerRoot, maxSize: 64, initialSize: 16);
pool.AddTo(container); // disposed when the container is disposed
container.BindPooledFactory(pool); // IPool<HitMarker> and IFactory<HitMarker>
Prewarm and fixed capacity
OnityObjectPool<T> and PrefabComponentPool<T> take initialSize (distinct items created in the constructor) and fixedSize (when true, maxSize is the total number of items, and Get() throws InvalidOperationException once all of them are checked out). pool.Prewarm(count) raises the created total to count; it is a target, not an increment, and it runs no get or release hooks. maxSize without fixedSize limits the retained inactive items: a release beyond it destroys the item.
On a fixed-size pool, TryGet(out item) returns false instead of throwing when every item is checked out; TryGet(param, initialize, out item) and its two-parameter form do the same for a parameterized get. CountAll, CountActive and CountInactive report the items the pool created, the ones checked out and the ones waiting. Clear() destroys waiting items only, so items checked out across it still count toward a fixed size.
PrefabComponentPool<T> also takes actionOnCreate. It runs once for each new clone, under parent and still inactive, so before the clone’s first Awake and OnEnable, including the clones that initialSize and Prewarm create. Use it for setup a clone needs once; per-use state still belongs in IPoolHooks. If it throws, the clone is destroyed and the exception propagates. In OnityObjectPool<T>, createFunc is that place (fragment):
// At most 16 markers at once; each clone is set up once, before its first OnEnable.
PrefabComponentPool<HitMarker> pool = new PrefabComponentPool<HitMarker>(
m_hitMarkerPrefab, m_hitMarkerRoot, maxSize: 16, initialSize: 16, fixedSize: true,
actionOnCreate: marker => marker.gameObject.layer = m_overlayLayer);
if (pool.TryGet(out HitMarker marker)) // false while all 16 are checked out
{
marker.Show(amount);
}
BindPooledFactory(prefab, parent, defaultCapacity, maxSize) has no prewarm, fixed-size or diagnostics-name option. Build the pool yourself, as in Pool lifetime, when you need one. A diagnosticsName labels the pool in Onity/Tools/Pool Monitor.
Parameterized pooled spawn
BindPooledFactory(prefab) binds a zero-parameter IFactory<TComponent>. For Create(position) or Create(position, velocity) there are two options; they differ in when the component sees the argument.
A wrapper factory over IPool<T> works with BindPooledFactory(prefab) as it is. It applies the argument after Get() returns, so the clone’s OnEnable and OnPoolGet() have already run without it:
using Onity.Factory; // IFactory<TParam, TValue>
using Onity.Pooling; // IPool<T>
using UnityEngine;
public sealed class HitMarkerAtPositionFactory : IFactory<Vector3, HitMarker>
{
private readonly IPool<HitMarker> m_pool;
public HitMarkerAtPositionFactory(IPool<HitMarker> pool)
{
m_pool = pool;
}
public HitMarker Create(Vector3 position)
{
HitMarker marker = m_pool.Get();
marker.transform.position = position;
return marker;
}
}
Register the prefab pool first, then the wrapper, and gameplay code injects IFactory<Vector3, HitMarker> (fragment):
container.BindPooledFactory(m_hitMarkerPrefab, m_hitMarkerRoot);
container.BindFactory<Vector3, HitMarker, HitMarkerAtPositionFactory>();
The PooledFactory adapter applies the argument earlier. Both pools implement IParameterizedPool<T>, whose Get(param, initialize) and Get(param1, param2, initialize) run initialize before activation and before OnPoolGet(), on every use, including a clone’s first OnEnable. PooledFactory<TParam, TValue> and PooledFactory<TParam1, TParam2, TValue> (Onity.Pooling) adapt such a pool to IFactory<...>. BindPooledFactory binds only the zero-parameter factory, so build the pool yourself and bind the adapter as an instance:
using Onity.DI; // OnityContainer, AddTo(scope)
using Onity.Factory; // IFactory<TParam, TValue>
using Onity.Pooling; // PrefabComponentPool<T>, PooledFactory<TParam, TValue>
using Onity.Unity.Installers; // MonoInstaller, BindPooledFactory
using UnityEngine;
public sealed class GameInstaller : MonoInstaller
{
[SerializeField] private HitMarker m_hitMarkerPrefab;
[SerializeField] private Transform m_hitMarkerRoot;
public override void InstallBindings(OnityContainer container)
{
PrefabComponentPool<HitMarker> pool = new PrefabComponentPool<HitMarker>(
m_hitMarkerPrefab, m_hitMarkerRoot, defaultCapacity: 8, maxSize: 32, initialSize: 8);
pool.AddTo(container); // caller-built: tie it to the scope
container.BindPooledFactory(pool); // IPool<HitMarker> and IFactory<HitMarker>
container.BindInstance<IFactory<Vector3, HitMarker>>(
new PooledFactory<Vector3, HitMarker>(
pool,
(marker, position) => marker.transform.position = position));
}
}
With the adapter, OnPoolGet() already sees the state that initialize set, so do not clear those fields in OnPoolGet(); clear them in OnPoolRelease() instead.
Plain C# object pool
For objects without a GameObject, use OnityObjectPool<T> (T : class). It keeps inactive items in its own stack and exposes the same IPool<T> contract. Reset logic goes in the actionOnGet and actionOnRelease callbacks; IPoolHooks is not called by this pool. With collectionCheck: true it rejects a second return of the same item, by reference, in the Editor and in players:
using Onity.Pooling; // OnityObjectPool<T>
using OnityObjectPool<PathNode> pool = new OnityObjectPool<PathNode>(
createFunc: () => new PathNode(),
actionOnGet: node => node.Reset(),
actionOnRelease: node => node.Clear(),
collectionCheck: true,
defaultCapacity: 64,
maxSize: 1024,
diagnosticsName: "PathNodePool");
PathNode node = pool.Get();
pool.Release(node);
pool.Clear(); // destroys the inactive items; the pool stays usable
Clear() runs actionOnDestroy on every inactive item and keeps the pool usable; Dispose() does the same and then rejects further calls. To hand the pool to gameplay code as a factory, bind it; it stays caller-owned unless you add it to the scope (fragment):
IPool<PathNode> pool = new OnityObjectPool<PathNode>(() => new PathNode());
container.BindPooledFactory(pool); // IPool<PathNode> and IFactory<PathNode>
IFactory<PathNode> nodes = container.Resolve<IFactory<PathNode>>();
PathNode node = nodes.Create();
Compared with Zenject and VContainer
Feature shape only; measured pooling results, with their conditions, are on DI vs VContainer and Zenject.
| Use case | Onity | Zenject | VContainer |
|---|---|---|---|
| Runtime-argument factory | your IFactory<...> class and BindFactory | BindFactory and PlaceholderFactory chains | factory delegates registered on the builder |
| Prefab pooled factory | BindPooledFactory(prefab) binds IFactory<T> and IPool<T> | MemoryPool and MonoMemoryPool with installer wiring | none built in; combine registration with your own pool |
| Reset lifecycle | IPoolHooks on the component, or actionOnGet and actionOnRelease on OnityObjectPool<T> | pool callbacks such as OnSpawned and OnDespawned | your own |
| Prewarm, fixed capacity, retention limit | constructor arguments, Prewarm(count), TryGet and the Count* properties on both pools | pool size options on the memory pool binding | your own |
| Fluent factory bodies | none; a C# factory class | FromMethod, FromIFactory and related | delegates |
Onity ships fewer factory concepts and no fluent factory bodies; custom construction logic is a C# class. Zenject has the broadest factory surface; VContainer stays DI-only.
See also
- Dependency Injection: bindings, resolving and the factory entry points.
- Lifecycle and Scopes: who disposes which pool.
- Async with OnityTask: releasing after a delay with a scope token, and the single-consumer rule for pooled tasks.
- DI vs VContainer and Zenject: the measured comparison.