Introduction
Over the past few months, I’ve been learning about Elixir. As a developer in the Ruby on Rails community, I’d heard lots of buzz about how Elixir was a smooth transition from Ruby! While I instantly noticed how alike Ruby and Elixir are in expressiveness and syntax, I was surprised at how different these two languages actually are, and how Elixir excels in scalability and fault-tolerance.
Ruby
Ruby is an object-oriented programming (OOP) language. In OOP, we use objects to encapsulate state and methods to manipulate that state. Everything is an object, even classes and methods.
Ruby is not strongly typed. Instead, it uses dynamic or “duck typing” (e.g. “if it acts like a duck, it’s a duck”). Read more about duck typing and polymorphism in this article. By definition, dynamically typed code is flexible.
As such, a developer new to the codebase may find it difficult to intuitively understand the design of the application and may end up duplicating features or misusing them. Ruby’s use of inheritance, callbacks, and other side effects can add complexity. Technical debt is common and tricky to clean up.
Data is often mutable, and changing an object’s state can unintentionally impact another part of the codebase that relies on it. Even a small change can mean lots of mental energy spent analyzing its effect on other parts of the code.
Additionally, dynamically typed code lends itself to bugs. Since Ruby is compiled at runtime, many bugs go undetected until a user encounters them in production, usually because of an edge case that wasn’t accounted for in development. This can add churn to a project, costing extra resources and time.
Elixir
Elixir is a functional and process-oriented programming language. In functional programming, the focus is on manipulating functions and passing immutable data between them. Everything in Elixir revolves around processes. While self
in Ruby is the current object, self()
in Elixir is the current process.
Unlike Ruby, Elixir is a compiled language. If you have a bug, you’re more likely to find out about it if your code won’t compile. With tools like ElixirLS, the feedback loop is even smaller, with inline error reporting that displays right in your editor or IDE. Discovering problems early can reduce churn, resulting in a product that reaches the market faster and generates more sales.
A developer new to an Elixir codebase will have a much easier time intuiting the control flow of the application, namely because Elixir is written with pure functions. Pure functions transform data input into output reliably, without unexpected side effects. In other words, running a pure function with the same input will always return the same output. Pure functions strung together in a pipeline are elegant and easily digestible. For example:
defmodule Routine do
def morning(%{level: 0} = energy) do
energy
|> wake_up()
|> drink_coffee()
|> walk_the_dog()
end
def wake_up(energy) do
%{energy | level: 1}
end
def drink_coffee(energy) do
%{energy | level: 2}
end
def walk_the_dog(energy) do
%{energy | level: 3}
end
end
We can clearly see each step in the flow and exactly what output is produced at each step.
While like Ruby, Elixir is not strongly typed, we can work around this by using named structs with pattern matching. Building on the previous example, the code below shows pattern matching in wake_up()
, drink_coffee()
, and walk_the_dog()
, which will only run if the input is an %Energy{}
struct. Take a look:
defmodule Energy do
defstruct level: 0
end
defmodule Routine do
def morning(%Energy{} = energy) do
energy
|> wake_up()
|> drink_coffee()
|> walk_the_dog()
end
def wake_up(%Energy{level: _} = energy) do
%{energy | level: 1}
end
def drink_coffee(%Energy{level: _} = energy) do
%{energy | level: 2}
end
def walk_the_dog(%Energy{level: _} = energy) do
%{energy | level: 3}
end
end
Another way to accomplish type safety is with typespecs. Typespecs are defined right above a function and indicate the type that can be passed in and the type that will be returned. For example:
@spec morning(%Energy{:level => any, optional(any) => any}) ::
%Energy{:level => 3, optional(any) => any}
def morning(%Energy{} = energy) do
energy
|> wake_up()
|> drink_coffee()
|> walk_the_dog()
end
These specs can be run as part of the app’s test suite and be configured to fail if a function doesn’t adhere to its typespec.
Ruby and Elixir
Despite differences, one reason Elixir feels similar to Ruby is because of their ecosystems and tooling. If you’re a Ruby developer with Rails experience, Phoenix will probably feel familiar. For example, view templates that are compiled as .erb
in Rails strongly resemble .eex
files in Elixir.
Where Rails uses Bundler for package management, Elixir uses Hex. Where Rails uses Rake for task management, Elixir uses mix. Ruby organizes code via classes and modules; Elixir uses modules and structs. Robust testing libraries are available for both languages, in which the syntax will feel similarly expressive.
Where Elixir Excels
When it comes to scalability and fault tolerance, Elixir truly excels. Processes in Elixir run concurrently, and each process manages its own memory. If a process fails, it doesn’t impact other processes, and we can use supervision to ensure that another process spins up to replace it. If application load increases, the number of processes can increase accordingly–for example, every request to a Phoenix application spawns a new process. Scaling comes at no extra cost to the developer, as resource distribution is handled automatically by the Erlang VM.
Ultimately, the right tool for the job will vary by project, but when it comes to solving problems of performance and scalability, Elixir is magic!
Resources for Learning
- The Pragmatic Programmers - Learn Functional Programming with Elixir by Ulysses Almeida
- The Pragmatic Programmers - Programming Elixir >/= 1.6 by Dave Thomas
- The Pragmatic Studio - Developing with Elixir and OTP
- The Coding Gnome - Elixir for Programmers
- Manning - The Little Elixir and OTP Guidebook
- Exercism.io Elixir Track
DockYard is a digital product consultancy specializing in user-centered web application design and development. Our collaborative team of product strategists help clients to better understand the people they serve. We use future-forward technology and design thinking to transform those insights into impactful, inclusive, and reliable web experiences. DockYard provides professional services in strategy, user experience, design, and full-stack engineering using Ember.js, React.js, Ruby, and Elixir. From idea to impact, we empower ambitious product teams to build for the future.