Notifications
Article
SOLID Principles in Unity - Part 2: Single Responsibility Principle
Updated 20 days ago
122
1
Get instant one-on-one help on your Unity project from community experts!
Chat with our experts on Unity Live Help to get some help on your project!
Please, before starting to read, remember that I am not saying that there are no better ways to implement something described in the article, but I just want to present another possibility and besides, I am not focusing on optimization but only showing one way among many possibilities in an example that is not even a real project, but only a simple idea.
  • Part 1: SOLID Overview ( The intention is to revisit and understand SOLID from a generic perspective)
  • Part 2: Single Responsibility Principle ( in Unity and C#)
  • Part 3: Open/Closed Principle ( in Unity and C#)
  • Part 4: Liskov Substitution Principle ( in Unity and C#)
  • Part 5: Interface Segregation Principle ( in Unity and C#)
  • Part 6: Dependency Inversion Principle ( in Unity and C#)

Introduction

Hey everyone, it's been a while since the first article of this series rolled out, I've been quite busy, but finally here it is, the second part of it. In this article we're going to see more about the first principle (the S in SOLID), the Single Responsibility Principle. Now that you understandood a bit more about SOLID in a general way, we're going to dig more into how the Single Responsibility works.

What is SRP (Single Responsibility Principle) about?

As Robert Martin (Uncle Bob) has suggested on his paper Design Principles and Design Patterns - "a class should have only one reason to change", but what is this reason to change, what is in fact this responsibility all about?
In software development, we use the terms "tightly coupled" and "loosely coupled" to state how much your classes and components know about each other in a system and how many different parts of the code we should hammer every single time a simple change comes into play in the requirements.
There is a thin line between a monolithic and a modular code, specially when we need to achieve results and finalize a project at any cost. In game development, it's not different and in most cases we're only focusing on finishing the project, of course keeping quality but sometimes we end up not checking out the better solution for the problem, always promising ourselves to revisit the code later and start refactoring it, until we get to a point where it's too late to go back and we simply take the hit and ship another monolithic project that will cost a lot more time to maintain when we need to implement a new feature later on.
What this means in practice, is that loosely coupled (and modular) code rarely needs to be changed once it's written. It has a standardized set of methods, it no longer needs to change. If a new requirement demands a change in the application, we should try to avoid touching the components previously created. The data keeps the same, but new derived modules will process it differently to accomodate to the new requirements. That sounds like a dream, but we all know how hard it is to achieve it in real life. The Single Responsibility Principle is the most important among all of the other principles, I mean they all have their importance, but in the end all of them will work to achieve SRP in our project if you think about it.
Why do we want to follow the Open Closed Principle? To try to keep your classes with only one responsibility. And what about Liskov Substitution Principle and the other ones? I guess you got the point, right?
SRP is probably the most important of all, as I said, but at the same time it's the most subjective and misleading of all, and many people tend to distort the idea behind it setting up a set of rules and trying to follow them up and they start to fall into their own traps along the project's lifecycle. I've seen people saying that each function in a class is one responsibility. Well, this statement is not necessaraly true, it varies from project to project and from business to business and also from programmer to programmer.
Yes, I could agree that each function would probably be a responsibility, but what we should take as a rule of thumb in this case is that there is no rule of thumb. First, it should make sense to separate those functions into different responsibilities, or in the end we'll just have an amougamation of components tightly coupled as dependencies of each other. Each situation should be analyzed individually.

A Simple Example of SRP

Let's consider a simple example.
Imagine that we are writing a feature for an RPG game and what we need is to have our character using different types of consumable items, which may include, potions, food and probably scrolls. It's quite clear that each of those items might have different behaviours
The best way to show SRP in practice is by using a god class example. Probably in all projects I've worked on, in some point there was always those manager classes taking a bunch of decisions and manipulating multiple instances of many other classes at the same time. Those god classes do not often start out with low cohesion (those undesirable traits such as being difficult to maintain, test, reuse, or even understand). Sooner or later, that monster class should be refactored, with the intent to reduce that coupling.
An example of a game manager class should be perfect to see what I mean:
using UnityEngine; using UnityEngine.UI; public class GameManager : MonoBehaviour { public const int INITIAL_LIFE = 100; private const string SCORE_TXT = "SCORE: "; private const string LIFE_TXT = "LIFE: "; //UI [SerializeField] private Text scoreText; [SerializeField] private Text lifeText; [SerializeField] private Image gameOverOverlay; //Audio [SerializeField] private AudioClip attackSfx; [SerializeField] private AudioClip enemyDeathSfx; [SerializeField] private AudioClip playerDeathSfx; //Entities [SerializeField] private Player player; [SerializeField] private AudioSource audioSource; private void Start() { InitializeUI(); player.gameObject.SetActive(true); } private void InitializeUI() { gameOverOverlay.gameObject.SetActive(false); if (scoreText != null) { scoreText.text = SCORE_TXT + "0"; scoreText.gameObject.SetActive(true); } if (lifeText != null) { lifeText.text = LIFE_TXT + INITIAL_LIFE.ToString(); lifeText.gameObject.SetActive(true); } } private void DisableTextUI() { if (scoreText != null) { scoreText.gameObject.SetActive(false); } if (lifeText != null) { lifeText.gameObject.SetActive(false); } } public void UpdateScore(int score) { if(scoreText != null) { scoreText.text = SCORE_TXT + score.ToString(); } } public void UpdateLife(int life) { if(lifeText != null) { lifeText.text = LIFE_TXT + life.ToString(); } } public void PlayerAttacksEnemy(Enemy enemy) { if (player != null && enemy != null) { enemy.ReceiveDamage(player.Attack); PlayAttackSfx(); if (enemy.IsDead) { player.AddScore(enemy.TotalPoints); player.AddToTotalEnemiesKilled(); } } } public void EnemyAttacksPlayer(Enemy enemy) { if(enemy != null && player != null) { player.ReceiveDamage(enemy.Attack); PlayAttackSfx(); if (player.IsDead) { PlayPlayerDeathSfx(); gameOverOverlay.gameObject.SetActive(true); DisableTextUI(); player.gameObject.SetActive(false); } UpdateLife(player.Life); } } public void PlayAttackSfx() { if(audioSource != null && attackSfx != null) { audioSource.clip = attackSfx; audioSource.Play(); } } public void PlayEnemyDeathSfx() { if (audioSource != null && enemyDeathSfx!= null) { audioSource.clip = enemyDeathSfx; audioSource.Play(); } } public void PlayPlayerDeathSfx() { if (audioSource != null && playerDeathSfx != null) { audioSource.clip = playerDeathSfx; audioSource.Play(); } } }
As we can see in the example above, this class is responsible for different events in the game. We can easily identify 3 different domains in this example:
  • Update the UI with the changes on the player object
  • Play audio clips
  • Handle attacks on the player and enemies
Those responsibilities are all unrelated and we could simply create components to take care of them instead of creating a monolithic class like that. Some possible examples would be:
  • UIComponent - Handles the UI elments and their changes
  • AudioComponent - Handles audio clips and how to play them
  • CombatComponent - Handles damage during the combat
In this case, the 3 component classes would be monoBehaviours, to keep things simple. And we could still create a helper class ( GameConstants ), which could be only a regular class holding the common constants used in the game.
UIComponent
using UnityEngine; using UnityEngine.UI; public class UIComponent : MonoBehaviour { [SerializeField] private Text scoreText; [SerializeField] private Text lifeText; [SerializeField] private Image gameOverOverlay; private void Start() { InitializeUI(); } private void InitializeUI() { gameOverOverlay.gameObject.SetActive(false); if (scoreText != null) { scoreText.text = GameConstants.SCORE_TXT + "0"; scoreText.gameObject.SetActive(true); } if (lifeText != null) { lifeText.text = GameConstants.LIFE_TXT + GameConstants.INITIAL_LIFE.ToString(); lifeText.gameObject.SetActive(true); } } public void DisableTextUI() { if (scoreText != null) { scoreText.gameObject.SetActive(false); } if (lifeText != null) { lifeText.gameObject.SetActive(false); } } public void UpdateScore(int score) { if (scoreText != null) { scoreText.text = GameConstants.SCORE_TXT + score.ToString(); } } public void UpdateLife(int life) { if (lifeText != null) { lifeText.text = GameConstants.LIFE_TXT + life.ToString(); } } public void ShowGameOverOverlay() { gameOverOverlay.gameObject.SetActive(true); } }
AudioComponent
using UnityEngine; public class AudioComponent : MonoBehaviour { //Audio Clips [SerializeField] private AudioClip attackSfx; [SerializeField] private AudioClip playerDeathSfx; //Audio Sources [SerializeField] private AudioSource audioSource; public void PlayAttackSfx() { if (audioSource != null && attackSfx != null) { audioSource.clip = attackSfx; audioSource.Play(); } } public void PlayPlayerDeathSfx() { if (audioSource != null && playerDeathSfx != null) { audioSource.clip = playerDeathSfx; audioSource.Play(); } } }
CombatComponent
using UnityEngine; public class CombatComponent : MonoBehaviour { public void PlayerAttacksEnemy(Player player, Enemy enemy) { if (player != null) { enemy?.ReceiveDamage(player.Attack); } } public void EnemyAttacksPlayer(Enemy enemy, Player player) { if (enemy != null) { player?.ReceiveDamage(enemy.Attack); } } }
GameConstants
public class GameConstants { public const int INITIAL_LIFE = 100; public const string SCORE_TXT = "SCORE: "; public const string LIFE_TXT = "LIFE: "; }
Note that this is only a simple example, I'm not saying that this is the best way for separating those domains and I'm not taking optimization in consideration either, as optimization alone would be subject for many other articles.
The CombatComponent is not 100% necessary in that case above, as we could simply call the methods from the gameManager, but I just included it because it's going to make more sense as we progress into the other parts of the series.
The idea was only to have the gameManager orchestrating the components and handling the situations in the game without keeping the responsibility of knowing what's happening on the UI, the audio etc.
GameManager 2.0
using UnityEngine; public class GameManager : MonoBehaviour { //Components [SerializeField] private UIComponent uIComponent; [SerializeField] private AudioComponent audioComponent; [SerializeField] private CombatComponent combatComponent; //Entities [SerializeField] private Player player; public void PlayerAttacksEnemy(Enemy enemy) { combatComponent?.PlayerAttacksEnemy(player, enemy); OnEnemyAttaked(enemy); } public void EnemyAttacksPlayer(Enemy enemy) { combatComponent?.EnemyAttacksPlayer(enemy, player); OnPlayerAttacked(); } private void OnPlayerAttacked() { audioComponent?.PlayAttackSfx(); uIComponent?.UpdateLife(player.Life); if (player.IsDead) { audioComponent?.PlayPlayerDeathSfx(); uIComponent?.ShowGameOverOverlay(); uIComponent?.DisableTextUI(); player.gameObject.SetActive(false); } } private void OnEnemyAttaked(Enemy enemy) { audioComponent?.PlayAttackSfx(); if (enemy.IsDead) { player.AddScore(enemy.TotalPoints); player.AddToTotalEnemiesKilled(); } } }
There are many other ways to refactor monolithic classes and there are some great design patterns that can be used to achieve it. If you are interested in learning more about some design patterns that could be used to decouple classes, take a look on the following design patterns:
  • Component - Allow a single entity to span multiple domains without coupling the domains to each other.
  • Event Queue - Decouple when a message or event is sent from when it is processed.
  • Service Locator - Provide a global point of access to a service without coupling users to the concrete class that implements it.
Another option to be used to start decoupling your modules is the C# events with delegates or actions, which is basically the idea for listeners or observers.

Conclusion

As we could see, the main reason behind the SRP (Single Responsibility Principle) is to spread the verb about making our classes more modular and less monolithic, increasing the cohesion. In essence, high cohesion means keeping parts of a code base that are related to each other in a single place. Low coupling, at the same time, is about separating unrelated parts of the code base as much as possible. In theory, the guideline looks pretty simple, but in practice, however, you need to dive into the domain model of your game/software deep enough to understand which parts of your code base are actually related. This is not quite a trivial task to achieve. It demands practice and lots a solid understading of your business layer.

Rodrigo Abreu
Software Engineer at EA / Unity Live Expert - Programmer
12
Comments
V
Vsev0l0d
10 days ago
Great article!
1