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!

74 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

Thank you so much, this was absolutely AMAZING! I really struggle with organizing and structuring my code in roblox, especially since there isn’t any sort of “industry standard”. I am going to study this, and it makes a lot of sense to me how it’s better and organized, I will say all of my code does NOT follow this and I have experienced not wanting to touch the script because of it, thinking about “what if it was built like this?” seems 100x easier, faster, and smoother to add a new mechanic or something

1 Like

At some point, wouldnt it be better to just have a data module for stuff? Lets say you have 8 plants, instead of creating 8 plant classes, since they function the same, wouldnt it be better to have 1 plant class of this type and a data folder like so:

local PlantData = {
    Corn = {
        GrowthTime = 15,
        Reward = 10,
    },

    Potato = {
        GrowthTime = 20,
        Reward = 20,
    },

    Carrot = {
        GrowthTime = 10,
        Reward = 30,
    },
}
2 Likes

I love tables and modules, they are best :+1:

Sorry for bumping, but I don’t really understand OCP completely. Could you give an example of some code that breaks OCP?

-- Define types for our shapes
type Point = {
    x: number,
    y: number
}

type Shape = {
    type: string
}

type Circle = Shape & {
    center: Point,
    radius: number
}

type Rectangle = Shape & {
    topLeft: Point,
    width: number,
    height: number
}

-- Area calculator that violates OCP
local AreaCalculator = {}

function AreaCalculator.calculateArea(shape: Shape): number
    -- This function violates OCP because we need to modify this function
    -- every time we add a new shape type
    if shape.type == "circle" then
        local circle = shape :: Circle
        return math.pi * circle.radius * circle.radius
    elseif shape.type == "rectangle" then
        local rectangle = shape :: Rectangle
        return rectangle.width * rectangle.height
    else
        error("Unknown shape type: " .. shape.type)
    end
end

-- Usage example
local circle: Circle = {
    type = "circle",
    center = { x = 0, y = 0 },
    radius = 5
}

local rectangle: Rectangle = {
    type = "rectangle",
    topLeft = { x = 0, y = 0 },
    width = 10,
    height = 20
}

print("Circle area:", AreaCalculator.calculateArea(circle))
print("Rectangle area:", AreaCalculator.calculateArea(rectangle))

-- If we want to add a new shape, like Triangle, we would need to MODIFY
-- the existing calculateArea function, which violates the Open-Closed Principle

The right way:

-- Define a base Shape interface
export type Shape = {
    calculateArea: (self: any) -> number
}

-- Define specific shape types
export type Circle = Shape & {
    radius: number,
    center: {x: number, y: number}
}

export type Rectangle = Shape & {
    width: number,
    height: number,
    position: {x: number, y: number}
}

export type Triangle = Shape & {
    base: number,
    height: number,
    points: {{x: number, y: number}}
}

-- Shape factory module
local ShapeFactory = {}

-- Create a circle that implements the Shape interface
function ShapeFactory.createCircle(radius: number, center: {x: number, y: number}): Circle
    local circle = {
        radius = radius,
        center = center
    }
    
    function circle:calculateArea(): number
        return math.pi * self.radius * self.radius
    end
    
    return circle
end

-- Create a rectangle that implements the Shape interface
function ShapeFactory.createRectangle(width: number, height: number, position: {x: number, y: number}): Rectangle
    local rectangle = {
        width = width,
        height = height,
        position = position
    }
    
    function rectangle:calculateArea(): number
        return self.width * self.height
    end
    
    return rectangle
end

-- Create a triangle that implements the Shape interface
function ShapeFactory.createTriangle(base: number, height: number, points: {{x: number, y: number}}): Triangle
    local triangle = {
        base = base,
        height = height,
        points = points
    }
    
    function triangle:calculateArea(): number
        return 0.5 * self.base * self.height
    end
    
    return triangle
end

-- Area calculator that follows OCP
local AreaCalculator = {}

function AreaCalculator.calculateTotalArea(shapes: {Shape}): number
    local totalArea = 0
    
    for _, shape in ipairs(shapes) do
        -- No type checking or conditionals needed!
        -- Each shape knows how to calculate its own area
        totalArea = totalArea + shape:calculateArea()
    end
    
    return totalArea
end

-- Usage example
local myShapes = {
    ShapeFactory.createCircle(5, {x = 0, y = 0}),
    ShapeFactory.createRectangle(10, 20, {x = 5, y = 5}),
    ShapeFactory.createTriangle(8, 6, {{x = 0, y = 0}, {x = 8, y = 0}, {x = 4, y = 6}})
}

print("Total area of all shapes:", AreaCalculator.calculateTotalArea(myShapes))

-- Adding a new shape type (Pentagon) without modifying existing code
export type Pentagon = Shape & {
    sideLength: number,
    center: {x: number, y: number}
}

function ShapeFactory.createPentagon(sideLength: number, center: {x: number, y: number}): Pentagon
    local pentagon = {
        sideLength = sideLength,
        center = center
    }
    
    function pentagon:calculateArea(): number
        -- Formula for regular pentagon area
        return (5 * self.sideLength * self.sideLength) / (4 * math.tan(math.pi/5))
    end
    
    return pentagon
end

-- We can now use the pentagon without changing any existing code
table.insert(myShapes, ShapeFactory.createPentagon(10, {x = 15, y = 15}))
print("Updated total area with pentagon:", AreaCal

1 Like

Id agree if the functions were more complex, but for simple functions and classes like this id just add a “shape” field to Shape and then have a map {[shape]: (self: Shape) -> number} so each shape can be a pure struct, or heck id keep the if statement thing because it isnt even that hard to expand

Its the same thing I have for the part query jump table in simplezone, I dont need to make a whole wrapper for BaseParts to determine what query method they should use :V

Its important to remember luau is a multi paradigm language, so the best way to code is to use the best paradigm for the situation instead of being a purist

I use around 5 different coding paradigms in my game, like library-based oop (for simple mathematical/utility objects), closure-based oop (for classes with 1-3 methods), prototype based oop (for all decently sized classes), data oriented programming (for stuff that is performance intensive like needing alot of parts or tracking alot of items), functional programming (mostly for mathematical classes), pure structs with local functions, etc. It all works out because I use the paradigms where theyre needed, not randomly/everywhere :V

Same, I was gonna comment the same thing about the examples being “too simple” but then he could argue that it’s better that way so we could understand it more easily ( fair point, Agree ).
And we’re not supposed to copy the code in the examples but use it to get the general idea while reading the explanation.

Being experienced ( I have more experience being inexperienced ) is to know what to use and where to use it for the best result.
Experience doesn’t come from tutorials.
This tutorial is mostly for knowing the tools and how they are used, the rest is on the reader( where and when to use it ).

In my opinion OOP doesn’t work with simple stuff.
If you have a simple task and you do it using OOP your task changes and becomes more complicated ( 50 Lines of code and 2 functions → 200 Lines with 2 classes 4 methods and more properties )


Thx OP for making this.

2 Likes

It is nice this is being pushed into the algorithm. So many posts or other resources just have poor or horrible code practices which I feel will help many of those developers out.

1 Like

Hey,

It’s crucial to understand that programming paradigms serve a greater purpose beyond simply organizing logic. They are fundamental building blocks that enable teams to create scalable, maintainable, and extensible codebases. The choice of paradigms directly impacts how effectively teams can build upon and scale their software products .

The Challenge with Multiple Paradigms

While Luau and many modern languages support multiple paradigms, implementing five different paradigms within a single codebase presents significant challenges:

  1. Team Productivity Impact: Research shows that consistent coding standards and practices significantly enhance team productivity and collaboration . When multiple paradigms are used extensively, it becomes more difficult for team members to maintain this consistency and clarity.
  2. Onboarding Complexity: Studies indicate that clear coding standards reduce onboarding time and improve retention rates for new developers . Multiple paradigms create additional cognitive load for new team members, who must understand not just the codebase but also multiple ways of solving similar problems.

Scale and Performance Considerations

The scale at which enterprise systems operate demands careful consideration of architectural choices:

  1. Enterprise-Level Scale: While Roblox’s scale of hundreds of thousands to millions of requests per second is impressive, enterprise systems often handle hundreds of millions or billions of requests per second. At this scale, architectural decisions become even more critical .
  2. Performance Implications: Different paradigms have varying performance characteristics, especially in high-load scenarios. Research shows that the choice of paradigm can significantly impact system throughput and efficiency .

Code Maintainability and Technical Debt

The relationship between code maintainability and paradigm choice is well-documented:

  1. Maintainability Challenges: Studies show that using multiple paradigms can complicate code comprehension and maintenance . While multiparadigm programming offers flexibility, it also introduces complexity that can negatively impact maintainability if not managed properly .
  2. Long-term Sustainability: Case studies of successful large-scale software projects emphasize the importance of sustainable software architecture and consistent architectural decisions .

Best Practices for Enterprise Development

Industry standards and best practices suggest:

  1. Consistency Over Flexibility: While paradigms can be used anywhere, they shouldn’t be used everywhere. Enterprise-level software development requires consistent, standardized approaches that facilitate team collaboration and code maintenance .
  2. Strategic Architectural Choices: Successful large-scale projects demonstrate the importance of making strategic architectural decisions early and maintaining consistency throughout the development lifecycle .

Why This Approach is Problematic

Im not sure if you are suggesting the use of five different paradigms (library-based OOP, closure-based OOP, prototype-based OOP, data-oriented programming, and functional programming). If you are suggesting the mentioned idea, it demonstrates a misunderstanding of enterprise-scale development requirements:

  1. Scalability Concerns: At enterprise scale, the complexity of managing multiple paradigms can impede the ability to scale quickly and efficiently .
  2. Team Impact: The focus should be on what’s best for the entire team and future maintainers, not just the current lead engineer’s preferences or understanding .
  3. Clean Code Principles: Clean code principles emphasize consistency and simplicity. Using multiple paradigms unnecessarily complicates the codebase and violates these principles .

Thanks,

Samuel J. Taylor
Senior Software and Data Engineer
Walmart Global Tech

2 Likes

I must clarify that again, paradigms are only used when they need to be used. For example, in one of the games I’m working on, 90% of my codebase is prototype based oop, a small minority (~3%) is closure-based oop (because the class doesnt have many functions, im sure this isnt hard to understand at all either), ~5% is library-based oop, for mathematical objects and registries, and ~2% is data oriented programming (just for stuff like needing to track alot of items)

This isn’t actually inconsistent, as I have set criteria for when to use different paradigms. This means as the codebase expands, the same paradigms will be used for the same usecases.

The usage of different paradigms increases performance and usability in the long run too, as you can’t use 1 paradigm for everything and have it be optimal.

Plus, these paradigms aren’t too hard to learn. Library based oop is just normal oop but you put your functions in another table, closure based oop is just normal oop but you define the functions in the constructor, etc. Also, Luau uses library based oop quite a bit, so it’s easy to understand and use 100%.

Also, multiple paradigms doesn’t really violate clean code principles, if you code in Studio you’re using library based oop alot (table library, vector library, string library, etc.) and I’m sure you’ve never once thought it looks unclean nor complicated.

I appreciate that you put your time into this reply but this purist mindset is mostly why large codebases are unperformant/unusable in alot of areas. You just can’t use 1 paradigm for everything

2 Likes

Hey,

What are we calling programming paradigms; I am not 100% sure we agree to what they are.

thanks,

1 Like

I get that programming principles are necessary in some cases. But choose them based on the problem at hand.

I’d like to believe I’ve tried most programming principles, but when it comes to Luau specifically, procedural programming usually comes out on top. I use OOP rarely, or not at all for that matter, but it still has its place. I’ve really only followed SRP, and overall, I don’t have many issues with those collaborating.

Clean code is subjective in the end. And I would also believe most people that have read this post are those that are in this cycle of searching for ways to make their code cleaner but instead make the code more complicated than it has to be.

Everything changes for enterprise development. But this is Roblox.

For those that need (or want) it, this post is good. This response isn’t needed, and honestly, I’d rather not post on something that was quiet for 2 years, but it kept being revived, so I figured I’d just say this.

So, do whatever floats your boat. But programming paradigms are simply guides.

To whoever is reading this, please also remember that programming principles were made to solve a problem, not as a means to overcomplicate code that was fine to begin with. And then you began to continue to search for a solution to a problem that was created because of misuse of a principle.

2 Likes