Refactoring from Existing Architecture
This guide shows the refactoring shape Onity is meant to encourage: move game rules into constructor-injected plain C# services, keep MonoBehaviour classes as thin Unity adapters, and keep all wiring in one installer. The result is less global state, fewer scene-order assumptions, and services that can be tested without loading a scene.
Use this page as the reference when converting a manager-heavy Unity script, serialized Unity reference graph, ScriptableObject-driven setup, VContainer/Zenject lifetime scope, or static event bus into Onity.
Refactoring rules
| Smell | Onity target |
|---|---|
GameManager.Instance from unrelated scripts | Inject a small role interface such as IScoreService |
| One manager owns score, UI, scene loading, and spawn rules | Split state/rules into services; keep views in MonoBehaviours |
Update does manual resolve or scene search | Resolve once through the context, then call plain methods |
| Event sender references every receiver | Publish a typed message through OnityEventHub or OnityEvent |
| ScriptableObject stores runtime state | Bind ScriptableObjects as read-only config; keep runtime state in services |
| Serialized references connect gameplay systems directly | Inject role interfaces; keep serialized refs for view/prefab assets |
| VContainer entry point registration for each manager loop | Bind an IOnityTickable / IOnityInitializable singleton; Onity collects it automatically |
Zenject SignalBus used only for simple gameplay notifications | Use OnityEventHub or OnityEvent typed messages |
Example 1: From GameManager.Instance
Before
The usual singleton manager is convenient at first, but the dependencies are hidden and the class changes for too many reasons: score rules, UI, enemy flow, and scene transitions are all coupled.
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. UI listens to a reactive property. The enemy script only calls the small interface it was given by the context; it does not know where score is stored or which UI will update.
using Onity.Reactive;
using Onity.Unity.Messaging;
public readonly struct ScoreChanged
{
public readonly int Value;
public ScoreChanged(int value)
{
Value = value;
}
}
public interface IScoreService
{
ReactiveProperty<int> Score { get; }
void AddEnemyKill();
}
public sealed class ScoreService : IScoreService
{
private const int k_pointsPerEnemy = 10;
private readonly OnityEventHub m_events;
public ReactiveProperty<int> Score { get; } = new ReactiveProperty<int>(0);
public ScoreService(OnityEventHub events)
{
m_events = events;
}
public void AddEnemyKill()
{
Score.Value += k_pointsPerEnemy;
m_events.Publish(new ScoreChanged(Score.Value));
}
}
using System;
using Onity.DI;
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 IDisposable m_subscription;
private void OnEnable()
{
m_subscription = m_score.Score.Subscribe(SetScore);
}
private void OnDisable()
{
m_subscription?.Dispose();
m_subscription = null;
}
private void SetScore(int value)
{
// Update UI Toolkit, TMP, or UGUI here.
}
}
using Onity.DI;
using Onity.Unity.Installers;
public sealed class GameInstaller : MonoInstaller
{
public override void InstallBindings(OnityContainer container)
{
container.Bind<IScoreService>().To<ScoreService>().AsSingle();
}
}
The important part is not the number of files. The important part is the new direction of dependency flow:
EnemyHealth -> IScoreService <- ScoreHud
|
v
OnityEventHub
EnemyHealth and ScoreHud no longer reference each other, no object calls a global singleton, and ScoreService can be tested as a plain class.
Example 2: From a VContainer manager
Before
A typical VContainer setup registers a service and an entry point separately. That is a good DI model, but in a project moving to Onity the same role can use the Onity lifecycle interfaces directly.
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()
{
m_score.Score.Value = 0;
}
public void Tick()
{
// Session-level per-frame rule.
}
}
After
In Onity, the manager becomes a service with explicit lifecycle contracts. A singleton that implements IOnityInitializable or IOnityTickable is collected automatically when the container builds, so there is no separate entry-point registration line.
using Onity.DI;
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()
{
m_score.Score.Value = 0;
IsRunning = true;
}
public void Tick()
{
if (!IsRunning)
{
return;
}
// Session-level per-frame rule.
}
}
using Onity.DI;
using Onity.Unity.Installers;
public sealed class GameInstaller : MonoInstaller
{
public override void InstallBindings(OnityContainer container)
{
container.Bind<IScoreService>().To<ScoreService>().AsSingle();
container.BindInterfacesAndSelfTo<GameSessionService>().AsSingle().NonLazy();
}
}
BindInterfacesAndSelfTo<GameSessionService>() makes one instance visible as IGameSessionService, IOnityInitializable, IOnityTickable, and GameSessionService. NonLazy() constructs it during Build(), then Onity runs Initialize() and pumps Tick() from the owning context.
Example 3: From Unity references and ScriptableObject config
Before
Serialized references are useful for assets and views, but they become brittle when they wire gameplay systems together. In this example, the reward rule, UI, audio, and runtime score live in one scene object. Moving the HUD object or audio object can break 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
Keep the ScriptableObject as config, not as the runtime owner. Bind it through the installer as a small read-only interface. The score rule becomes a service; the HUD and audio stay as Unity views that observe state/events.
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;
using Onity.Unity.Messaging;
public readonly struct EnemyRewarded
{
public readonly int Score;
public EnemyRewarded(int score)
{
Score = score;
}
}
public interface IEnemyRewardService
{
ReactiveProperty<int> Score { get; }
void AddEnemyKill();
}
public sealed class EnemyRewardService : IEnemyRewardService
{
private readonly IEnemyRewardConfig m_config;
private readonly OnityEventHub m_events;
public ReactiveProperty<int> Score { get; } = new ReactiveProperty<int>(0);
public EnemyRewardService(IEnemyRewardConfig config, OnityEventHub events)
{
m_config = config;
m_events = events;
}
public void AddEnemyKill()
{
Score.Value += m_config.PointsPerEnemy;
m_events.Publish(new EnemyRewarded(Score.Value));
}
}
using System;
using Onity.DI;
using Onity.Unity;
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 IDisposable m_subscription;
private void OnEnable()
{
m_subscription = m_rewards.Score.Subscribe(SetScore);
}
private void OnDisable()
{
m_subscription?.Dispose();
m_subscription = null;
}
private void SetScore(int value)
{
// Update UI here.
}
}
public sealed class EnemyRewardAudio : MonoBehaviour
{
[SerializeField] private AudioSource m_audio;
[SerializeField] private AudioClip m_killClip;
private IDisposable m_subscription;
private void OnEnable()
{
m_subscription = OnityEvent.Observe<EnemyRewarded>(this).Subscribe(OnEnemyRewarded);
}
private void OnDisable()
{
m_subscription?.Dispose();
m_subscription = null;
}
private void OnEnemyRewarded(EnemyRewarded message)
{
m_audio.PlayOneShot(m_killClip);
}
}
using Onity.DI;
using Onity.Unity.Installers;
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();
}
}
The ScriptableObject still gives designers a familiar asset workflow, but the runtime state no longer lives inside the asset or a scene reference web.
Example 4: From Zenject manager + SignalBus
Before
Zenject can solve the singleton problem, but a project may still accumulate 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
In Onity, the manager becomes a role service and the signal becomes a typed message. The lifecycle is collected automatically, and the event path is the same messaging system used by reactive streams.
using Onity.DI;
using Onity.Unity.Messaging;
public readonly struct WaveStarted
{
public readonly int Wave;
public WaveStarted(int wave)
{
Wave = wave;
}
}
public interface IWaveService
{
int CurrentWave { get; }
}
public sealed class WaveService :
IWaveService,
IOnityInitializable,
IOnityTickable
{
private readonly OnityEventHub m_events;
public int CurrentWave { get; private set; }
public WaveService(OnityEventHub events)
{
m_events = events;
}
public void Initialize()
{
CurrentWave = 1;
m_events.Publish(new WaveStarted(CurrentWave));
}
public void Tick()
{
// Wave progression.
}
}
using Onity.DI;
using Onity.Unity.Installers;
public sealed class CombatInstaller : MonoInstaller
{
public override void InstallBindings(OnityContainer container)
{
container.BindInterfacesAndSelfTo<WaveService>().AsSingle().NonLazy();
}
}
If a plain service needs only one direction of a message channel, use typed subscriber injection:
using System;
using Onity.DI;
using Onity.Messaging;
using Onity.Unity.Installers;
using Onity.Unity.Messaging;
public sealed class CombatInstaller : MonoInstaller
{
public override void InstallBindings(OnityContainer container)
{
container.BindMessageChannel<WaveStarted>();
container.BindInterfacesAndSelfTo<WaveService>().AsSingle().NonLazy();
container.Bind<WaveHudModel>().AsSingle();
}
}
public sealed class WaveHudModel : IDisposable
{
private readonly IDisposable m_subscription;
public WaveHudModel(ISubscriber<WaveStarted> waves)
{
m_subscription = waves.Subscribe(OnWaveStarted);
}
public void Dispose()
{
m_subscription.Dispose();
}
private void OnWaveStarted(WaveStarted message)
{
// Update HUD model state.
}
}
Example 5: From static events or UnityEvent
Before
Static C# events and inspector-wired UnityEvent callbacks both decouple the sender from the receiver syntactically, but they often hide lifetime and scope. A missed unsubscribe can leak, 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 overload routes through the nearest GameObjectContext when one exists, then falls back to the active scene/project context. Dispose subscriptions in OnDisable for enable/disable lifetime, or use AddTo(this) when a subscription should live until destroy.
using System;
using Onity.Reactive;
using Onity.Unity;
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));
}
}
public sealed class DamageHud : MonoBehaviour
{
private IDisposable m_subscription;
private void OnEnable()
{
m_subscription = OnityEvent.Observe<PlayerDamaged>(this)
.Where(message => message.Amount > 0)
.Subscribe(OnPlayerDamaged);
}
private void OnDisable()
{
m_subscription?.Dispose();
m_subscription = null;
}
private void OnPlayerDamaged(PlayerDamaged message)
{
// Update UI.
}
}
Use direct interface injection when the receiver is a required collaborator. Use a typed event when there can be zero, one, or many receivers and the sender should not know them.
Migration checklist
- Name the responsibility before naming the class. Prefer
ScoreService,WaveService, orGameSessionServiceover a broadGameManager. - Create small interfaces for roles other code consumes.
- Move rules and state into constructor-injected plain C# services.
- Keep
MonoBehaviourscripts as input, view, collision, trigger, or prefab adapters. - Bind ScriptableObjects as config contracts, not as mutable runtime state.
- Bind all services in one
MonoInstallerassigned to aProjectContext,SceneContext, orGameObjectContext. - Use
OnityEventHubin services andOnityEvent.Publish/Subscribe/Observefrom Unity-facing scripts when a typed event is cleaner than a direct dependency. - Replace static events gradually; convert one message at a time and dispose subscriptions in
OnDisable, withAddTo(this)for destroy lifetime or aCompositeDisposablefor plain services. - Delete the singleton last, after every caller receives an injected interface.
See also
- Dependency Injection — binding, injection sites, and documented resolve behavior.
- Lifecycle & Scopes — automatic lifecycle collection and context ownership.
- Events & Messaging — event hub and
OnityEventexamples. - Migration: From Zenject — syntax-level differences for existing Zenject projects.
- Migration: From VContainer — API-level migration details.
- ADR 0004: Refactoring from Existing Architecture.