Things to consider before starting an Elixir/Phoenix project

Context

I'm coming to the Elixir and Phoenix ecosystem as primarily a TypeScript, React, and Node.js using serverless Lambda functions) background. I've also used Java Spring at Amazon and Ruby on Rails at Make School and am well aware of the MVC pattern and the many benefits that a batteries-included framework provides.

I'm building an event management application that offers hosts a platform to manage events and attendees to attend with minimal fees. Elixir and Phoenix sound like a great choice for this because of its real-time capabilities and built-in resilience patterns built into the language itself. These notes are from spending a few weeks working towards a minimal viable product (MVP) to evaluate my productivity with the ecosystem, compared to my usual go-to infrastructure.

Cons

I'll start with the cons so we can end of the positives.

Lack of type inference on most libraries

Although Elixir has a way to define types using something like Dialyxir, it's opt-in and most methods and packages don't use it by default. It feels like JavaScript before the huge TypeScript wave and DefinitelyTyped hit and spoiled us all with thorough typings built-in to most popular libraries.

The one savior is that Elixir treats documentation as a first class citizen, and most libraries have detailed API documentation. It's a lot slower than having your editor tell you immediately with the red squiggly lines though and makes the dev experience less enjoyable than TypeScript or Java.

Lack of go to definition on using metaprogrammed modules

Elixir has a lot of metaprogramming concepts built-in to the language, similar to Ruby. Although it's really nice to write a single line of code to use or import a module to inherit many different methods and characteristics, it's not easy to know what methods are available without having to refer to the API documentation.

One of my most missed parts of TypeScript is doing an import like import * as someModule from 'some-module' and then typing someModule. and seeing all of the available options.

The go to definition in VS Code is also wonky when it comes to methods from these meta programming concepts. For example, when using something like Phoenix LiveView and calling assign_new, a method that's in the scope of the LiveView module, you can 't simply go to the definition of this method like you could in TypeScript. So I'll find myself going to the LiveView module definition and searching for the method itself to see the documentation and usage. Or of course, referring again to the API documentation.

Deployment is trickier than serverless (even with Releases and Docker)

Strong opinion here, but in my experience servers are more annoying to manage than serverless concepts like Lambda, AppSync, API Gateway, etc. with the AWS Cloud Development Kit (CDK) in most cases. On one hand it's cool to be able to just generate a new module for a new API route and deploy to your monolith without as much orchestration. But the initial setup is steeper to me and most applications don't need the additional complexity of managing the networking, Docker images, auto-scaling, etc. Especially not for my MVP.

The Elixir Releases pattern works pretty good and generates a Dockerfile that makes deployment much easier than doing it alone, but it leaves out things like installing NPM, your Node.js dependencies, etc. Fly.io has an similar experience as deploying a Ruby on Rails app to Heroku, but there was a bug on the deployment that was related to Postgres permissions that was up for a few weeks that blocked my deployments and made me lost trust in committing fully to them for production.

In the meantime of their bug fix, I explored Amazon Elastic Beanstalk, Elastic Container Service (ECS) with Elastic Container Registry (ECR), Gigalixir, and Heroku and didn't love the experience with any of them compared to AWS CDK and serverless.

Scattered documentation

Due to the nature of HexDocs, the documentation ecosystem for Elixir, things are mostly broken up from the API, but less so on guides like you'll find when learning a frontend framework like Angular, React, Svelte, etc. I've had this experience with other backend languages like Java and Kotlin too, but the problem with only having mostly API-focused documentation is that you have to know what you're doing and can't explore things as easily as a newcomer.

For example, when learning Phoenix LiveView, there are modules for rendering HTML, adding interactivity , testing, etc. As a fun experiment, try finding the LiveView documentation based on this screenshot of the Phoenix documentation home page under the "Guides" tab.

Phoenix documentation guides page

Or under the "Modules" tab:

Phoenix documentation modules page

Ok, if that wasn't hard enough, here's a link to the Live View documentation that I found by reading carefully through the entire page in the "Components and HEEx" section on the guides page. Now try to find out how to upload a file using the menu.

Phoenix LiveView modules page

As a comparison, let's do the same search for Sveltekit:

Sveltekit homepage

Right away you can see a section called form actions in the menu and dive deeper as needed without knowing the full API structure.

Testing outputs and framework

The LiveView testing framework outputs the entire HTML of the page as a single line, which almost reads like a minified version of the page. Compare this with something React testing library or Cypress and it's much more difficult to debug what the page looks like when a test fails.

I also miss the helpers from React testing library to get items by role in a more declarative way, for example: screen.getByRole('button', {name: "Submit'}). I created a module to help mimic some of this by the way, pasted below in case it's helpful:

defmodule CobraEvents.Support.ElementHelpers do
  import Phoenix.LiveViewTest

  use CobraEventsWeb.ConnCase

  def get_by_role(view, :form) do
    form = view |> element("form")
    assert has_element?(form)

    form
  end

  def get_by_role(view, :button, text_filter) do
    button = view |> element("button", text_filter)
    assert has_element?(button)

    button
  end

  def get_by_role(view, :textbox, text_filter) do
    label = view |> element("label", text_filter)

    textbox_id = Regex.run(~r/(?<=for=")\w+(?=")/, label |> render) |> Enum.at(0)

    input = view |> element("input##{textbox_id}")

    textbox = if has_element?(input), do: input, else: view |> element("textarea##{textbox_id}")

    assert has_element?(label)
    assert has_element?(textbox)

    textbox
  end

  def get_by_role(view, :alert, text_filter) do
    alert = view |> element("div[role=\"alert\"", text_filter)
    assert has_element?(alert)

    alert
  end
end
Elixir element helpers module example

I tried setting up Cypress and Playwright, but it's tricky since you can't easily reset a database and seed it with fixtures like you can when writing a test in Elixir. That makes the things you can test without Elixir severely limited.

Debugger setup

To be fair, I didn't spend much time on getting this set up. But it wasn't out of the box and didn't feel intuitive. Since it was an MVP for testing out the ecosystem I just stuck with the built-in Logger and throwing random errors to log to the console and pause where I wanted. For example:

# Stop execution here and have the console output the value
raise some_value

# Log to the console
require Logger
Logger.info(some_value)
Hacky debugging approaches without using an actual debugger

Inconsistent editor formatting

This is more of a nitpick, but like in Ruby, the parenthesis are optional. By default, Phoenix scaffolds your project without the parenthesis. When you have the default formatter set up using the Elixir extension in VS Code, however, it will add the parenthesis when it formats. causing different styles in the files you've changed and larger-than-necessary file diffs.

I tried changing the settings, but there I couldn't find any options to force it to ignore parenthesis unless required. And my final attempt to make things consistent was to run the formatter manually using Mix to get the whole codebase up to the standard format. But it ignored all of the existing files, meaning the mix formatter for the project was different than the editor formatter from the extension.

Pros

Let's end of the positives!

Elixir is beautiful

Just like with Ruby and Python, it's a real pleasure to work with Elixir as a language. The pattern matching, piping functions and simple syntax are beautiful to write and read.

Beautiful API documentation

HexDocs looks beautiful. Especially when compared to the run-of-the-mill auto-generated API documentation like AWS has or a Java library would use. Plus, since the language makes documentation a first-class citizen, in general I've found thorough API documentation on most packages I've used.

LiveView is fun

LiveView is an interesting concept and the most fun way I've written frontend code without a frontend framework like React. The JavaScript interop is clever and works great.

Although it's a bit of a learning curve since it's different than anything else that I've used before, it wasn't the hardest thing to learn. I definitely see the value for backend developers to create powerful frontends without being an expert on the latest and greatest frontend framework too.

The new Tailwind and ESBuild support works great

Tailwind and ESBuild are set up out of the box on new Phoenix projects and they work seamlessly. The built-in CoreComponents module is also a great example of how to write accessible components using the LiveView pattern and helps you learn by reading real-world examples.

Conclusion

This probably won't be my last time using Elixir and Phoenix, but it doesn't have all of the bells and whistles that I use every day with TypeScript and [insert frontend framework of choice] yet. And I won't be using it for Cobra Events.

My next MVP evaluation attempt will be using SST and Remix. Let's see how well that goes, and happy coding! SL