Using the Composition root architecture in Unity. Part 1. Setting up a project from scratch

Hello to everyone who is not indifferent to architectural solutions in the framework of projects on Unity and not only. If the question of choice is still relevant for you or you are just interested in options, then I’m ready to talk about the implementation of the Composition root architecture with examples of the simplest logic. There is a single entry point and Dependency Injection, that is, everything we like. I myself have been following this architecture for several years and have implemented quite a few projects on it, from prototypes to pvp games.

Composition root is a mixture of MVP and MVVM models and actively uses the Observer pattern, in this article I will not delve into the essence of these terms, but will try to clearly show how it works. The implementation of the project structure goes through a bunch of basic concepts: Entity – Presenter Model (PM) – View.

entity – an entity, a separate logical unit that serves to create PM and View and transfer dependencies to them

Presenter Model – contains business logic not related to Monobehaviour classes

view – Gameobject on stage

The path from a single entry point to the first game entity

Let’s see in practice how to take the first steps. Let’s create two objects on the stage: an empty Canvas and a GameObject Entry Point with a component on it with the same name.

The EntryPoint class will contain quite a bit of code.

EntryPoint
public class EntryPoint : MonoBehaviour
{
   [SerializeField] private ContentProvider _contentProvider;
   [SerializeField] private RectTransform _uiRoot;

   private Root _root;

   private void Start()
   {
       var rootCtx = new Root.Ctx
       {
           contentProvider = _contentProvider,
           uiRoot = _uiRoot,
       };
  
       _root = Root.CreateRoot(rootCtx);
   }

   private void OnDestroy()
   {
       _root.Dispose();
   }
}

It is worth clarifying here that _uiRoot – this is the same empty canvas, and _contentProvider – this is a scriptable object, which will contain everything that should appear on the stage in the future. We don’t have the Root class yet, and then we will create it.

In the future, lighting and the camera should also be loaded from the Content Provider
In the future, lighting and the camera should also be loaded from the Content Provider

This is where the fun begins, first we create the DisposableObject class, from which all our future entities and PMs, including Root, will be inherited. The purpose of DisposableObject is to be able to safely destroy its instances and the subscriptions within them if necessary. Here we are gradually approaching the Observer pattern, but first things first.

DisposableObject class
public abstract class DisposableObject : IDisposable
{
   private bool _isDisposed;
   private List<IDisposable> _mainThreadDisposables;
   private List<Object> _unityObjects;
  
   public void Dispose()
   {
       if (_isDisposed)
           return;
       _isDisposed = true;
       if (_mainThreadDisposables != null)
       {
           var mainThreadDisposables = _mainThreadDisposables;
           for (var i = mainThreadDisposables.Count - 1; i >= 0; i--)
               mainThreadDisposables[i]?.Dispose();
           mainThreadDisposables.Clear();
       }
       try
       {
           OnDispose();
       }
       catch (Exception e)
       {
           Debug.Log($"This exception can be ignored. Disposable of {GetType().Name}: {e}");
       }

       if (_unityObjects == null) return;
       foreach (var obj in _unityObjects.Where(obj => obj))
       {
           Object.Destroy(obj);
       }
   }

   protected virtual void OnDispose() {}

   protected TDisposable AddToDisposables<TDisposable>(TDisposable disposable) where TDisposable : IDisposable
   {
       if (_isDisposed)
       {
           Debug.Log("disposed");
           return default;
       }
       if (disposable == null)
       {
           return default;
       }

       _mainThreadDisposables ??= new List<IDisposable>(1);
       _mainThreadDisposables.Add(disposable);
       return disposable;
   }
}

One of the most popular frameworks for reactive programming in Unity is UniRx, it is he who will help to establish logical connections between entities and their creations. You can read more about it here. The IDisposable interface is part of UniRx.

Root class
public class Root : DisposableObject
{
   public struct Ctx
   {
       public ContentProvider contentProvider;
       public RectTransform uiRoot;
   }
   private readonly Ctx _ctx;
  
   private Root(Ctx ctx)
   {
       _ctx = ctx;
       CreateGameEntity();
   }

   private void CreateGameEntity()
   {
       var ctx = new GameEntity.Ctx
       {
           contentProvider = _ctx.contentProvider,
           uiRoot = _ctx.uiRoot
       };
  
       AddToDisposables(new GameEntity(ctx));
   }
}

Now contentProvider and uiRoot are variables in the Ctx structure (the name is short for Context). This structure was created in the EntryPoint and passed to the constructor of the Root class, which laid the foundation for the “root” for the future tree of our project.

Create a Game Entity
public class GameEntity : DisposableObject
{
   public struct Ctx
   {
       public ContentProvider contentProvider;
       public RectTransform uiRoot;
   }
  
   private readonly Ctx _ctx;
   private UIEntity _uiEntity;
  
   public GameEntity(Ctx ctx)
   {
       _ctx = ctx;
       CreateUIEntity();
   }

   private void CreateUIEntity()
   {
       var UIEntityCtx = new UIEntity.Ctx()
       {
           contentProvider = _ctx.contentProvider,
           uiRoot = _ctx.uiRoot
       };
       _uiEntity = new UIEntity(UIEntityCtx);
       AddToDisposables(_uiEntity);
   }
}

Implementation of the simplest logic

At this stage, the Game Entity spawns only one UIEntity, inside which a simple logic for counting button clicks will be implemented. Consider the implementation of UIEntity and the logic of relationships within the entity using a reactive variable.

UIEntity class
public class UIEntity : DisposableObject
{
   public struct Ctx
   {
       public ContentProvider contentProvider;
       public RectTransform uiRoot;
   }

   private readonly Ctx _ctx;
   private UIPm _pm;
   private UIviewWithButton _view;
   private readonly ReactiveProperty<int> _buttonClickCounter = new ReactiveProperty<int>();
   public UIEntity(Ctx ctx)
   {
       _ctx = ctx;
       CreatePm();
       CreateView();
   }

   private void CreatePm()
   {
       var uiPmCtx = new UIPm.Ctx()
       {
           buttonClickCounter = _buttonClickCounter
       };
       _pm = new UIPm(uiPmCtx);
       AddToDisposables(_pm);
   }

   private void CreateView()
   {
       _view = Object.Instantiate(_ctx.contentProvider.uIviewWithButton, _ctx.uiRoot);
       _view.Init(new UIviewWithButton.Ctx()
       {
           buttonClickCounter = _buttonClickCounter
       });
   }

   protected override void OnDispose()
   {
       base.OnDispose();
       if(_view != null)
           Object.Destroy(_view.gameObject);
   }
}
UIPm class
public class UIPm : DisposableObject
{
   public struct Ctx
   {
       public ReactiveProperty<int> buttonClickCounter;
   }

   private Ctx _ctx;
  
   public UIPm(Ctx ctx)
   {
       _ctx = ctx;
       _ctx.buttonClickCounter.Subscribe(ShowClicks);
   }

   private void ShowClicks(int click)
   {
       Debug.Log($"clicks: {click}");
   }
}
UIViewWithButton class
public class UIviewWithButton : MonoBehaviour
{
   public struct Ctx
   {
       public ReactiveProperty<int> buttonClickCounter;
   }

   private Ctx _ctx;
   [SerializeField] private Button button;

   public void Init(Ctx ctx)
   {
       _ctx = ctx;
       button.onClick.AddListener( () => _ctx.buttonClickCounter.Value++);
   }
}

The entity spawns a PM with the logic of displaying the number of clicks in Debug.Log. Everything is simple here and there is nothing to focus on. The implementation of the view is a little more interesting. Used to create it content providerwhich contained the prefab with the corresponding component and uiRootwhich served as the parent for this prefab.

buttonClickCounter – reactive variable created by UniRx, which became part of the context for the view and pm. It is initialized in the entity and passed on. UIViewWithButton on each click incriminates the value of the variable, UIPm takes this value. To do this, in Pm you need to create a subscription to change the value of the variable. This subscription is added to the list inside the DisposableObject and will be destroyed when the object is destroyed.

Naturally, variables of any type can be passed in the context, but it is reactive variables and events that are most convenient for organizing links between logical units.

Using such a connection, you can create short encapsulated views, leaving them only the moments of interaction with the player, and hide all the logic in pm. Entities can spawn other entities containing any number of views and pm. Here everything depends on the programmer’s decomposition skill. Relationships between entities are just as easily implemented through contexts and reactive variables.

Expansion of the logical part

Let’s add logic for rotating the cube by clicking on an existing button.

To do this, we will create another entity and describe in it the creation of a game object and its reaction to pressing a button. For this variable buttonClickCounter it is necessary to take it up a level in the Game Entity and add it to the UIEntity context.

Updated Game Entity class
public class GameEntity : DisposableObject
{
   public struct Ctx
   {
       public ContentProvider contentProvider;
       public RectTransform uiRoot;
   }
  
   private readonly Ctx _ctx;
   private UIEntity _uiEntity;
   private CubeEntity _cubeEntity;
   private readonly ReactiveProperty<int> _buttonClickCounter = new ReactiveProperty<int>();

  
   public GameEntity(Ctx ctx)
   {
       _ctx = ctx;
       CreateUIEntity();
       CreteCubeEntity();
   }

   private void CreateUIEntity()
   {
       var UIEntityCtx = new UIEntity.Ctx()
       {
           contentProvider = _ctx.contentProvider,
           uiRoot = _ctx.uiRoot,
           buttonClickCounter = _buttonClickCounter
       };
       _uiEntity = new UIEntity(UIEntityCtx);
       AddToDisposables(_uiEntity);
   }

   private void CreteCubeEntity()
   {
       var cubeEntityCtx = new CubeEntity.Ctx()
       {
           contentProvider = _ctx.contentProvider,
           buttonClickCounter = _buttonClickCounter
       };
       _cubeEntity = new CubeEntity(cubeEntityCtx);
       AddToDisposables(_cubeEntity);
   }
}
CubeEntity class
public class CubeEntity : DisposableObject
{
   public struct Ctx
   {
       public ContentProvider contentProvider;
       public ReactiveProperty<int> buttonClickCounter;

   }

   private Ctx _ctx;
   private CubePm _pm;
   private CubeView _view;
   private readonly ReactiveProperty<float> _rotateAngle = new ReactiveProperty<float>();
  
   public CubeEntity(Ctx ctx)
   {
       _ctx = ctx;
       CreatePm();
       CreteView();
   }

   private void CreatePm()
   {
       var cubePmCtx = new CubePm.Ctx()
       {
           buttonClickCounter = _ctx.buttonClickCounter,
           rotateAngle = _rotateAngle
       };
       _pm = new CubePm(cubePmCtx);
       AddToDisposables(_pm);
   }

   private void CreteView()
   {
       _view = Object.Instantiate(_ctx.contentProvider.cubeView, Vector3.zero, Quaternion.identity);
       _view.Init(new CubeView.Ctx()
       {
           rotateAngle = _rotateAngle
       });
   }
  
   protected override void OnDispose()
   {
       base.OnDispose();
       if(_view != null)
           Object.Destroy(_view.gameObject);
   }
}

The context of the created CubeEntity also includes a variable buttonClickCounter, which reaches CubePm. In the same place, a method is subscribed to it that sets a value for another reactive variable rotateAnglewhich, in turn, is subscribed to by the CubeView.

I will note that the ways of organizing a subscription in Pm and View are different. If inside pm it’s enough to add a subscription to the list for “destruction”, then inside the MonoBehaviour view, the subscription needs to be indicated that it belongs to this particular object, implemented using .addTo(this). This binding will help destroy the subscription along with the GameObject when it comes down to it.

CubePm class
public class CubePm : DisposableObject
{
   public struct Ctx
   {
       public ReactiveProperty<float> rotateAngle;
       public ReactiveProperty<int> buttonClickCounter;
   }

   private Ctx _ctx;
  
   public CubePm(Ctx ctx)
   {
       _ctx = ctx;
       AddToDisposables(_ctx.buttonClickCounter.Subscribe(AddRotationAngle));
   }

   private void AddRotationAngle(int clickCount)
   {
       _ctx.rotateAngle.Value = clickCount * 30;
   }
}
CubeView class
public class CubeView: MonoBehaviour
{
   public struct Ctx
   {
       public ReactiveProperty<float> rotateAngle;
   }

   private Ctx _ctx;

   public void Init(Ctx ctx)
   {
       _ctx = ctx;
       _ctx.rotateAngle.Subscribe(RotateMe).AddTo(this);
   }

   private void RotateMe(float angle)
   {
       transform.eulerAngles = new Vector3(0, angle, 0);
   }
}

So, we got a project with this structure. Looking at the code, it is not always possible to present the described logic in the form of a diagram, but on this image the principle of organizing entities in the Composition root is well understood.

You can download and view the project in working condition here.

Finally

I know that I didn’t specify a lot, for example, you can add a singleton check in the root class to keep the root class from being duplicated or to talk more about UniRx features, for example, creating reactive events. But more on that, perhaps another time. Here I wanted to give more applied material on how to start a project from scratch with a clear and stable architecture.

In the next part of the article, I will talk about how you can change meaningful structures to reference interfaces of the global class within composition root. This has its pros and cons, and at the very least, it can be interesting to explore.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *