We Tried Elixir, Liked It … And Decided Against It (For Now)

Steffen Wenz
Workpath
Published in
6 min readMar 14, 2022

--

Here at Workpath, our backend is a monolith written in Ruby on Rails, and that’s a great fit for our product. However, it has started to accumulate “cruft” around the edges. So we have started moving some features to separate services, as the first Outposts to our Citadel.

Many RoR startups add Elixir to their stack when at this juncture because, hey, a Rails core dev designed it! Curious about this technology, a few brave Workpath developers organized a hackathon to explore it. These are our thoughts!

The Syntax

As Rubyists, we’d like to think that we appreciate beautiful, concise code. There are two Elixir features that caught our eyes: Pattern matching and the pipeline operator.

Pattern Matching

Let’s take a concrete example. In Workpath, teams can define key results, which are success indicators for a specific goal. A key result can just be a TODO item that you tick off (not best practice!), or a metric, for example moving a KPI from a start to a target value (much better!).

Our goal is to try out Elixir. We’ll consider ourselves successful if we’ve done a hackathon, and gotten 100 claps on this Medium post 🤞

Let’s calculate the progress percentages from the screenshot above:

Even without knowledge of Elixir, it’s apparent this defines a struct with the fields done (in case it’s a TODO item) or start, target and actual (in case it’s a metric).

But why are there several progress() functions? Those are actually clauses of the same function, each with a pattern for the parameters they expect:

  • def progress(%KeyResult{done: true}) basically means “if progress() is called with a key result which has done set to true, use this clause.”
  • The third clause uses binding in order to make the values of start, target and actual available in the function body.

Pattern matching helps to keep function bodies very slim. Also, if you call progress("unexpected"), none of the clauses will match, and you’ll get an error without having to implement that check yourself. On the flip side, having too many overloaded clauses will make your code hard to read, especially since the order in which you define clauses changes semantics.

What’s really elegant about pattern matching in Elixir is that it works the same in all places it can be used: assignments, function clauses, and the case statement. Contrast that with Python, where pattern matching was recently bolted on to an existing language:

I didn’t realize how much of a mental burden this was until I saw this very consistent implementation in Elixir. JavaScript did a much better job keeping assignment and parameter destructuring the same, and there’s a proposal for a powerful match statement in the making.

Pipelines

We’re not done: Let’s implement calculation of progress at goal level, as an average of key results’ progress, so we get the “50%” from the screenshot above. We’re going to cap individual key result progress in the interval [0%, 100%] though, so that significant under/overachievement of key results doesn’t have a disproportionate effect on goal progress.

The pipeline operator |> simply takes the preceding expression, and injects it as the first argument in the function call that follows. Code is thus expressed as data flowing from one function to another. It’s often more readable than nesting many function calls, or assigning to a bunch of intermediate variables. Elixir’s Enum and Stream modules contain many helper functions for manipulating eager/lazy streams of data this way.

Here’s some code that tests the functions we’ve coded up, in case you’re interested.

The Concurrency

Elixir actually compiles to bytecode which runs on the Erlang virtual machine. Erlang first came out in 1986, so it predates Ruby by almost ten years, but its design is quite visionary:

  • Great concurrency model which allows for hundreds or thousands of light-weight processes to run efficiently, with a “shared nothing” memory model.
  • Fault tolerance: One typical coding style is to let individual processes crash instead of trying to catch every conceivable error, but then introduce supervisor processes which notice and deal with such failures.
  • Hot swapping: Reload code for part of an application without stopping it! In times of Kubernetes and green-blue deployment, this has lost some relevance, but is still kinda cool.

Elixir inherits these features, which makes it all the more attractive to Workpath, because Ruby does not have a great parallelism story. We want the languages in our stack to be diverse so they cover a larger problem space!

Ruby parallelism is mostly threads. Even Rails 7’s new load_async is implemented via thread pools, which need to be configured (wasn’t Rails against configuration?). But writing bug-free code with threads is hard, and they can’t fully exploit the machine’s cores due to the GIL (a lock which limits Ruby execution to one thread at a time).

The new Ractors in Ruby 3 will improve this situation if they see wider-spread adoption. Their design is very similar to Elixir processes: Ractors communicate by passing messages, not by sharing memory, and thus don’t have to share the same GIL. But since most Ruby objects are mutable, the language designers go through great lengths to make it safe to pass messages (distinguishing shareable and unshareable objects, differentiating copy and move semantics …). Just goes to show that immutability and parallelism go together like 🍞 and 🧈. And even Ractors are currently backed by OS threads, which require more resources than Elixir processes.

The Learning Curve

So Elixir is … hard to learn. It’s not a big language. The official guide is excellent, and the whole language specification can be read in a day. But there’s a few things which make it tough to approach:

  1. Elixir forces you to go “all in” on immutability and its concurrency model. Scala discourages, but lets you create mutable variables. Golang, whose goroutines are similar to Elixir processes, lets you write a single-threaded program until you need parallelism. In Elixir, it’s “my way or the highway”. Prepare to Google “supervision trees” and Agents even for simple programs.
  2. No OOP. Just like with mutability, if your brain is wired to think in terms of classes and objects, you’ll initially have a tougher time with Elixir. We caught ourselves typing kr.progress() all the time, long after understanding that in Elixir it’s KeyResult.progress(kr). The KeyResult module we coded earlier is a poster example for inheritance in Ruby, but in a functional language, you solve that differently. In fact, we wondered if Elixir’s superficial similarity to Ruby is helpful or detrimental for the learning curve.
  3. You need to learn Erlang, too, if you write serious Elixir code. At least to read documentation of Erlang libraries that you’re using from Elixir, or to decipher the errors that they throw.

The Verdict

We decided against adding Elixir to our stack because of its steep learning curve and the small number of developers that use it.

Ruby is already not the most mainstream language, something that I as CTO need to consider for our hiring plans. Elixir would only make this more difficult. And sure, we could teach Elixir to new and existing team members, but we’re a startup and don’t want to bear that cost right now.

Elixir’s ecosystem and community also aren’t as big as we know from Ruby. As a business, we need to consider this when making a part of our product depend on Elixir’s longevity.

However, while we learned that Elixir was not a shiny new general-purpose language, we liked it and saw that it really excels at one specific use case. And if we ever come across a problem that fits Elixir’s strengths, we’ll revisit this decision – choosing technologies is all about picking the right 🛠 for the job, after all!

→ Learn more about Workpath, or see our open Rails software engineer positions in 🍻 Munich, 🇩🇪 Berlin and 🇪🇸 Madrid

--

--