Before I get into the main part of this post, I want to make sure everyone has read Part 1 of this series. This talks about what System Architecture and Design are and why we should even care. It’s a very good read and will give the reader of an ample understanding of how to apply these future concepts to correct the problems mentioned in the last post.
Note: If you are willing, the study of these concepts is critical to understanding how to implement these principles. And this post is long, but it’s long for a reason. I needed to explain everything in great detail so every topic was covered. With that being said, happy reading!
Unlike the last post, this post will talk about one of the many technical aspects of implementing clean and well-structured, designed, and architected code. So, let us begin.
What is SOLID?
This is a question every developer, engineer, or programmer should ask. Simply put, SOLID is a set of 5 principles that are used in developing highly flexible, extendable, and maintainable Object Oriented code. Now, if you are wondering why we care about Object Oriented development, please refer to my series on OOP as I will not be discussing these concepts in this post. You can find the first part of this series here.
Now that we have an understanding of what SOLID is and where it can be used, let’s deep dive into why this is important and why we care.
The first principle of SOLID is the Single Responsibility Principle(SRP). There is a lot of common misinformation regarding this concept. If you are experienced with development, you may think SRP is the idea that code should only do one operation. These functions could be a function, module, class, etc… While this is a good idea to have when developing clean code, that’s not actually what this principle is indicating. This principle really means that a class or module should only have one responsibility per actor(object that it acts on). This gives it the name SRP.
Now, you may be wondering, what does this mean? And this is a good question. Let’s take a look at an example implementation of this concept.
Let’s say I wanted to make a game in which players can plant, grow, and harvest crops. With this, we can start to think of how this game will be structured.
With this, let’s first give an example that breaks SRP.
Note the usage of Pascal and Cammel case for public and private definitions.
Crop = {}
Crop.__index = Crop
function Crop.new()
local self = setmetatable({}, Crop)
self.growthTime = 0
self.harvested = false
self.watered = false
return self
end
function Crop:Grow(dt)
self.growthTime = self.growthTime + dt
if self.growthTime >= 10 and not self.harvested then
self.harvested = true
self:GiveRewards()
end
end
function Crop:Water()
self.watered = true
end
function Crop:GiveRewards()
-- Code to give rewards to the player who planted the crop
end
This implementation while clean looking actually violates the SRP principle. In this implementation, the Crop class has two critical responsibilities: managing the crop’s growth and harvesting of the crop and awarding the player for the harvest. If we think logically about this we can intuitively tell that the reward shouldn’t be coded with the crop class. Yet for some reason, problems like these plague modern code. We for some reason implement functionality to objects or modules that don’t have any relationship or belong in the context they exist. In this example here, we can see that, but if we sit down and think about it we can tell this will over time lead to a disaster. Remember, slow and steady wins the race, making a mess and cleaning it up will always be slower than doing it right the first time.
Let’s look at an example where we follow the SRP:
Crop = {}
Crop.__index = Crop
function Crop.new()
local self = setmetatable({}, Crop)
self.growthTime = 0
self.harvested = false
self.watered = false
return self
end
function Crop:Grow(dt)
self.growthTime = self.growthTime + dt
if self.growthTime >= 10 and not self.harvested then
self.harvested = true
end
end
-- CropReward class implementation
CropReward = {}
CropReward.__index = CropReward
function CropReward.new()
local self = setmetatable({}, CropReward)
return self
end
function CropReward:GiveRewards()
-- Code to give rewards to the player who planted the crop
end
Notice how this refactored code is different. We have two separate stand-alone classes that encapsulate the required logic. The Crop class only manages what should be managed in a crop(growth and harvest) and we added a new class, CropReward that handles the reward. This de-couples our code which means that we have functionality that is connected without having to directly associate or connected with their implementations. So, instead of having one class do everything, we have one class handle Crop logic, and one class handle reward logic.
Now, why do care about the Single Responsibility Principle?
Let’s take a look at some code:
Crop = {}
Crop.__index = Crop
function Crop.new()
local self = setmetatable({}, Crop)
self.growthTime = 0
self.harvested = false
self.watered = false
return self
end
function Crop:Grow(dt)
self.growthTime = self.growthTime + dt
if self.growthTime >= 10 and not self.harvested then
self.harvested = true
end
end
CropReward = {}
CropReward.__index = CropReward
function CropReward.new()
local self = setmetatable({}, CropReward)
return self
end
function CropReward:GiveRewards()
-- Code to give rewards to the player who planted the crop
end
Potato = {}
Potato.__index = Potato
setmetatable(Potato, {__index = Crop})
function Potato.new()
local self = setmetatable({}, Potato)
self.growthTime = 0
self.harvested = false
self.watered = false
return self
end
function Potato:Grow(dt)
self.growthTime = self.growthTime + dt
if self.growthTime >= 15 and not self.harvested then
self.harvested = true
end
end
This is a code snippet where we create a Potato class. Now, in the same instance, what if we wanted to have the potato give a different reward? Well, if we changed the implementation in the Crop class, it would change the implementation everywhere. So now, whatever changes we have will directly affect every crop that it’s inherited from. Instead, we can add a new rewards class to each object individually.
Here is the finished code:
CropReward = {}
CropReward.__index = CropReward
function CropReward.new()
local self = setmetatable({}, CropReward)
return self
end
function CropReward:GiveNormalReward()
return 150
end
function CropReward:GivePotatoReward()
return 500
end
Crop = {}
Crop.__index = Crop
function Crop.new()
local self = setmetatable({}, Crop)
self.growthTime = 0
self.harvested = false
self.watered = false
self.reward = CropReward.new()
return self
end
function Crop:Grow(dt)
self.growthTime = self.growthTime + dt
if self.growthTime >= 10 and not self.harvested then
return self.reward:GiveNormalReward()
end
end
Potato = {}
Potato.__index = Potato
setmetatable(Potato, {__index = Crop})
function Potato.new()
local self = setmetatable({}, Potato)
self.growthTime = 0
self.harvested = false
self.watered = false
self.reward = CropReward.new()
return self
end
function Potato:Grow(dt)
self.growthTime = self.growthTime + dt
if self.growthTime >= 15 and not self.harvested then
return self.reward:GivePotatoReward()
end
end
This act of not inheriting and instead attributing another class is called Composition. This is the idea that we parent-child classes in instances where there is no need for highly inherited logic. Because remember, inheritances cause tight coupling. The whole reason we used SRP was to remove the tight coupling to allow for more extendable and maintainable code.
These concepts can be used in a lot of other ways also. Let’s say you wanted to have a car that could mount a gun. You don’t want the fire method to be a part of the car object by default, rather, we want to use composition to use the gun class whenever the player decides to add it to the specific car.
So, why use SRP? Because it allows us to decouple our code which allows for more extendable and maintainable code.
Let’s talk about the Open Closed Principle(OCP). The OCP is the idea that we should be able to add new functionality to a software system without modifying the existing code. We actually used OCP with our Crop class. Remember how we inherited the Crop class and added additional functionality without changing its implementation? That is very important when thinking about highly scalable systems and especially when talking about system architecture and design.
For the sake of using another example, lets talk about some more code:
Player = {}
Player.__index = Player
function Player.new()
local self = setmetatable({}, Player)
self.speed = 10
return self
end
function Player:Move()
-- handle player movement
end
We have a player class here that handles the player’s movement in the game. This looks rather limited unless you know the true power of the OCP. Yes, we can’t change the concrete implementation of this code, but we can extend its usability.
PowerUp = {}
PowerUp.__index = PowerUp
setmetatable(PowerUp, {__index = Player})
function PowerUp.new()
local self = setmetatable({}, PowerUp)
return self
end
function PowerUp:Move()
self.speed = 20 -- increase player speed
Player.Move(self) -- call original Move method
end
Notice how we inherit from the player and change the speed without actually changing any code from the player. Now we can have power moveability and regular player speed. This implementation not only prevents us from possibly breaking the player object but also allows us to separate the logic in different places. This makes it easier to understand and debug in the long run.
The next principle I will be talking about is Liskov Substitution Principle(LSP). This is the idea that types(objects, Instances, Modules) must be able to be substituted for each other without affecting the correctness of the program.
You may be thinking, what the heck does this mean? Well, that is a good question, essentially whenever we create an object, if possible, we should try and make it usable in any context where the inherited or derived object is used.
Still don’t get it? Lets look at some code:
Vehicle = {}
Vehicle.__index = Vehicle
function Vehicle.new()
local self = setmetatable({}, Vehicle)
self.speed = 10
return self
end
function Vehicle:Drive()
-- handle vehicle movement
end
We have a vehicle class that contains a Drive method. This method logically would drive the Vehicle.
Car = {}
Car.__index = Car
setmetatable(Car, {__index = Vehicle})
function Car.new()
local self = setmetatable({}, Car)
return self
end
function Car:Honk()
-- play honking sound
end
Now, notice we use an abstract idea and implement it into the car class by inheritance. This is important to understand this principle. We must be able to use every child of the abstract vehicle class in the same place as the parent.
Here is some code showing what this means:
function DriveVehicle(vehicle)
vehicle:Drive()
end
local vehicle = Vehicle.new()
local car = Car.new()
DriveVehicle(vehicle)
DriveVehicle(car)
In this academic example, we instantiate the abstract vehicle class. Normally we wouldn’t want to create a vehicle object because its sole job is to be inherited by the actual objects. This is one of the pillars of OOP I talked about in my OOP series. Despite this, notice how we can call the DriveVehicle function without having to worry about it erroring. This is because we make sure that we allow for the substitution of the objects from the derived class.
Now, you may be asking, why do we care? Because this allows us to do wonderful things with our code. If we don’t have to worry about passing a module or an object through to a function, the possibilities are limitless.
Here is another example:
Character = {}
Character.__index = Character
function Character.new(startingHealth)
local self = setmetatable({}, Character)
self.Health = startingHealth
return self
end
function Character:TakeDamage(damage)
self.Health = self.Health - damage
if self.Health <= 0 then
self:Die()
end
end
function Character:Die()
-- handle character death
end
Character class contains take damage and die method.
Zombie = {}
Zombie.__index = Zombie
setmetatable(Zombie, {__index = Character})
function Zombie.new(startingHealth, isRageMode)
local self = setmetatable({}, Zombie)
self.Health = startingHealth
self.IsRageMode = isRageMode
return self
end
function Zombie:TakeDamage(damage)
if self.IsRageMode then
-- zombies in rage mode take half damage
damage = damage / 2
end
self.Health = self.Health - damage
if self.Health <= 0 then
self:Die()
end
end
Its usage:
function AttackCharacter(character, damage)
character:TakeDamage(damage)
end
local character = Character.new(100)
local zombie = Zombie.new(50, true)
AttackCharacter(character, 20)
AttackCharacter(zombie, 20)
This implementation will allow both NPCs and Players to attack each other without having to worry about creating an Adapter. The adapter pattern is a design pattern that I will discuss in more detail in my later posts.
The next principle I will discuss is the Interface Segregation Principle(LSP). Lua by itself does not support interfaces, but with the help of Luau we can theoretically implement them. Now, the reason for LSP is to create small, focused interfaces that are specific to current needs, rather than having large, monolithic interfaces that try to satisfy all needs. In Roblox Lua, we can implement ISP by creating interfaces with a specific set of methods that are relevant to each need and object that needs to use them
Lets first give an example that breaks LSP:
type IPlayer = {
new : () -> (any),
PickupItem : (self : any, {IsAdminOnly : boolean}) -> ()
}
local RegularPlayer : IPlayer = {} :: IPlayer
function RegularPlayer.new()
local self = setmetatable({}, {__index = RegularPlayer})
return self
end
function RegularPlayer:PickupItem(item : {IsAdminOnly : boolean}) : ()
if not item.IsAdminOnly then
-- pick up the item
else
error("Cannot pick up admin-only item")
end
end
local AdminPlayer : IPlayer = {} :: IPlayer
setmetatable(AdminPlayer, {__index = RegularPlayer})
function AdminPlayer.new()
local self = setmetatable({}, {__index = AdminPlayer})
return self
end
function AdminPlayer:PickupItem(item : {IsAdminOnly : boolean})
if not item.IsAdminOnly then
-- pick up the item
else
error("Cannot pick up admin-only item")
end
end
In this code snippet, we see we have a player interface that is implemented by both classes. While this is good in some instances, if we wanted to extend the functionality of the AdminPlayer, we would have to use the Open Closed Principle and another interface.
To fix this issue we can simply create two separate interfaces for each class that is catered specifically for the object its created for.
type IRegularPlayer = {
new : () -> (any),
PickupItem : (self : any, {IsAdminOnly : boolean}) -> ()
}
type IAdminPlayer = {
new : () -> (any),
PickupItem : (self : any, {IsAdminOnly : boolean}) -> (),
BanPlayer : (self : any, playerToBan : Player) -> (boolean)
}
local RegularPlayer : IRegularPlayer = {} :: IRegularPlayer
function RegularPlayer.new()
local self = setmetatable({}, {__index = RegularPlayer})
return self
end
function RegularPlayer:PickupItem(item : {IsAdminOnly : boolean}) : ()
if not item.IsAdminOnly then
-- pick up the item
else
error("Cannot pick up admin-only item")
end
end
local AdminPlayer : IAdminPlayer = {} :: IAdminPlayer
setmetatable(AdminPlayer, {__index = RegularPlayer})
function AdminPlayer.new()
local self = setmetatable({}, {__index = AdminPlayer})
return self
end
function AdminPlayer:PickupItem(item : {IsAdminOnly : boolean})
if not item.IsAdminOnly then
-- pick up the item
else
error("Cannot pick up admin-only item")
end
end
function AdminPlayer:BanPlayer(Player : Player) : boolean
--run ban logic here
return true
end
This snippet does exactly that, allowing us to have extended functionality from the restrictive interface.
Here is the implementation of using these classes:
local RegPlayer : IRegularPlayer = RegularPlayer.new()
RegPlayer:PickupItem({IsAdminOnly = true})
local Admin : IAdminPlayer = AdminPlayer.new()
Admin:BanPlayer(RegularPlayer)
The last principle I will discuss is the Dependency Inversion Principle. This principle is about decoupling high-level modules from their low-level counterparts. What does this mean? Well, let’s say we have a client that communicates with the server. On that server, we have a database, and on the client, we have a UI that relies on the data in the database. In that instance, we would need to separate the database module from the controller module, and the controller module from the client view. This idea is often associated with the Model-View-Controller (MVC) architectural pattern.
In MVC, the View communicates with the Controller to interact with the Model on the server and update the data. MVC is commonly used in enterprise and web applications. For example, Google’s website utilizes the MVC pattern. You might have unknowingly used MVC in Roblox or other development scenarios when you took user input, passed it to a controller module, and then forwarded that information to a module responsible for handling the database.
So, why does this matter? MVC introduces a separation of high-level and low-level layers. At the lowest level, we have the database layer, which deals with the data for the actors being acted upon. For instance, each player would have a unique player database.
The next level in this structure is the controller, which is responsible for updating the Model. The controller receives information from the View and acts upon the server-side Model to replicate or manipulate the existing data.
Finally, at the highest level, we have the View that the end user sees. The View is influenced by the Model and its updates are based on the input received from the Controller. For example, if the user interacts with a door, the door needs to be updated to open. Similarly, if the user interacts with a switch or a lever, a module or class relays that information to the server-side Model. In the case of user data, if the user picks up an item, the Controller notifies the server-side Model, which updates the inventory and consequently updates the client-side View.
In most cases, a gateway is involved in this process. A gateway acts as an application or class that solely handles the requests and directs them based on the query to a specific Controller responsible for acting upon the relevant actor. This concept aligns with the Single Responsibility Principle, where each component has a distinct responsibility.
Let’s look at two code examples of us using this principle:
In this module, we use the Facade design pattern to hide the complex logic and make it more extendable.
Here is an order processing module.
--!strict
-- Define types
export type Item = {
id : string,
price : number,
}
export type PaymentDetails = {
cardNumber : string,
expiryDate : string,
cvv : string,
}
export type Inventory = {
GetItem : (string) -> Item?,
CheckStock : (Item, number) -> boolean,
UpdateStock : (Item, number) -> nil,
}
export type PaymentProcessor = {
ProcessPayment : (PaymentDetails, number) -> boolean,
}
export type OrderFacade = {
Inventory : Inventory,
PaymentProcessor : PaymentProcessor,
PlaceOrder : (string, number, PaymentDetails) -> boolean,
}
-- Create OrderFacade class
local OrderFacade = {} :: OrderFacade
OrderFacade.__index = OrderFacade
function OrderFacade.new() : OrderFacade
local self = setmetatable({}, OrderFacade)
self.Inventory = Inventory.new()
self.PaymentProcessor = PaymentProcessor.new()
return self
end
function OrderFacade:PlaceOrder(itemID : string, quantity : number, paymentDetails : PaymentDetails) : boolean
local item = self.Inventory:GetItem(itemID)
if item and self.Inventory:CheckStock(item, quantity) then
local totalAmount = item.price * quantity
if self.PaymentProcessor:ProcessPayment(paymentDetails, totalAmount) then
self.Inventory:UpdateStock(item, quantity)
print("Order placed successfully.")
return true
end
end
print("Failed to place the order.")
return false
end
-- Create Inventory class
local Inventory = {} :: Inventory
Inventory.__index = Inventory
function Inventory.new() : Inventory
return setmetatable({}, Inventory)
end
function Inventory:GetItem(itemID : string) : Item?
-- Retrieve item from the database
end
function Inventory:CheckStock(item : Item, quantity : number) : boolean
-- Check if sufficient stock is available
end
function Inventory:UpdateStock(item : Item, quantity : number) : nil
-- Update the stock in the database
end
-- Create PaymentProcessor class
local PaymentProcessor = {} :: PaymentProcessor
PaymentProcessor.__index = PaymentProcessor
function PaymentProcessor.new() : PaymentProcessor
return setmetatable({}, PaymentProcessor)
end
function PaymentProcessor:ProcessPayment(paymentDetails : PaymentDetails, totalAmount : number) : boolean
-- Process the payment
end
-- Usage example
local facade = OrderFacade.new()
facade:PlaceOrder('123', 2, {cardNumber='...', expiryDate='...', cvv='...'})
local Interactables = require(game.ServerStorage.Interactables)
export type IFacade = {
InteractWith : (interactable : Interactables.IInteractable, player : Player) -> (),
}
local mod : IFacade = {} :: IFacade
function mod.InteractWith(interactable : Interactables.IInteractable, player : Player)
interactable:Interact(player)
end
return mod
In this module we create an interactable interface and use the factory design pattern to allow us to easily add new objects and keep everything consistant.
export type IInteractable = {
new : () -> (),
Interact : (self: any, Player : Player) -> (),
__index : any,
}
local Door : IInteractable = {} :: IInteractable
Door.__index = Door
function Door.new()
local self = setmetatable({}, Door)
return self
end
function Door:Interact(player)
-- handle door interaction
end
local Switch : IInteractable = {} :: IInteractable
Switch__index = Switch
function Switch.new()
local self = setmetatable({}, Door)
return self
end
function Switch:Interact(player)
-- handle switch interaction
end
local Lever : IInteractable = {} :: IInteractable
Lever.__index = Lever
function Lever.new()
local self = setmetatable({}, Lever)
return self
end
function Lever:Interact(player)
-- handle lever interaction
end
local objects = {
Lever = Lever;
Switch = Switch;
Door = Door;
}
return function(objectType) : IInteractable?
local object : IInteractable = objects[objectType] :: IInteractable
if not object then
return error("no object of that type")
end
return object.new()
end
Here is the concrete usage with dependency inversion.
local Interactables = require(game.ServerStorage.Interactables)
local Facade = require(game.ServerStorage.InteractionsFacade)
local door = Interactables("Door")
local switch = Interactables("Switch")
local lever = Interactables("Lever")
local Player = newproxy(true)
Facade.InteractWith(door, Player)
Facade.InteractWith(switch, Player)
Facade.InteractWith(lever, Player)
Now, you may be asking why we did this; Well, let’s talk about the code.
We have two modules that have two separate defined interfaces. We make sure to use the Single Responsibility Principle and the Liskov Substitution Principle extensively. We use these two things to our advantage by creating an interactable object factory module that is responsible for creating every interactable object in our game. This paired with our easy-to-use Interactions module/facade allows us to keep all the code for our interactable objects in one specific place. For example, if we wanted to add a new object to our factory module, all we have to do is define the class and implement the Interactable interface.
Here is an inventory MVC I coded. You can download this file to take a look. This is to show as a theoretical example, not a concrete implementation.
InvMVC.rbxl (48.0 KB)
So, what is SOLID and how do we use it? SOLID is a set of design principles that guide the programmer to develop well-structured and designed code. These are the principles that our entire software sector is built from. They are a way to code, design, and architect clean code.
To use it you need to study, chances are you won’t understand these concepts the first go around. But with time and practice you can gain a solid understanding of how to architect and design highly scalable systems.
You care because they help write highly scalable, sustainable, and highly productive code. If you read the last post, you would understand the importance of designing code to be scalable and sustainable.
Farewell, and until next time!