Refactoring from Existing Architecture
Use this page when you convert an existing Unity codebase one piece at a time. The target shape is the same in every example: game rules move into constructor-injected plain C# services, MonoBehaviour classes stay thin adapters for input, views, triggers and prefabs, every subscription and loop is tied to its owner, and all wiring sits in one installer. The result has less global state, fewer scene-order assumptions, and services you can test without a scene.
Contents:
- Refactoring rules
- Example 1: from a GameManager singleton
- Example 2: from a VContainer manager
- Example 3: from Unity references and ScriptableObject config
- Example 4: from a Zenject manager and SignalBus
- Example 5: from static events or UnityEvent
- Example 6: from a coroutine loop
- Migration checklist
Refactoring rules
| Smell | Onity target |
|---|---|
GameManager.Instance called from unrelated scripts | Inject a small role interface such as IScoreService |
| One manager owns score, UI, scene loading and spawn rules | Split state and rules into services; keep views in MonoBehaviours |
Update resolves services or searches the scene | Inject once through the context, then call plain methods |
| The publisher references every subscriber | Publish a typed message through OnityEventHub or OnityEvent |
| A ScriptableObject stores runtime state | Bind ScriptableObjects as read-only config; keep runtime state in services |
| Serialized references connect gameplay systems | Inject role interfaces; keep serialized references for views and prefab assets |
| A VContainer entry point per manager loop | Bind a singleton that implements IOnityTickable or IOnityInitializable; the container collects it |
Zenject SignalBus for simple notifications | Typed messages through OnityEventHub or OnityEvent |
A coroutine loop with WaitForSeconds | An async OnityTaskVoid loop on the scope token |
An IDisposable field disposed by hand in OnDisable | TakeUntilDisable(this) on the subscription made in OnEnable |
Example 1: from a GameManager singleton
Before. The singleton is convenient at first, but its dependencies are hidden and it changes for too many reasons: score rules, UI, enemy flow and scene transitions are coupled in one class:
using UnityEngine;
using UnityEngine.SceneManagement;
public sealed class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
[SerializeField] private ScoreHud m_hud;
[SerializeField] private int m_pointsPerEnemy = 10;
[SerializeField] private int m_winScore = 100;
private int m_score;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
public void EnemyKilled()
{
m_score += m_pointsPerEnemy;
m_hud.SetScore(m_score);
if (m_score >= m_winScore)
{
SceneManager.LoadScene("Win");
}
}
}
public sealed class EnemyHealth : MonoBehaviour
{
private void Die()
{
GameManager.Instance.EnemyKilled();
Destroy(gameObject);
}
}
After. The score rule becomes a plain service that exposes its state as a read-only reactive property. The enemy script calls the small interface it was given; it does not know where the score is stored or which HUD shows it:
using Onity.Reactive; // ReactiveProperty<T>, IReadOnlyReactiveProperty<T>
public interface IScoreService
{
IReadOnlyReactiveProperty<int> Score { get; }
void AddEnemyKill();
}
public sealed class ScoreService : IScoreService
{
private const int k_pointsPerEnemy = 10;
private readonly ReactiveProperty<int> m_score = new ReactiveProperty<int>(0);
public IReadOnlyReactiveProperty<int> Score => m_score;
public void AddEnemyKill()
{
m_score.SetValue(m_score.Value + k_pointsPerEnemy);
}
}
using Onity.DI; // Inject
using Onity.Unity.Reactive; // TakeUntilDisable
using UnityEngine;
public sealed class EnemyHealth : MonoBehaviour
{
[Inject] private IScoreService m_score;
private void Die()
{
m_score.AddEnemyKill();
Destroy(gameObject);
}
}
public sealed class ScoreHud : MonoBehaviour
{
[Inject] private IScoreService m_score;
private void OnEnable()
{
m_score.Score.Subscribe(SetScore) // emits the current score first
.TakeUntilDisable(this); // disposed in OnDisable
}
private void SetScore(int value)
{
// Update UI Toolkit, TMP or UGUI here.
}
}
using Onity.DI; // OnityContainer
using Onity.Unity.Installers; // MonoInstaller
public sealed class GameInstaller : MonoInstaller
{
public override void InstallBindings(OnityContainer container)
{
container.Bind<IScoreService>().To<ScoreService>().AsSingle();
}
}
The number of files is not the point; the direction of the dependencies is:
EnemyHealth -> IScoreService <- ScoreHud
EnemyHealth and ScoreHud no longer reference each other, nothing calls a global singleton, and ScoreService is tested as a plain class. Scene transitions, which the manager also owned, belong in their own service.
Example 2: from a VContainer manager
Before. VContainer registers the service and the entry point separately:
using VContainer;
using VContainer.Unity;
public sealed class GameLifetimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
builder.Register<IScoreService, ScoreService>(Lifetime.Singleton);
builder.RegisterEntryPoint<GameSessionManager>();
}
}
public sealed class GameSessionManager : IInitializable, ITickable
{
private readonly IScoreService m_score;
public GameSessionManager(IScoreService score)
{
m_score = score;
}
public void Initialize()
{
// Session setup.
}
public void Tick()
{
// Session-level per-frame rule.
}
}
After. The manager becomes a service with Onity’s lifecycle interfaces. A singleton that implements IOnityInitializable or IOnityTickable is collected when the container builds, so there is no entry point registration:
using Onity.DI; // IOnityInitializable, IOnityTickable
public interface IGameSessionService
{
bool IsRunning { get; }
}
public sealed class GameSessionService : IGameSessionService, IOnityInitializable, IOnityTickable
{
private readonly IScoreService m_score;
public bool IsRunning { get; private set; }
public GameSessionService(IScoreService score)
{
m_score = score;
}
public void Initialize()
{
IsRunning = true;
}
public void Tick()
{
if (!IsRunning)
{
return;
}
// Session-level per-frame rule.
}
}
using Onity.DI; // OnityContainer
using Onity.Unity.Installers; // MonoInstaller
public sealed class GameInstaller : MonoInstaller
{
public override void InstallBindings(OnityContainer container)
{
container.Bind<IScoreService>().To<ScoreService>().AsSingle();
container.BindInterfacesAndSelfTo<GameSessionService>().AsSingle();
}
}
BindInterfacesAndSelfTo<GameSessionService>() makes one instance visible as IGameSessionService, IOnityInitializable, IOnityTickable and GameSessionService. Lifecycle singletons are created at Build(), which runs Initialize(); the owning context pumps Tick() every frame. See From VContainer for the full mapping.
Example 3: from Unity references and ScriptableObject config
Before. Serialized references are right for assets and views, but brittle when they wire gameplay systems together. The reward rule, the HUD, the audio and the runtime score live in one scene object, so moving the HUD or the audio object breaks the rule code:
using UnityEngine;
[CreateAssetMenu]
public sealed class EnemyRewardSettings : ScriptableObject
{
public int PointsPerEnemy = 10;
}
public sealed class EnemyRewardManager : MonoBehaviour
{
[SerializeField] private EnemyRewardSettings m_settings;
[SerializeField] private ScoreHud m_hud;
[SerializeField] private AudioSource m_audio;
[SerializeField] private AudioClip m_killClip;
private int m_score;
public void OnEnemyKilled()
{
m_score += m_settings.PointsPerEnemy;
m_hud.SetScore(m_score);
m_audio.PlayOneShot(m_killClip);
}
}
After. The ScriptableObject stays as configuration and is bound through the installer as a small read-only interface. The rule becomes a service; the HUD and the audio stay Unity views that observe state and messages:
using UnityEngine;
public interface IEnemyRewardConfig
{
int PointsPerEnemy { get; }
}
[CreateAssetMenu]
public sealed class EnemyRewardConfig : ScriptableObject, IEnemyRewardConfig
{
[SerializeField] private int m_pointsPerEnemy = 10;
public int PointsPerEnemy => m_pointsPerEnemy;
}
using Onity.Reactive; // ReactiveProperty<T>, IReadOnlyReactiveProperty<T>
using Onity.Unity.Messaging; // OnityEventHub
public readonly struct EnemyRewarded
{
public readonly int Score;
public EnemyRewarded(int score)
{
Score = score;
}
}
public interface IEnemyRewardService
{
IReadOnlyReactiveProperty<int> Score { get; }
void AddEnemyKill();
}
public sealed class EnemyRewardService : IEnemyRewardService
{
private readonly IEnemyRewardConfig m_config;
private readonly OnityEventHub m_events;
private readonly ReactiveProperty<int> m_score = new ReactiveProperty<int>(0);
public IReadOnlyReactiveProperty<int> Score => m_score;
public EnemyRewardService(IEnemyRewardConfig config, OnityEventHub events)
{
m_config = config;
m_events = events;
}
public void AddEnemyKill()
{
m_score.SetValue(m_score.Value + m_config.PointsPerEnemy);
m_events.Publish(new EnemyRewarded(m_score.Value));
}
}
using Onity.DI; // Inject
using Onity.Unity; // OnityEvent
using Onity.Unity.Reactive; // TakeUntilDisable
using UnityEngine;
public sealed class EnemyDeathReporter : MonoBehaviour
{
[Inject] private IEnemyRewardService m_rewards;
public void ReportEnemyKilled()
{
m_rewards.AddEnemyKill();
}
}
public sealed class EnemyRewardHud : MonoBehaviour
{
[Inject] private IEnemyRewardService m_rewards;
private void OnEnable()
{
m_rewards.Score.Subscribe(SetScore)
.TakeUntilDisable(this); // disposed in OnDisable
}
private void SetScore(int value)
{
// Update UI here.
}
}
public sealed class EnemyRewardAudio : MonoBehaviour
{
[SerializeField] private AudioSource m_audio;
[SerializeField] private AudioClip m_killClip;
private void OnEnable()
{
OnityEvent.Observe<EnemyRewarded>(this)
.Subscribe(OnEnemyRewarded)
.TakeUntilDisable(this); // disposed in OnDisable
}
private void OnEnemyRewarded(EnemyRewarded message)
{
m_audio.PlayOneShot(m_killClip);
}
}
using Onity.DI; // OnityContainer
using Onity.Unity.Installers; // MonoInstaller, BindScriptableObject
using UnityEngine;
public sealed class GameInstaller : MonoInstaller
{
[SerializeField] private EnemyRewardConfig m_rewardConfig;
public override void InstallBindings(OnityContainer container)
{
container.BindScriptableObject<IEnemyRewardConfig, EnemyRewardConfig>(m_rewardConfig);
container.Bind<IEnemyRewardService>().To<EnemyRewardService>().AsSingle();
}
}
OnityEventHub needs no installer line; every context binds it. Designers keep the asset workflow, and the runtime state no longer lives in the asset or in a web of scene references.
Example 4: from a Zenject manager and SignalBus
Before. Zenject removes the singleton, but a project still accumulates manager classes and SignalBus wiring for simple notifications:
using Zenject;
public readonly struct WaveStartedSignal
{
public readonly int Wave;
public WaveStartedSignal(int wave)
{
Wave = wave;
}
}
public sealed class CombatInstaller : MonoInstaller
{
public override void InstallBindings()
{
SignalBusInstaller.Install(Container);
Container.DeclareSignal<WaveStartedSignal>();
Container.BindInterfacesAndSelfTo<WaveManager>().AsSingle();
}
}
public sealed class WaveManager : IInitializable, ITickable
{
private readonly SignalBus m_signals;
private int m_wave;
public WaveManager(SignalBus signals)
{
m_signals = signals;
}
public void Initialize()
{
m_wave = 1;
m_signals.Fire(new WaveStartedSignal(m_wave));
}
public void Tick()
{
// Wave progression.
}
}
After. The manager becomes a role service and the signal becomes a typed message. The lifecycle is collected automatically, and the message travels through the same broker the reactive bridges use:
using Onity.DI; // IOnityInitializable, IOnityTickable
using Onity.Unity.Messaging; // OnityEventHub
public readonly struct WaveStarted
{
public readonly int Wave;
public WaveStarted(int wave)
{
Wave = wave;
}
}
public interface IWaveService
{
int CurrentWave { get; }
}
public sealed class WaveDirector : IWaveService, IOnityInitializable, IOnityTickable
{
private readonly OnityEventHub m_events;
public int CurrentWave { get; private set; }
public WaveDirector(OnityEventHub events)
{
m_events = events;
}
public void Initialize()
{
CurrentWave = 1;
m_events.Publish(new WaveStarted(CurrentWave));
}
public void Tick()
{
// Wave progression.
}
}
A service that needs only one direction of the channel injects ISubscriber<WaveStarted> or IPublisher<WaveStarted> after BindMessageChannel<WaveStarted>(), and ties its subscription to the scope that owns it:
using Onity.DI; // IOnityScopeLifetime, AddTo(scope)
using Onity.Messaging; // ISubscriber<T>
public sealed class WaveHudModel
{
public int DisplayedWave { get; private set; }
public WaveHudModel(ISubscriber<WaveStarted> waves, IOnityScopeLifetime scope)
{
waves.Subscribe(OnWaveStarted).AddTo(scope); // unsubscribed when the scope ends
}
private void OnWaveStarted(WaveStarted message)
{
DisplayedWave = message.Wave;
}
}
using Onity.DI; // OnityContainer
using Onity.Unity.Installers; // MonoInstaller
using Onity.Unity.Messaging; // BindMessageChannel
public sealed class GameInstaller : MonoInstaller
{
public override void InstallBindings(OnityContainer container)
{
container.BindMessageChannel<WaveStarted>(); // IPublisher<WaveStarted>, ISubscriber<WaveStarted>
container.BindInterfacesAndSelfTo<WaveDirector>().AsSingle(); // IWaveService, lifecycle, WaveDirector
container.Bind<WaveHudModel>().AsSingle();
}
}
BindMessageChannel<WaveStarted>() binds the broker’s own channel, so WaveDirector publishing through OnityEventHub reaches WaveHudModel. There is no DeclareSignal step and no signal installer. See From Zenject for the full mapping.
Example 5: from static events or UnityEvent
Before. Static C# events and inspector-wired UnityEvent callbacks decouple the publisher from the subscriber in syntax, but they hide lifetime and scope. A missed unsubscribe leaks, and a static event ignores ProjectContext, SceneContext and GameObjectContext ownership:
using System;
using UnityEngine;
public static class GameEvents
{
public static event Action<int> PlayerDamaged;
public static void RaisePlayerDamaged(int amount)
{
PlayerDamaged?.Invoke(amount);
}
}
public sealed class DamageButton : MonoBehaviour
{
public void Click()
{
GameEvents.RaisePlayerDamaged(10);
}
}
public sealed class DamageHud : MonoBehaviour
{
private void OnEnable()
{
GameEvents.PlayerDamaged += OnPlayerDamaged;
}
private void OnDisable()
{
GameEvents.PlayerDamaged -= OnPlayerDamaged;
}
private void OnPlayerDamaged(int amount)
{
// Update UI.
}
}
After. Use a typed message. The owner overloads of OnityEvent route through the nearest context above the component, so a prefab under a GameObjectContext talks to its own scope and a scene object talks to the scene scope. The subscription made in OnEnable is retained with TakeUntilDisable(this):
using Onity.Reactive; // Where
using Onity.Unity; // OnityEvent
using Onity.Unity.Reactive; // TakeUntilDisable
using UnityEngine;
public readonly struct PlayerDamaged
{
public readonly int Amount;
public PlayerDamaged(int amount)
{
Amount = amount;
}
}
public sealed class DamageButton : MonoBehaviour
{
public void Click()
{
OnityEvent.Publish(this, new PlayerDamaged(10)); // the nearest context's channel
}
}
public sealed class DamageHud : MonoBehaviour
{
private void OnEnable()
{
OnityEvent.Observe<PlayerDamaged>(this)
.Where(message => message.Amount > 0)
.Subscribe(OnPlayerDamaged)
.TakeUntilDisable(this); // disposed in OnDisable
}
private void OnPlayerDamaged(PlayerDamaged message)
{
// Update UI.
}
}
Use direct interface injection when the subscriber is a required collaborator. Use a typed message when there can be zero, one or many subscribers and the publisher should not know them. Messages are not replayed: a subscriber that arrives late misses earlier messages, which is what a ReactiveProperty<T> is for.
Example 6: from a coroutine loop
Before. A coroutine loop on a MonoBehaviour couples a rule (regeneration) to a scene object, and its lifetime to that object’s enable state:
using System.Collections;
using UnityEngine;
public sealed class HealthRegeneration : MonoBehaviour
{
[SerializeField] private Health m_health;
private Coroutine m_loop;
private void OnEnable()
{
m_loop = StartCoroutine(RegenerateLoop());
}
private void OnDisable()
{
if (m_loop != null)
{
StopCoroutine(m_loop);
m_loop = null;
}
}
private IEnumerator RegenerateLoop()
{
WaitForSeconds wait = new WaitForSeconds(1f);
while (true)
{
yield return wait;
m_health.Heal(1);
}
}
}
After. The loop moves into the service that owns the rule, as an async OnityTaskVoid on the scope token. Initialize() starts it after Build(), and the token is canceled before the scope disposes the property the loop writes to. This is the canonical HealthService from Getting Started:
using System;
using System.Threading;
using Onity.DI; // IOnityInitializable, IOnityScopeLifetime, AddTo(scope)
using Onity.Messaging; // ISubscriber<T>
using Onity.Reactive; // ReactiveProperty<T>
using Onity.Unity.Async; // OnityTask, OnityTaskVoid
public sealed class HealthService : IOnityInitializable
{
private const int k_maxHealth = 100;
private const float k_regenerationInterval = 1f;
private readonly ReactiveProperty<int> m_health;
private readonly IOnityScopeLifetime m_scope;
public HealthService(ReactiveProperty<int> health, ISubscriber<PlayerDamaged> damage, IOnityScopeLifetime scope)
{
m_health = health;
m_scope = scope;
damage.Subscribe(OnDamaged).AddTo(scope); // unsubscribed when the scope ends
}
public void Initialize()
{
RegenerateAsync(m_scope.Token).Forget(); // the token is canceled before the scope disposes its services
}
private void OnDamaged(PlayerDamaged message)
{
m_health.SetValue(Math.Max(0, m_health.Value - message.Amount));
}
private async OnityTaskVoid RegenerateAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
await OnityTask.Delay(k_regenerationInterval, token);
m_health.SetValue(Math.Min(k_maxHealth, m_health.Value + 1));
}
}
}
When a loop belongs to a MonoBehaviour after all (a view animation, for example), keep it there as an async OnityTaskVoid with this.GetScopeCancellationToken() or this.GetCancellationTokenOnDestroy() (Onity.Unity.Async) as its token. Never use async void; see Async with OnityTask.
Migration checklist
- Name the responsibility before naming the class. Prefer
ScoreService,WaveDirectororGameSessionServiceover a broadGameManager. - Create small interfaces for the roles other code consumes.
- Move rules and state into constructor-injected plain C# services; expose state as
IReadOnlyReactiveProperty<T>. - Keep
MonoBehaviourscripts as input, view, collision, trigger or prefab adapters with[Inject]members. - Bind ScriptableObjects as config contracts with
BindScriptableObject, not as mutable runtime state. - Bind the services in one
MonoInstallerassigned to theProjectContext,SceneContextorGameObjectContextwhose lifetime matches them. - Use
OnityEventHuborISubscriber<T>in services andOnityEvent.Publish,SubscribeandObservewith the owner overload in Unity-facing scripts when a typed message is cleaner than a direct dependency. - Replace static events one message at a time. Retain every subscription:
TakeUntilDisable(this)inOnEnable,AddTo(this)for destroy lifetime,AddTo(scope)in services. - Replace coroutine loops with
async OnityTaskVoidmethods that take the scope token. - Delete the singleton last, after every caller receives an injected interface.
See also
- Dependency Injection: bindings, injection sites and documented resolve behavior.
- Lifecycle and Scopes: lifecycle collection, the scope token and context ownership.
- Events and Messaging:
OnityEventHub,OnityEventand channel bindings. - Async with OnityTask: loops, delays and cancellation on scope and destroy tokens.
- From Zenject and From VContainer: API-level mappings.
- ADR 0004: Refactoring from Existing Architecture: the decision record behind this guide.