3.3 Layered Architecture Paradigm

The layered architecture style is one of the most common architecture styles in any kind of software development. Layering your application provides you with many benefits: allows your features to be manageable and traceable, separates lifecycle concerns from feature implementations, and removes concerns about code organization. If you’re starting out and you still don’t know how to handle scalability, integrating this paradigm into your architecture and sticking with it is extremely recommended. Besides being easy to understand, it allows you to have a very strong foundation for scalability.

💡Note
To fully utilize everything this paradigm has to offer, make sure you’re placing your features into a folder/namespace hierarchy as well. As your code base grows, maintaining a clear relationship between your features pays off generously as it heavily reduces the amount of fog surrounding the features you built months ago.

How it works

The idea here is to have a hierarchy of your features vertically. Think of an organization where you have a CEO above managers, managers managing leads, and leads managing the rest of employees. Clear cut responsibilities and a defined scoped for everyone in the organization is the theme here.

The top layer in your hierarchy can be imagined as a service that other features can call in order to perform an action or listen to an event. What do I mean by “service”?

It’s a concept inspired from web development. A common architectural design in web applications is to implement different “microservices” that take care of different responsibilities. For example, you might have a microservice for messaging, one for processing video, etc.

When you want to use one of these microservices, you don’t really need to think of their underlying logic. You’d pass the video microservice a video to process, and it gives you back an id to use, for example.

We can utilize this philosophy to manage our features. For example, the underlying save logic could change from using XML, to JSON, to utilizing a web service, the rest of our application shouldn’t be concerned about that. Other parts of your code should simply be able to ask your save system to save, wait until it’s done saving, then continue.

// Random example from a UI class that handles saving 
private IEnumerator SaveButtonPressedRoutine()
{
    yield return SaveSystem.SaveAsync();
    OnSaveUIFinished?.Invoke(); 
}

To see the full benefit of this style/paradigm, let’s set some ground rules based on these intentions.

Rules

  • Make sure your features are implemented in layers as much as possible
  • To access a child feature, communicate with the service
  • Service manages features, features manage sub-features, and so on
  • A class in an upper layer can access the lower layer it manages, lower layer fires events that the upper layer class listens to
  • Lower layer classes don’t reference a class in their upper layer

💡Note
We’ve talked about the dependency rule in another chapter, which is an extremely useful rule in software architecture. The dependency rule requires layered architecture, and layered architecture is utilized through the dependency rule. Make sure to check that out if you’d like to understand why the top layer references the layer below, and not the other way around.

Characteristics

Categorization
Every feature you implement is placed and managed under a higher layer. This typically encourages thinking of your features in term of category and sub-category. Things don’t exist in your architecture in complete disconnection from category (or shouldn’t be).

Focused concerns
If you’d like to implement feature A that affects all instances of feature B, then you don’t need to take instances of feature B into great consideration. You’d communicate with feature C, which manages instances of feature B. When it’s time to be specific or introduce variations, you’d define the action in the parent feature C to accommodate your requirements.

That way, feature A doesn’t need to be concerned with specifics of feature B, which successfully achieves separation of concern.

Easy state management
One issue with not having a hierarchy is that related features don’t share the same state as a source of truth. For example, you might deactivate all enemies in the scene for a cutscene, but forget about the specifics of an enemy spawner that doesn’t get deactivated.

With a hierarchy, you can have a source of truth for the entire hierarchy of your feature. If you have a class that manages all of your enemies, then an implementation where deactivating all enemy-related features becomes far easier.

Easy to establish architectural patterns
A common issue you might face without a hierarchy is that your architecture can be wired differently for each feature. Establishing patterns tend to be one of the most important practices in software architecture, as it makes it easier to revisit your features and not spend time re-familiarizing yourself with how everything was wired.

A hierarchy should be easy enough to follow. Top layer of your feature is where lifecycle is handled, bottom layer reports events back.

Criteria

Feature placement
The strength of this paradigm is that your feature naturally falls into a designated place in the architecture

Reference handling
This can vary, but a good practice to utilize here is letting the references trickle through your features all the way down. A common thing to do here is to assign the most common references on the top layer in the inspector, and pass it down to the features it manages.

For example, let’s say we have an enemy manager that manages multiple enemy controllers. You can initialize them with the needed references the enemy manager already has. That way, you can greatly simplify your inspector setup.

public class EnemyManager : Singleton<EnemyManager>
{
	[SerializeField]
	private EnemyGrid enemyGrid;
	[SerializeField]
	private BlocksContainer blocksContainer;
	//...
	private void Awake()
	{
		foreach(EnemyController controller in enemyControllers)
		{
			controller.Initialize(enemyGrid, blocksContainer);
		}
		//...
	}
	//...
}

Scalability
As you add new features, they’ll be managed by top layers or form their own top layers. If maintained properly, this style/paradigm can easily support many features down the hierarchy, as for the most part, you’re dealing with control on a higher layer.

Let’s see a sample hierarchy

Imagine we have two types of items in our game: power ups (activated immediately) and storable items (stored in inventory)

We can have an ItemsManager for managing all items in your game, and (controllers/managers/etc..follow the name convention that appeals more to you) for each type of item. Let’s say the reason why we have two controllers, is because their logic is very different, despite sharing the same lifecycle.

Each controller under the items manager has a list of items. Each could be its own class or a different prefab. This will depend on your requirements.

An immediate benefit becomes clear immediately: you already have all the references gathered in this example. You have a way of generalizing logic.

To understand the benefits of our architecture, let’s see some examples.

Example: Dependency

Say when you collect some items in the game, you get a special popup. Sometimes there’s an achievment. You also play a specific sound.

You might implement this method for handling pickup depending on the case:

public override void PickedUp()
{
	ui.ShowSpecialPopup(popupText, popupImage);
	audio.PlaySound(pickupAudio);
	achievments.TryRegisterAchievement(achievment);
}

This code is badly coupled and handles multiple responsibilities. Which is very very bad for scalability.

Imagine any of these scenarios:

  • You’re implementing a special mode where picking up this item shouldn’t trigger this popup
  • Your audio manager isn’t present in the scene
  • Your entire UI code changes completely, as it usually does throughout your development
  • You’re porting the game to another platform without any achievements…and you have 80 of these calls in your game

This is why your feature shouldn’t generally have dependencies on the presentation layer, or any other feature that’s supposed to be reacting to what’s happening in your feature. The dependency should be the other way around.

The other way around

A better solution would be to implement the other services to have our ItemsManager as their dependency. They can then listen to item pickup events.

Our item will simply take care of reporting the pickup event.

public event Action<Item> OnItemPickedUp;
//...
public void PickedUp()
{
	OnItemPickedUp?.Invoke(this);
}

Our ItemsManager will listen to these events and report them to any other interested service or system.

The dependent services that react to our features would then listen to that event and react. Here’s an example for how this might look like for the achievment system:

private void Awake()
{
	ItemManager.Instance.OnItemPickedUp += HandleItemPickedUp;
	//...
}
//...
private void HandleItemPickedUp(Item item)
{
	switch(item.Type)
	{
		case ItemType.StoneKey:
			TryRegisterAchievment(stoneKeyAchievment);
			break;
		//...
	}
}

Now, you can remove the achievements system from the game, or change the UI or sound without having to touch your features. Simply, our features should operate independent from unrelated or presentation features. New features could be built that react to our items without having to change a single line in our item classes.

Example: Reset!

Say when the player loses, we fade out, reset to the latest checkpoint in this scene, then fade in.

Surely, we have many other features. An example of that would be character interactions, weapons, character state, etc. So we need to find references to all instances of these features, and somehow reset them. But things could get more complicated than that.

For example, all the storable items are deterministic, so they just reset position. But all the power ups are randomized over specific locations.

How would that reset method look like?

public void ResetAll(CheckpointData data)
{
	// Gather references to all items somehow first
	//..
	foreach (var item in items)
	{
		if (item is SotrableItem)
		{
			ResetItemPosition(item, data);
		}
		else if (item is PowerUpItem)
		{
			// We also need the grid info from the scene
			ResetItemToRandomGrid(item, data);
		}
	}
	// ...TODO: Other features logic to reset
}

This reset method will quickly get messy, and I’m writing it in a very clean way. Not to mention, changing it to add new variations might introduce bugs to already stable code. It also violates the single-responsibility principle, where your class shouldn’t delve to this level of detail.

This has one major disadvantage that’s very crucial and typical. Let me highlight it as boldly as I can.

💡Note
An advantage of having a hierarchy is having references to instances of your features that are easily accessible in a place where they’re expected to be. You’ll find that maybe other features will need references to all items as well, in which case you’ll have to keep facing the same problem of finding those references (without sacrificing performance hopefully).

When you don’t have a hierarchy, you simply need to keep rewiring your architecture to get your references from somewhere. Which is not supposed to be a concern, especially when implementing an unrelated feature.

Now with hierarchy

In a parallel universe where we have a hierarchy, and we’re able to access the top layer of each feature, we can simply do something like this:

public void ResetAll(CheckpointData data)
{
	itemManager.Reset(data);
	npcManager.Reset(data);
	//...etc
}

If you’re feeling fancy, you can implement an interface IResettable on the concerned services. Maybe even have them grabbed dynamically in edit mode.

public void ResetAll(CheckpointData data)
{
	foreach(IResettable resettable in resettables)
	{
		resettable.Reset(data);
	}
}

The idea here is to implement a Reset() method for each concerned top-layer, and let it call the reset method on its child controllers. These controllers would then determine what resetting logic would best be suitable for the features they manage.

Now, your code lives in a designated place. You don’t really have to touch this reset method again. Every type of feature “resetting” can be changed without affecting the rest as well. And when it comes to implementing other features (E.g. make items non-interactable during cutscenes)…you don’t have to gather these references again.

Singleton use and abuse

The top layers in our architecture can be safely implemented as singletons. If you’re interested in learning more about singletons, I wrote a chapter about the design pattern which you can check out here.

Mentioning singletons usually generates a lot of debate, and I won’t be talking much about that in this chapter.

If your application is layered, however, they could become a powerful tool if you stick to the rule of not implementing anything in a lower layer as a singleton.

Loading

3.3 Layered Architecture Paradigm

The layered architecture style is one of the most common architecture styles in any kind of software development. Layering your application provides you with many benefits: allows your features to be manageable and traceable, separates lifecycle concerns from feature implementations, and removes concerns about code organization. If you’re starting out and you still don’t know how to handle scalability, integrating this paradigm into your architecture and sticking with it is extremely recommended. Besides being easy to understand, it allows you to have a very strong foundation for scalability.

💡Note
To fully utilize everything this paradigm has to offer, make sure you’re placing your features into a folder/namespace hierarchy as well. As your code base grows, maintaining a clear relationship between your features pays off generously as it heavily reduces the amount of fog surrounding the features you built months ago.

How it works

The idea here is to have a hierarchy of your features vertically. Think of an organization where you have a CEO above managers, managers managing leads, and leads managing the rest of employees. Clear cut responsibilities and a defined scoped for everyone in the organization is the theme here.

The top layer in your hierarchy can be imagined as a service that other features can call in order to perform an action or listen to an event. What do I mean by “service”?

It’s a concept inspired from web development. A common architectural design in web applications is to implement different “microservices” that take care of different responsibilities. For example, you might have a microservice for messaging, one for processing video, etc.

When you want to use one of these microservices, you don’t really need to think of their underlying logic. You’d pass the video microservice a video to process, and it gives you back an id to use, for example.

We can utilize this philosophy to manage our features. For example, the underlying save logic could change from using XML, to JSON, to utilizing a web service, the rest of our application shouldn’t be concerned about that. Other parts of your code should simply be able to ask your save system to save, wait until it’s done saving, then continue.

// Random example from a UI class that handles saving 
private IEnumerator SaveButtonPressedRoutine()
{
    yield return SaveSystem.SaveAsync();
    OnSaveUIFinished?.Invoke(); 
}

To see the full benefit of this style/paradigm, let’s set some ground rules based on these intentions.

Rules

  • Make sure your features are implemented in layers as much as possible
  • To access a child feature, communicate with the service
  • Service manages features, features manage sub-features, and so on
  • A class in an upper layer can access the lower layer it manages, lower layer fires events that the upper layer class listens to
  • Lower layer classes don’t reference a class in their upper layer

💡Note
We’ve talked about the dependency rule in another chapter, which is an extremely useful rule in software architecture. The dependency rule requires layered architecture, and layered architecture is utilized through the dependency rule. Make sure to check that out if you’d like to understand why the top layer references the layer below, and not the other way around.

Characteristics

Categorization
Every feature you implement is placed and managed under a higher layer. This typically encourages thinking of your features in term of category and sub-category. Things don’t exist in your architecture in complete disconnection from category (or shouldn’t be).

Focused concerns
If you’d like to implement feature A that affects all instances of feature B, then you don’t need to take instances of feature B into great consideration. You’d communicate with feature C, which manages instances of feature B. When it’s time to be specific or introduce variations, you’d define the action in the parent feature C to accommodate your requirements.

That way, feature A doesn’t need to be concerned with specifics of feature B, which successfully achieves separation of concern.

Easy state management
One issue with not having a hierarchy is that related features don’t share the same state as a source of truth. For example, you might deactivate all enemies in the scene for a cutscene, but forget about the specifics of an enemy spawner that doesn’t get deactivated.

With a hierarchy, you can have a source of truth for the entire hierarchy of your feature. If you have a class that manages all of your enemies, then an implementation where deactivating all enemy-related features becomes far easier.

Easy to establish architectural patterns
A common issue you might face without a hierarchy is that your architecture can be wired differently for each feature. Establishing patterns tend to be one of the most important practices in software architecture, as it makes it easier to revisit your features and not spend time re-familiarizing yourself with how everything was wired.

A hierarchy should be easy enough to follow. Top layer of your feature is where lifecycle is handled, bottom layer reports events back.

Criteria

Feature placement
The strength of this paradigm is that your feature naturally falls into a designated place in the architecture

Reference handling
This can vary, but a good practice to utilize here is letting the references trickle through your features all the way down. A common thing to do here is to assign the most common references on the top layer in the inspector, and pass it down to the features it manages.

For example, let’s say we have an enemy manager that manages multiple enemy controllers. You can initialize them with the needed references the enemy manager already has. That way, you can greatly simplify your inspector setup.

public class EnemyManager : Singleton<EnemyManager>
{
	[SerializeField]
	private EnemyGrid enemyGrid;
	[SerializeField]
	private BlocksContainer blocksContainer;
	//...
	private void Awake()
	{
		foreach(EnemyController controller in enemyControllers)
		{
			controller.Initialize(enemyGrid, blocksContainer);
		}
		//...
	}
	//...
}

Scalability
As you add new features, they’ll be managed by top layers or form their own top layers. If maintained properly, this style/paradigm can easily support many features down the hierarchy, as for the most part, you’re dealing with control on a higher layer.

Let’s see a sample hierarchy

Imagine we have two types of items in our game: power ups (activated immediately) and storable items (stored in inventory)

We can have an ItemsManager for managing all items in your game, and (controllers/managers/etc..follow the name convention that appeals more to you) for each type of item. Let’s say the reason why we have two controllers, is because their logic is very different, despite sharing the same lifecycle.

Each controller under the items manager has a list of items. Each could be its own class or a different prefab. This will depend on your requirements.

An immediate benefit becomes clear immediately: you already have all the references gathered in this example. You have a way of generalizing logic.

To understand the benefits of our architecture, let’s see some examples.

Example: Dependency

Say when you collect some items in the game, you get a special popup. Sometimes there’s an achievment. You also play a specific sound.

You might implement this method for handling pickup depending on the case:

public override void PickedUp()
{
	ui.ShowSpecialPopup(popupText, popupImage);
	audio.PlaySound(pickupAudio);
	achievments.TryRegisterAchievement(achievment);
}

This code is badly coupled and handles multiple responsibilities. Which is very very bad for scalability.

Imagine any of these scenarios:

  • You’re implementing a special mode where picking up this item shouldn’t trigger this popup
  • Your audio manager isn’t present in the scene
  • Your entire UI code changes completely, as it usually does throughout your development
  • You’re porting the game to another platform without any achievements…and you have 80 of these calls in your game

This is why your feature shouldn’t generally have dependencies on the presentation layer, or any other feature that’s supposed to be reacting to what’s happening in your feature. The dependency should be the other way around.

The other way around

A better solution would be to implement the other services to have our ItemsManager as their dependency. They can then listen to item pickup events.

Our item will simply take care of reporting the pickup event.

public event Action<Item> OnItemPickedUp;
//...
public void PickedUp()
{
	OnItemPickedUp?.Invoke(this);
}

Our ItemsManager will listen to these events and report them to any other interested service or system.

The dependent services that react to our features would then listen to that event and react. Here’s an example for how this might look like for the achievment system:

private void Awake()
{
	ItemManager.Instance.OnItemPickedUp += HandleItemPickedUp;
	//...
}
//...
private void HandleItemPickedUp(Item item)
{
	switch(item.Type)
	{
		case ItemType.StoneKey:
			TryRegisterAchievment(stoneKeyAchievment);
			break;
		//...
	}
}

Now, you can remove the achievements system from the game, or change the UI or sound without having to touch your features. Simply, our features should operate independent from unrelated or presentation features. New features could be built that react to our items without having to change a single line in our item classes.

Example: Reset!

Say when the player loses, we fade out, reset to the latest checkpoint in this scene, then fade in.

Surely, we have many other features. An example of that would be character interactions, weapons, character state, etc. So we need to find references to all instances of these features, and somehow reset them. But things could get more complicated than that.

For example, all the storable items are deterministic, so they just reset position. But all the power ups are randomized over specific locations.

How would that reset method look like?

public void ResetAll(CheckpointData data)
{
	// Gather references to all items somehow first
	//..
	foreach (var item in items)
	{
		if (item is SotrableItem)
		{
			ResetItemPosition(item, data);
		}
		else if (item is PowerUpItem)
		{
			// We also need the grid info from the scene
			ResetItemToRandomGrid(item, data);
		}
	}
	// ...TODO: Other features logic to reset
}

This reset method will quickly get messy, and I’m writing it in a very clean way. Not to mention, changing it to add new variations might introduce bugs to already stable code. It also violates the single-responsibility principle, where your class shouldn’t delve to this level of detail.

This has one major disadvantage that’s very crucial and typical. Let me highlight it as boldly as I can.

💡Note
An advantage of having a hierarchy is having references to instances of your features that are easily accessible in a place where they’re expected to be. You’ll find that maybe other features will need references to all items as well, in which case you’ll have to keep facing the same problem of finding those references (without sacrificing performance hopefully).

When you don’t have a hierarchy, you simply need to keep rewiring your architecture to get your references from somewhere. Which is not supposed to be a concern, especially when implementing an unrelated feature.

Now with hierarchy

In a parallel universe where we have a hierarchy, and we’re able to access the top layer of each feature, we can simply do something like this:

public void ResetAll(CheckpointData data)
{
	itemManager.Reset(data);
	npcManager.Reset(data);
	//...etc
}

If you’re feeling fancy, you can implement an interface IResettable on the concerned services. Maybe even have them grabbed dynamically in edit mode.

public void ResetAll(CheckpointData data)
{
	foreach(IResettable resettable in resettables)
	{
		resettable.Reset(data);
	}
}

The idea here is to implement a Reset() method for each concerned top-layer, and let it call the reset method on its child controllers. These controllers would then determine what resetting logic would best be suitable for the features they manage.

Now, your code lives in a designated place. You don’t really have to touch this reset method again. Every type of feature “resetting” can be changed without affecting the rest as well. And when it comes to implementing other features (E.g. make items non-interactable during cutscenes)…you don’t have to gather these references again.

Singleton use and abuse

The top layers in our architecture can be safely implemented as singletons. If you’re interested in learning more about singletons, I wrote a chapter about the design pattern which you can check out here.

Mentioning singletons usually generates a lot of debate, and I won’t be talking much about that in this chapter.

If your application is layered, however, they could become a powerful tool if you stick to the rule of not implementing anything in a lower layer as a singleton.

Loading

PHP Code Snippets Powered By : XYZScripts.com