Software Architecture 101: What Makes it Good?
Introduction
So what is software architecture and why should you care? In this article, I hope to explore this idea and show you the benefits of good software structure and design. This article is intended for programming students or professionals with experience with game programming. I will be using C# as the demonstration language and Unity will be our reference Game Engine. Strictly speaking, this article will be as agnostic as possible to both—the main objective here is to explain what makes good architecture and what having good architecture can do for you and your projects. Perhaps after learning more about software architecture can even help you transition to becoming a software developer. So let’s get started.
What is Software Architecture
Unity is a fantastic game engine, however, the approach that new developers are encouraged to take does not lend itself well to writing large, flexible, or scalable code bases. This can apply to nearly all the major Game Engines. In particular, the default way that Unity manages dependencies between different game components can often be awkward and error prone. What we can do to prevent this outlines our project scope early on and using what we know from this stage, plan out a software design that will conform to our client and project needs.
One of the best truths I have learned from software development has to be that not even the client will know what they want. Generally, I find I could be given a list of must haves one week and by the following week, half of these might be the latest cuts from a project. Using a software design pattern can help mitigate the effects of drastic code base changes provided you are thinking about the client’s needs and have some grasp on the domain in which you are working.
(Watch: Best Practices in iOS Game Development & Architecture)
What is Good Software
Before we begin worrying about design principles, it would be good to start here and define what it is we are looking for. Software Architecture is pointless if we are not leveraging it to support our goals. And before we can leverage it, we need to know what is good software.
- Good software is functional. If any piece of software isn’t able to execute its core functionality then it’s useless.
- Good software is robust. What this means is that good software is resistant to changes around it and failures, it also means being able to recognize and deal with failures.
- Good software is measurable. This trait has taken time to grow on me personally. It should be possible to see how well the code is doing outside of a test environment. Usually, the best measures are how the software can facilitate the business needs. A good measure for a UI is how long does it take to load or react to an interaction.
- Good software is debuggable. This doesn’t mean being able to log everything for the heck of it but being able to bulk dump debug on demand can be very handy.
- Good software is maintainable. A software can be easy to maintain if it has consistent styling, good comments, is modular, etc. In fact, there is a lot of literature on good software design that just focuses on design principles that make it easy to make changes to parts of the software without breaking its functionality.
- Good software is reusable. Generalizing a solution can be hard and time-consuming. Obviously, we are all on deadlines so unless you are absolutely sure that you are going to reuse this piece of functionality elsewhere, you can time-bound the effort of making it reusable.
- Good software is extensible. Usually, the conversation starts with – “but suppose that tomorrow somebody wants to add X here…” software should be written with extension in mind, these extensions should be thought of in the most general of fashion. Like the general copy command on all OSs, it doesn’t care where you’re copying to or from these are extensions to the original program, making it immeasurably more valuable.
Good Class Structure
A. Single Responsibility
A responsibility is a reason to change. A class should have one, and only one reason to change. As an example, consider a class that compiles and prints a report. Imagine such a class can be changed for two reasons. First, the content of the report could change. Second, the format of the report could change. These two things change for very different causes; one substantive, and one cosmetic. The single responsibility principle says that these two aspects of the problem are really two separate responsibilities, and should, therefore, be in separate classes. It would be a bad design to couple two things that change for different reasons at different times.
The reason it is important to keep a class focused on a single concern is that it makes the class more robust. Continuing with that example, if there is a change to the report compilation process, there is a greater danger that the printing code will break if it is part of the same class.
B. Interfaces
An interface declares a contract. Implementing an interface enforces that your class will be bound to the contract (by providing the appropriate members). Consequently, everything that relies on that contract can work with your object, too. In other words, if you “Program to an Interface, not an Implementation” then you can inject different objects which share the same interface(type) into the method as an argument. This way, your method code is not coupled with any implementation of another class, which means it’s always open to working with newly created objects of the same interface. This—you will learn later—has major benefits such as conforming to the (Open/Closed) principle.
A common misconception that people new to Interfaces have is extracting interfaces from every class and using those interfaces everywhere instead of using the class directly. The goal is to make the code more loosely coupled, so it’s reasonable to think that being bound to an interface is better than being bound to a concrete class. However, in most cases the various responsibilities of an application have single, specific classes implementing them, so using interfaces in these cases just adds unnecessary maintenance overhead. Also, concrete classes already have an interface defined by their public members. A good rule of thumb instead is to only create interfaces when the class has more than one implementation.
What is Dependency Injection?
Dependency Inject is part of SOLID principles. In basic terms, it means resolving a class’s dependencies as late as possible. So what does that mean? DI isn’t the easiest principal to grasp but it is definitely a big step up in software design once you can understand it. I’m going to try to give a general example then we can look at specific implementations.
Let’s imagine you have a Game class. Let’s also imagine in this Game class you want to send an Email or any such service. A common approach would be to new up an EmailService directly inside the Game class.
public class Game
{
public void Main()
{
var emailService = new EmailService();
emailService.DoSomething();
}
}
What are the issues here? Are there issues here? The answers to these questions can vary wildly. It all depends on the context in which you are creating your game. If you are rapid prototyping this approach, this is perfectly valid as it does exactly what is needed. But would this suit a long term project, with a full development team? No. The reason this wouldn’t suit is the concreteness with which we have implemented our EmailService into our Game class. Later on during development, if one of the Team members needs to edit the EmailService, they could break the functionality inside the Game class without ever knowing until compile time. We would say the EmailService is tightly coupled to the Game class. I’m going to try and generalize what we’ve discussed here with an implementation example.
When writing an individual class to achieve some functionality, it will likely need to interact with other classes in the system to achieve its goals. One way to do this is to have the class itself create its dependencies, by calling concrete constructors.
public class Foo
{
IService = _service;
public Foo()
{
_service = new Service();
}
public void DoSomething()
{
_service.DoSomething();
}
}
This works fine for small projects, but as your project grows, it starts to get unwieldy. The class Foo is tightly coupled to class ‘Service’. If we decide later that we want to use a different concrete implementation, then we have to go back into the Foo class to change it. After thinking about this, often you come to the realization that ultimately, Foo shouldn’t bother itself with the details of choosing the specific implementation of the service. All Foo should care about is fulfilling its own specific responsibilities. As long as the service fulfills the abstract interface required by Foo, Foo is happy. Our class then becomes:
public class Foo
{
IService = _service;
public Foo(IService service)
{
_service = service;
}
public void DoSomething()
{
_service.DoSomething();
}
}
This is better, but now whatever class is creating Foo (let’s call it Bar) has the problem of filling in Foo’s extra dependencies:
public class Bar
{
public void DoSomething()
{
var foo = new Foo(new Service());
foo.DoSomething();
}
}
And class Bar probably also doesn’t really care about what specific implementation of Service Foo uses. Therefore, we push the dependency up again:
public class Bar
{
IService _service;
public Bar(IService service)
{
_service = service;
}
public void DoSomething()
{
var foo = new Foo(_service);
foo.DoSomething();
}
}
So we find that it is useful to push the responsibility of deciding which specific implementations of which classes to use further and further up in the ‘object graph’ of the application. Taking this to an extreme, we arrive at the entry point of the application, at which point all dependencies must be satisfied before things start. The dependency injection lingo for this part of the application is called the ‘composition root’. It would normally look like this:
var service = new Service();
var foo = new Foo(service);
var bar = new Bar(foo);
Benefits of Dependency Injection
There are many misconceptions about DI, due to the fact that it can be tricky to fully wrap your head around at first. I found it can take time and experience before it fully sinks in. As shown in the example above, DI can be used to easily swap different implementations of a given interface (in the example, this was ISomeService). However, this is only one of the many benefits that DI offers.
- Testability: Writing automated unit tests or user-driven tests becomes very easy because it is just a matter of writing a different ‘composition root’ which wires up the dependencies in a different way. Want to only test one subsystem? Simply create a new composition root.
- Refactorability: When the code is loosely coupled, as is the case when using DI properly, the entire code base is much more resilient to changes. You can completely change parts of the code base without having those changes wreak havoc on other parts.
- Encourages modular code: When using DI, you will naturally follow better design practices because it forces you to think about the contract between classes.
Drawbacks
For small enough projects, I would agree with you that using a global singleton might be easier and less complicated. But as your project grows in size, using global singletons will make your code unwieldy. Good code is basically synonymous with loosely coupled code, and to write loosely coupled code you need to:
(A) actually be aware of the dependencies between classes and
(B) code to interfaces (however, I don’t literally mean to use interfaces everywhere)
In terms of (A), when using global singletons, it’s not obvious at all what depends on what. And over time, your code will become really convoluted, as everything will tend towards depending on everything. There could always be some method somewhere deep in a call stack that does some hail mary request to some other class anywhere in your code base. In terms of (B), you can’t really code to interfaces with global singletons because you’re always referring to a concrete class.
With Dependency Injection, in terms of (A), it’s a bit more work to declare the dependencies you need up-front in your constructor, but this can be a good thing too because it forces you to be aware of the dependencies between classes.
And in terms of (B), it also forces you to code to interfaces. By declaring all your dependencies as constructor parameters, you are basically saying “in order for me to do X, I need these contracts fulfilled”. These constructor parameters might not actually be interfaces or abstract classes, but it doesn’t matter—in an abstract sense, they are still contracts, which isn’t the case when you are creating them within the class or using global singletons.
Then the result will be more loosely coupled code, which will make it 100x easier to refactor, maintain, test, understand, reuse, etc.
Let’s take an Example
Let us suppose we want to take user input into our game, we are currently developing on Desktop/Laptop machine but our game is intended for mobile. As you can probably already see there’s going to be some difficulty here when it comes to testing locally on the development machine and deploying to the mobile build as both are going to have independent ways to getting the user input. On the development machine, we will have a keyboard and mouse but on the mobile device, we might only have the touch screen for user input. How do we get around having two different user inputs and what’s the best way to manage this dual input in code? We might begin our input class like so.
public class UserInput
{
public float GetHorizontalAxis()
{
return Input.GetAxis(“Horizontal”);
}
}
So everything is perfect so far. We have our class that can manage user input that we can inject into our other classes as a dependency if need be. What would happen now if we want to mobile input?
Well from a pragmatic point of view the best option here would be to implement an interface first. We have multiple instances of user input both the Desktop Development case and the mobile case. Our interface could look something like this.
public interface IUserInput
{
float GetHorizontalAxis();
}
But how do we handle this in the dependent class? Rather than writing to a specific concrete class we can now write to an interface as the functionality is going to be guaranteed by that interface. So we can now change the dependent class to use the interface. So for example.
public class MyGame
{
private UserInput _userInput;
public MyGame(UserInput userInput)
{
_userInput = userInput;
}
}
Would become:
public class MyGame
{
private IUserInput _userInput;
public MyGame(IUserInput userInput)
{
_userInput = userInput;
}
}
Our previous UserInput class can now be updated like so.
public class DevUserInput : IUserInput
{
public float GetHorizontalAxis()
{
return Input.GetAxis(“Horizontal”);
}
}
Now when it comes to writing our extended functionality for our mobile case, we will have no problem in the implementation. Now that everything is conforming to our IUserInput, contract all we have to do now is let our new mobile user input class implement IUserInput as an interface.
public class MobileUserInput : IUserInput
{
public float GetHorizontalAxis()
{
(Specific functionality here...)
}
}
This is where we can see the true power of polymorphism at work now. We have two independent classes that will handle two very different use cases of the same problem. We have let both these concrete classes implement a common interface, this will now let us change between them without any hassle to the rest of the program. For example, in our game setup, we could do the following:
var myGame = MyGame(MobileUserInput); // For our mobile implementation.
var myGame = MyGame(DevUserInput); // For our development implementation.
So what have we learned
Hopefully, this has given you a taste for software design patterns and good principals. And when correctly used, these can help support a large, complex, and collaborative code bases. We looked at how a lot of software developers and programmers using Unity work, we talked about what can go wrong with some of the drawbacks—but also the benefits. As with any system or way of working, you will always encounter trade-offs, it is helpful to fully understand all possible implications before making a move.
Before we could look into architecture, though, we had to talk about good software, we looked at the characteristics of good software and explained how and why these are important. This then gave us a solid basis from which to build our architecture, once we’d established what we are trying to achieve. This fed us into Single Responsibility, the first principle of SOLID. We discussed single responsibility, trying to make it absolutely clear what a single function is and how we can recognize that. We looked at how it can be easy to confuse what we would instinctively see as a single object is actually a group of functionalities. We then looked at why it is better to break classes up by functionality, so change can only impact on a single functional basis. As I showed, this narrows down any single points of failure.
I explained what an Interface is, basically a glorified abstract class. We looked at the contract view of implementing an Interface as well as the benefits this can deliver. We also looked at over interfacing code. If a requirement doesn’t have more than one implementation, writing an interface for that class will only serve to add a code overhead—and this isn’t a pragmatic solution. This led us into Dependency Injection, were first, we looked at the common problems faced by large and complex code bases, most importantly, coupled classes. I explained how by using a DI approach, we can minimize coupling and code rigidity—the benefits this offers but also again the drawbacks. We saw how our code base would become flexible, testable, and refactorable. We also saw how in certain situations; this is actually a drawback.
The key thing is to analyze what your main goals are and to find the best project architecture that will support this. You have to think through the possible routes a project could take during development. If you can correctly identify this, picking a supportive architecture becomes a lot easier. This then, in turn, becomes the underlying structure which will support the convention, code norms, and give direction to any new members of the project. I hope looking at some of the most common approaches has helped you. But mostly, I hope it has helped shape your view of architecture and made it more relevant for your next project.
I hope this tutorial will help you to become a better software developer. You should also consider looking into software development tools and other tutorials to help you in your journey.
Author’s Bio
Stephen is a Software Developer with GameSparks, he has a passion for game development having completed his studies with Pulse College Dublin. He has previously worked to develop solutions for enterprise and is currently studying Information Systems with Trinity College Dublin.