Elixir SOLID Principles - Examples
Luan Gomes
Posted on December 25, 2021
Building an application can be hard depending on how you do it, as our career passes and the knowledge we get, more information and good practices we use for creating and improving the software we maintain.
Some good practices are in SOLID Principles, a mnemonic acronym for five design principles intended to make software designs more understandable, flexible, and maintainable.
Is good to be clear, SOLID has been created with Object-Oriented Programming in mind, so we are adapting to Elixir, a functional programming language, we are going to see that the benefits do not depend on the programming paradigm.
Example - Animals module
To explain better how we use the solid principles on elixir, I am gonna create a module and for each principle, we refactor it, the module is called Animals and is responsible to create an animal, adding some customization, getting and sending a picture of it.
The example below is the first version:
defmodule Animals do
def create_mammal(), do: #create an animal of type mammal
def create_carnivorous(), do: #create an animal of type carnivorous
def add_hat(animal), do: #add a hat to the animal
def add_shirt(animal), do: #add a hat to the animal
def get_picture(animal), do: #get picture from the animal
def send_picture_email(picture, email), do: #send the picture to email
def send_picture_whatsapp(picture, number), do: #send the picture to whatsapp
end
iex> {:ok, animal} = Animals.create_mammal()
iex> {:ok, animal_customized} = animal |> Animals.add_hat() |> Animals.add_shirt()
iex> :ok = animal_customized |> Animals.get_picture() |> Animals.send_picture_email("email@example.com")
if you noticed that this is not maintainable or scalable, you are right, in the first moment that could work, but responsibilities are mixed in a single module and are confusing.
The first principle that we are going to use is Single Responsibility, modules must be separated by their context.
Single Responsibility Principle
"There should never be more than one reason for a class to change."
defmodule Animals do
def create_mammal(), do: #create an animal of type mammal
def create_carnivorous(), do: #create an animal of type carnivorous
end
defmodule Animals.Clothes do
def add_hat(animal), do: #add a hat to the animal
def add_shirt(animal), do: #add a hat to the animal
end
defmodule Animals.Pictures do
def get_picture(animal), do: #get picture from the animal
def send_picture_email(picture, email), do: #send the picture to email
def send_picture_whatsapp(picture, number), do: #send the picture to whatsapp
end
iex> {:ok, animal} = Animals.create_mammal()
iex> {:ok, animal_customized} = animal |> Animals.Clothes.add_hat() |> Animals.Clothes.add_shirt()
iex> :ok = animal_customized |> Animals.Pictures.get_picture() |> Animals.Pictures.send_picture_email("email@example.com")
Now is more clear what each module does, but if we get the Animals.Pictures and try to add one more sending method, it starts to be a little repetitive and we break Open Closed principle, because we are modifying an entity.
Open Closed Principle
"Software entities ... should be open for extension, but closed for modification."
# FROM
defmodule Animals.Pictures do
def get_picture(animal), do: #get picture from the animal
def send_picture_email(picture, email), do: #send the picture to email
def send_picture_whatsapp(picture, number), do: #send the picture to whatsapp
end
#TO
defmodule Animals.Pictures do
def get(animal), do: #get picture from the animal
def send(picture, data, :email), do: #send the picture to email
def send(picture, data. :whats), do: #send the picture to whatsapp
end
iex> {:ok, animal} = Animals.create_mammal()
iex> {:ok, animal_customized} = animal |> Animals.Clothes.add_hat() |> Animals.Clothes.add_shirt()
iex> :ok = animal_customized |> Animals.Pictures.get() |> Animals.Pictures.send("email@example.com", :email)
In that way, we just create a new function send/3 without modifying the entity.
Another thing that can be improved is the module that creates the animals, currently, it has a general proposal, which is not extensible because we are changing the interface at each modification, sometimes is better to have a specific module that uses the same interface.
Interface segregation principle
"Many client-specific interfaces are better than one general-purpose interface."
# FROM
defmodule Animals do
def create_mammal(), do: #create an animal of type mammal
def create_carnivorous(), do: #create an animal of type carnivorous
end
#TO
defmodule Animals.Mammal do
def create(), do: #create an animal of type mammal
end
defmodule Animals.Carnivorous do
def create(), do: #create an animal of type carnivorous
end
iex> {:ok, animal} = Animals.Mammal.create()
iex> {:ok, animal_customized} = animal |> Animals.Clothes.add_hat() |> Animals.Clothes.add_shirt()
iex> :ok = animal_customized |> Animals.Pictures.get() |> Animals.Pictures.send("email@example.com", :email)
Now they are using the same interface and it is easy to extend each specific module without changing the interface that creates a new animal.
The Liskov principle states that a superclass object should be replaceable with a subclass object without breaking the functionality of the software and makes heavy use of inheritance and polymorphism, but for a functional programming language we have to deal with it in another way, elixir has behaviours, so all the modules that use the defined behaviour must implement their functions.
Liskov substitution principle
"Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it."
# FROM
defmodule Animals.Clothes do
def add_hat(animal), do: #add a hat to the animal
def add_shirt(animal), do: #add a shit to the animal
end
#TO
defmodule Animals.Animal do
@type t :: %{type: String.t, name: String.t}
defstruct ~w(type name)a
end
defmodule Animals.Clothes.Clothing do
@callback add(Animals.Animal.t) :: Animals.Animal.t
end
defmodule Animals.Clothes.Hat do
@behaviour Animals.Clothes.Clothing
def add(animal), do: #add a hat to the animal
end
defmodule Animals.Clothes.Shirt do
@behaviour Animals.Clothes.Clothing
def add(animal), do: #add a shirt to the animal
end
defmodule Animals.Clothes do
@spec apply(Animals.Animal.t, String.t)
def apply(animal, :hat), do: Animals.Clothes.Hat.add(animal)
def apply(animal, :shirt), do: Animals.Clothes.Shirt.add(animal)
end
iex> {:ok, animal} = Animals.Mammal.create()
iex> {:ok, animal_customized} = animal |> Animals.Clothes.apply(:hat) |> |> Animals.Clothes.apply(:shirt)
iex> :ok = animal_customized |> Animals.Pictures.get() |> Animals.Pictures.send("email@example.com", :email)
We have defined the Animals.Clothes.Clothing behaviour and for instance the Animals.Clothes.Hat must use the add/1 callback, so in this way, we guarantee that all the modules that implement it have the same action without breaking the functionality.
Now if we look at the Animals.Clothes module, we are explicitly declaring witch module has to be used depending on function parameters, this is incorrect accordingly with Dependency inversion principle, the correct way to fix this is to abstract the apply/2 function to receive a module in its arguments and use the function of that module because we have the type definition.
Dependency inversion principle
"Depend upon abstractions, [not] concretions."
# FROM
defmodule Animals.Clothes.Clothing do
@callback add(Animals.Animal.t) :: Animals.Animal.t
end
defmodule Animals.Clothes do
@spec apply(Animals.Animal.t, String.t)
def apply(animal, :hat), do: Animals.Clothes.Hat.add(animal)
def apply(animal, :shirt), do: Animals.Clothes.Shirt.add(animal)
end
#TO
defmodule Animals.Clothes.Clothing do
@type t :: module()
@callback add(Animals.Animal.t) :: Animals.Animal.t
end
defmodule Animals.Clothes do
@spec apply(Animals.Animal.t, Animals.Clothes.Clothing.t)
def apply(animal, clothing), do: clothing.add(animal)
end
iex> {:ok, animal} = Animals.Mammal.create()
iex> {:ok, animal_customized} = animal |> Animals.Clothes.apply(Animals.Clothes.Hat) |> |> Animals.Clothes.apply(Animals.Clothes.Shirt)
iex> :ok = animal_customized |> Animals.Pictures.get() |> Animals.Pictures.send("email@example.com", :email)
With the type definition, we can abstract the inner implementation to depend on the module itself, and we know what the module does because of the behaviour that it uses.
If we look for the difference between the first implementation and the final result, it is more verbose, but much more readable, maintainable and extensible.
# FROM
defmodule Animals do
def create_mammal(), do: #create an animal of type lion
def create_carnivorous(), do: #create an animal of type dog
def add_hat(animal), do: #add a hat to the animal
def add_shirt(animal), do: #add a hat to the animal
def get_picture(animal), do: #get picture from the animal
def send_picture_email(picture, email), do: #send the picture to email
def send_picture_whatsapp(picture, number), do: #send the picture to whatsapp
end
#TO
defmodule Animals.Mammal do
def create(), do: #create an animal of type mammal
end
defmodule Animals.Carnivorous do
def create(), do: #create an animal of type carnivorous
end
defmodule Animals.Pictures do
def get(animal), do: #get picture from the animal
def send(picture, data, :email), do: #send the picture to email
def send(picture, data. :whats), do: #send the picture to whatsapp
end
defmodule Animals.Animal do
@type t :: %{type: String.t, name: String.t}
defstruct ~w(type name)a
end
defmodule Animals.Clothes.Clothing do
@type t :: module()
@callback add(Animals.Animal.t) :: Animals.Animal.t
end
defmodule Animals.Clothes.Hat do
@behaviour Animals.Clothes.Clothing
def add(animal), do: #add a hat to the animal
end
defmodule Animals.Clothes.Shirt do
@behaviour Animals.Clothes.Clothing
def add(animal), do: #add a shirt to the animal
end
defmodule Animals.Clothes do
@spec apply(Animals.Animal.t, Animals.Clothes.Clothing.t)
def apply(animal, clothing), do: clothing.add(animal)
end
iex> {:ok, animal} = Animals.Mammal.create()
iex> {:ok, animal_customized} = animal |> Animals.Clothes.apply(Animals.Clothes.Hat) |> |> Animals.Clothes.apply(Animals.Clothes.Shirt)
iex> :ok = animal_customized |> Animals.Pictures.get() |> Animals.Pictures.send("email@example.com", :email)
I appreciate everyone who has read through here, if you guys have anything to add, please leave a comment.
This post was inspired by:
https://medium.com/@andreichernykh/solid-elixir-777584a9ccba
Posted on December 25, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.