Writing Clean Code Part 2 || What is SOLID? How do I use it? Why do I care?

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!

39 Likes

Wow man! I really appreciate the work you have put into these posts! I haven’t thoroughly read through both yet, but thank you for sharing your knowledge!

1 Like

No problem, glad I could help! I am planning on making this series rather long, lots of stuff to go over! So I hope you stay tuned!

1 Like

Please continue this series. These are extremely informative and detailed posts. Tutorials related to actual concepts used in software engineering in the real world are extremely scarce on the Devforum and the community, including myself, will greatly benefit from this series. You do a very good job on conveying complicated topics in a manner that is easy to understand, great job on writing these posts!

1 Like

I appreciate that it really means a lot! When I find the time I will make Part 3, I expect to cover one of the design pattern types(creational, structural, ect…) and how we can use them in lua. I have not given up on this series, I just have been extremly busy with my work. We are currently in the process of rewriting almost all of our legacy solutions. This is a big task, especially because we spent the last month hotfixing issues with our first greenfield launch.

1 Like

I just got off work and have a few hours right now, Ill go get some coffee and go ahead and type up that article. It should be done by the end of the night.

1 Like

Another look at SOLID programming and the “7 deadly sins” of programming for those reading to consider if they are too lazy to read and rather watch/listen to something.

2 Likes

Preech, you are someone we need in this community! We need to make people strive for better code and discover things that they will use in a real job! Very much appreciated.

1 Like