DEV Community

Cover image for Elixir SOLID Principles - Examples
Luan Gomes
Luan Gomes

Posted on

Elixir SOLID Principles - Examples

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.

Solid

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")
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)