Enhancing Your Elixir Codebase with Gleam

katafrakt

Paweł Świątkowski

Posted on August 6, 2024

Enhancing Your Elixir Codebase with Gleam

Do you write Elixir but sometimes miss the benefits of type safety we have in other languages? If the answer is "yes", you may want to take a look at Gleam. It is a relatively young language that runs on top of the BEAM platform, which means it can be added as an enhancement to an Elixir codebase without you having to rewrite everything.

In this article, we will add Gleam code to an Elixir project.

Why Use Gleam for Elixir?

First, let's ask ourselves a question: why would we use Gleam? The Elixir ecosystem is mature at this point, with a multitude of great solutions for everyday problems. Ecto and Phoenix make a really powerful combination for writing web applications. Elixir itself is a dynamically typed language, which is great for some applications but opens the door to a whole class of errors.

Imagine you're working on a critical payment processing system in Elixir and need to ensure your core logic's absolute reliability
and correctness. Despite Elixir's strengths, its dynamic typing sometimes leaves room for subtle bugs. Enter Gleam. It's a statically typed language for the BEAM platform that promises to enhance your system's robustness. Where Elixir falls a bit short, Gleam can shine. Let's explore how to integrate Gleam with an Elixir project, so we can have the best from both languages.

We want our core business logic to be written in Gleam while the surrounding code is in Elixir (somewhat akin to the idea of a "functional core, imperative shell", although in the functional world, it's rather a "pure core, stateful shell"). We will take this idea very far, establishing a physical boundary between the two worlds.

Let's see how to get there.

About The Project

To witness the power of Gleam in action, we will implement a feature in a made-up application to manage students at a university. We will take care of their enrollment in courses. Our business rules will look as follows:

  • A student can be enrolled in a course.
  • Every course has a limited number of seats and a finite-length waitlist. If a student tries to enroll but all the seats are already taken, they are put on the waitlist (if there are spots there; if not — the enrollment is rejected).
  • If someone with a reserved seat cancels their enrollment, the first person on the waitlist takes their place.
  • Some courses have age limits: only students older than a certain age can enroll.

We will start the implementation by creating a fairly standard Phoenix and Ecto application:

mix phx.new student_roll
mix ecto.create
mix phx.gen.live Enrollment Student students name:string date_of_birth:date
mix phx.gen.live Enrollment Course courses name:string max_students:integer waitlist_size:integer min_age:integer
mix phx.gen.schema Enrollment.Enrollment enrollments student_id:integer course_id:integer waitlisted_at:datetime
mix ecto.migrate
Enter fullscreen mode Exit fullscreen mode

With this basic structure in place, we can add Gleam into the mix using the mix_gleam library. We will follow the steps from the project README. First, install mix_gleam:

mix archive.install hex mix_gleam
Enter fullscreen mode Exit fullscreen mode

Now prepare the project to use Gleam by adding some entries to mix.exs's project definition:

@app_name :student_roll

def project do
  [
    archives: [mix_gleam: "~> 0.6"],
    app: @app_name,
    compilers: [:gleam | Mix.compilers()],
    aliases: [
      "deps.get": ["deps.get", "gleam.deps.get"]
    ],
    erlc_paths: [
      "build/dev/erlang/#{@app_name}/_gleam_artefacts"
    ],
    erlc_include_path: "build/dev/erlang/#{@app_name}/include",
    prune_code_paths: false,
    # rest of the function
  ]
end
Enter fullscreen mode Exit fullscreen mode

Note that this assumes you are changing an existing mix.exs file generated by phx.new. These are required steps to make the Gleam code work with Elixir, as outlined in the documentation. You don't need to understand every single change here (I, for instance, don't). The important thing is erlc_path which tells the compiler where to find compiled Gleam files, so we can use them in our project.

Next, also in mix.exs, we need to add the Gleam standard library and testing framework to our dependencies:

defp deps do
  [
    # others
    {:gleam_stdlib, "> 1.0"},
    {:gleeunit, "~> 1.0", only: [:dev, :test], runtime: false}
  ]
end
Enter fullscreen mode Exit fullscreen mode

Finally, fetch dependencies and create the src directory where the Gleam code will live:

mix deps.get
mkdir src
Enter fullscreen mode Exit fullscreen mode

Let's Write Some Gleam

With all the setup in place, we can now start writing our business logic in Gleam! In the first iteration, we will skip the waitlist functionality, but we will still create a system that checks if enrollment is possible. Let's start with a src/enrollment.gleam file. src is the directory at the top level of the project, where mix_gleam expects you to keep your Gleam code.

We will put the following code in the file:

pub type Student {
  Student(id: Int, age: Int)
}

pub type Course {
  Course(id: Int, min_age: Int, seats: Int, seats_used: Int)
}

pub type RejectionReason {
  NoSeats
  AgeRequirementNotMet
}

pub type EnrollmentDecision {
  Enrolled
  Rejected(reason: RejectionReason)
}

pub fn enroll(student: Student, course: Course) -> EnrollmentDecision {
  case student.age >= course.min_age {
    False -> Rejected(AgeRequirementNotMet)
    True ->
      case course.seats > course.seats_used {
        False -> Rejected(NoSeats)
        True -> Enrolled
      }
  }
}
Enter fullscreen mode Exit fullscreen mode

There are quite a few things to unpack here, so let's take a quick crash course on Gleam.

What's Happening Here?

In the first part, we define a bunch of types. Types are the heart of typed languages. Here we need a Student, a Course, an EnrollmentDecision, and a RejectionReason.

Taking a Student declaration, we define not only a type itself but also its constructor, a Student with two arguments: id and age. You can note that we don't include the name here, even though we defined it before in the Elixir schema definition. The name is just side-data. We don't use it for anything related to business logic (unless you have a requirement, such as that only people with names starting with E can take a course).

A type like RejectionReason defines two constructors, which we can basically read as no seats left or the age requirement not being met.

At the end of the code block, we define an actual enroll function. It takes a student and a course, and using a case statement, makes some decisions about the pending enrollment command. case is the only flow control structure in Gleam (there is no if, for example). In this example, we first check the age requirement, and if it's met, we check if seats are left.

With that code ready, we can now write a failing unit test. Let's add a student_roll_test.gleam file in the test directory (this is a naming convention that mix_gleam expects). In that file, add the following:

import enrollment.{Course, Enrolled, Student}
import gleeunit

pub fn main() {
  gleeunit.main()
}

pub fn enrolled_test() {
  let course = Course(id: 1, min_age: 21, seats: 10, seats_used: 9)
  let student = Student(id: 10, age: 20)
  let assert Enrolled = enrollment.enroll(student, course)
}
Enter fullscreen mode Exit fullscreen mode

There's a bit of boilerplate here, but in essence, we create a test function that builds a course with id = 1, min_age = 21, seats_limit = 10 and seats_taken = 9.

Then, we create a student who is 20 years old (therefore too young to enroll). Finally, we try to enroll them. On running mix gleam.test, you get the error:

Running student_roll_test.main
F
Failures:

  1) enrollment_test.enrolled_test: module 'enrollment_test'
     #{function => <<"enrolled_test">>,line => 12,
       message => <<"Assertion pattern match failed">>,
       module => <<"enrollment_test">>,
       value => {rejected,age_requirement_not_met},
       gleam_error => let_assert}
     location: enrollment_test.enrolled_test:12
     stacktrace:
       enrollment_test.enrolled_test
     output:

Finished in 0.011 seconds
1 tests, 1 failures
Enter fullscreen mode Exit fullscreen mode

This is good, but not great. Let's improve the output by using gleeunit/should, a library that makes the unit testing experience better in Gleam:

import enrollment.{Course, Enrolled, Student}
import gleeunit
import gleeunit/should

pub fn main() {
  gleeunit.main()
}

pub fn enrolled_test() {
  let course = Course(id: 1, min_age: 21, seats: 10, seats_used: 9)
  let student = Student(id: 10, age: 20)
  enrollment.enroll(student, course)
  |> should.equal(Enrolled)
}
Enter fullscreen mode Exit fullscreen mode

Now, the output is much more readable:

Running student_roll_test.main
F
Failures:

  1) student_roll_test.enrolled_test: module 'student_roll_test'
     Values were not equal
     expected: Enrolled
          got: Rejected(AgeRequirementNotMet)
     output:

Finished in 0.011 seconds
1 tests, 1 failures
Enter fullscreen mode Exit fullscreen mode

Now, let's fix the test by calling it using a student who is old enough, and we will have a pass:

Running student_roll_test.main
.
Finished in 0.012 seconds
1 tests, 0 failures
Enter fullscreen mode Exit fullscreen mode

Now that we have the first test figured out, we should write some more tests to cover interesting branches in the code. But one important question still lingers: How do I call this from my Phoenix project?

Calling Gleam from Elixir

Let's start working on the Phoenix side of our project. Go to lib/student_roll/enrollment.ex and define two
functions related to the enrollment process:

def enrolled?(course_id, student_id) do
  from(e in Enrollment, where: e.student_id == ^student_id
    and e.course_id == ^course_id and is_nil(e.waitlisted_at))
  |> Repo.exists?()
end

def enroll(course_id, student_id) do
  student = get_student!(student_id)
  course = get_course!(course_id)

  # some logic should go here

  %Enrollment{}
  |> Enrollment.changeset(%{student_id: student.id, course_id: course.id})
  |> Repo.insert()
end
Enter fullscreen mode Exit fullscreen mode

We will write a test checking the enrollment logic:

describe "enrollment" do
  import StudentRoll.EnrollmentFixtures

  test "enroll a student" do
    birthday = DateTime.utc_now() |> DateTime.add(-18 * 365, :day)
    student = student_fixture(%{date_of_birth: birthday})
    course = course_fixture(%{min_age: 22})

    assert {:ok, _} = Enrollment.enroll(course.id, student.id)
    assert Enrollment.enrolled?(course.id, student.id)
  end
end
Enter fullscreen mode Exit fullscreen mode

And it passes! But it should not. We created a student who is roughly 18 years old, but the course requires that the students be at least 22. This is because we did not plug our Gleam-written business logic into the mix. Let's fix this now.

Calling compiled Gleam modules looks to Elixir the same way as Erlang modules. You invoke modules by prepending : to the module name. In our case, this will be :enrollment. We will also have to pass something that Gleam can interpret as its Student and Course types. This is the boilerplate I mentioned earlier.

def enroll(course_id, student_id) do
  student = get_student!(student_id)
  course = get_course!(course_id)

  student_age =
    DateTime.utc_now() |> DateTime.to_date() |> Date.diff(student.date_of_birth) |> div(365)

  seats_taken =
    Repo.aggregate(
      from(e in Enrollment, where: e.course_id == ^course_id and is_nil(e.waitlisted_at)),
      :count
    )

  case :enrollment.enroll(
        {:student, student.id, student_age},
        {:course, course.id, course.min_age, course.max_students, seats_taken}
      ) do
    :enrolled ->
      %Enrollment{}
      |> Enrollment.changeset(%{student_id: student.id, course_id: course.id})
      |> Repo.insert()

    {:rejected, reason} ->
      {:error, reason}
  end
end
Enter fullscreen mode Exit fullscreen mode

Our enroll function is slightly inflated, so let's walk through it.

First, we need some more data. We calculate the student age in Elixir here and also fetch the number of already enrolled students. Second, we build a structure that Gleam will interpret as its type. These are just tuples, where the first element is the name of a type, followed by several fields required by the constructor.

# Gleam: Student(id: Int, age: Int)
{:student, student.id, student_age}

# Gleam: Course(id: Int, min_age: Int, seats: Int, seats_used: Int)
{:course, course.id, course.min_age, course.max_students, seats_taken}
Enter fullscreen mode Exit fullscreen mode

Finally, we call :enrollment.enroll with these tuples. If we run the test now, we will get:

1) test enrollment enroll a student (StudentRoll.EnrollmentTest)
  test/student_roll/enrollment_test.exs:131
  match (=) failed
  code:  assert {:ok, _} = Enrollment.enroll(course.id, student.id)
  left:  {:ok, _}
  right: {:error, {:rejected, :age_requirement_not_met}}
  stacktrace:
    test/student_roll/enrollment_test.exs:136: (test)
Enter fullscreen mode Exit fullscreen mode

This is exactly what we wanted to have! The test does not pass because the student is too young.

pub type RejectionReason {
  NoSeats
  AgeRequirementNotMet
}

pub type EnrollmentDecision {
  Enrolled
  Rejected(reason: RejectionReason)
}


# in Elixir:
# :enrolled | {:rejected, :age_requirement_not_met} | {:rejected | :no_seats}
Enter fullscreen mode Exit fullscreen mode

We've made the first step. We called the Gleam code from Elixir, got the results back, and interpreted them back into Elixir idioms (a result tuple of {:ok, term()} | {:error, term()}).

We should now write any remaining tests we want to run in Elixir. Remember that at this point, the enrollment process is thoroughly unit tested in Gleam by Gleeunit, so perhaps we don't need to duplicate all the cases — only the ones that have consequences in Elixir (but this is up to you and your testing strategy).

After this, we can refactor the enroll function to look nicer and more reader-friendly.

defp get_gleam_student!(id) do
  student = get_student!(id)
  student_age =
    DateTime.utc_now() |> DateTime.to_date() |> Date.diff(student.date_of_birth) |> div(365)
  {:student, id, student_age}
end

defp get_gleam_course!(id) do
  course = get_course!(id)
  seats_taken =
    Repo.aggregate(
      from(e in Enrollment, where: e.course_id == ^id and is_nil(e.waitlisted_at)),
      :count
    )
  {:course, course.id, course.min_age, course.max_students, seats_taken}
end

def enroll(course_id, student_id) do
  course = get_gleam_course!(course_id)
  student = get_gleam_student!(student_id)

  case :enrollment.enroll(student, course) do
    :enrolled ->
      %Enrollment{}
      |> Enrollment.changeset(%{student_id: student_id, course_id: course_id})
      |> Repo.insert()

    {:rejected, reason} ->
      {:error, reason}
  end
end
Enter fullscreen mode Exit fullscreen mode

This looks better. You could even hide get_gleam_student!/1 and get_gleam_course!/1 in a module called, for example, GleamTypes. This way, other contexts that potentially call these structures in Gleam will have them readily available.

But that's not all. We still have some work to do.

Implementing the Waitlist

If you recall, our initial requirements also included a waitlist.

This is missing in the implementation above; now is the time to fix it. This will allow you to see how changing the code in both Gleam and Elixir works. I strongly recommend you change the logic in Gleam first, unit-test it, and only then, write the Elixir proxy code (although if you fancy some strict TDD, you can start with some red Elixir tests too).

But before diving into the code, let's ask ourselves one design question: How should we represent the waitlist? Should it be a number (waitlist_size) or maybe a list of students? This is the classic question in functional design and probably deserves a separate article to address it fully.

In our project, for already-signed-in students, we just pass a number of them, not a list of them. This time, we will model the waitlist as an actual list to make things more interesting.

First, we need to change our domain model by extending the Course type:

pub type Course {
  Course(
    id: Int,
    min_age: Int,
    seats: Int,
    seats_used: Int,
    waitlist: List(Student),
    max_waitlist_size: Int,
  )
}
Enter fullscreen mode Exit fullscreen mode

And then the list of enrollment decisions:

pub type EnrollmentDecision {
  Enrolled
  Rejected(reason: RejectionReason)
  Waitlisted
}
Enter fullscreen mode Exit fullscreen mode

After that, we have to adjust our existing Gleam tests because now they fail with the following message:

error: Incorrect arity
   ┌─ /home/user/dev/student_roll/_build/dev/lib/student_roll/test/student_roll_test.gleam:10:16
   │
10 │   let course = Course(id: 1, min_age: 21, seats: 10, seats_used: 9)
   │                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Expected 6 arguments, got 4

This call accepts these additional labelled arguments:

  - max_waitlist_size
  - waitlist
Enter fullscreen mode Exit fullscreen mode

And we also have to modify the Elixir glue code that calls Gleam:

defp get_gleam_student!(id) do
  get_student!(id)
  |> to_gleam_student()
end

defp to_gleam_student(student) do
  student_age =
    DateTime.utc_now() |> DateTime.to_date() |> Date.diff(student.date_of_birth) |> div(365)

  {:student, student.id, student_age}
end

defp get_gleam_course!(id) do
  course = get_course!(id)

  seats_taken =
    Repo.aggregate(
      from(e in Enrollment, where: e.course_id == ^id and is_nil(e.waitlisted_at)),
      :count
    )

  waitlist =
    from(e in Enrollment, where: e.course_id == ^id and not is_nil(e.waitlisted_at))
    |> Repo.all()
    |> Enum.map(&to_gleam_student/1)

  {:course, course.id, course.min_age, course.max_students, seats_taken, waitlist,
    course.waitlist_size}
end

# the enroll/2 function stays the same
Enter fullscreen mode Exit fullscreen mode

Now let's write a failing test in Gleeunit:

pub fn waitlist_exceeded_test() {
  let course =
    Course(
      id: 2,
      min_age: 0,
      seats: 10,
      seats_used: 10,
      max_waitlist_size: 2,
      waitlist: [
        Student(id: 1, age: 22),
        Student(id: 2, age: 13),
        Student(id: 3, age: 54),
      ],
    )

  let student = Student(id: 4, age: 22)

  enrollment.enroll(student, course)
  |> should.equal(Rejected(NoSeats))
}

pub fn waitlisted_test() {
  let course =
    Course(
      id: 2,
      min_age: 0,
      seats: 10,
      seats_used: 10,
      max_waitlist_size: 2,
      waitlist: [],
    )

  let student = Student(id: 4, age: 22)

  enrollment.enroll(student, course)
  |> should.equal(Waitlisted)
}
Enter fullscreen mode Exit fullscreen mode

One of these tests will accidentally pass (because we reused the NoSeats rejection reason, which might be a dubious choice). But the other one fails. Let's fix it now by actually implementing the waitlist. We need to import the gleam/list package on top and change the branch which previously resulted in NoSeats:

import gleam/list

# ...

pub fn enroll(student: Student, course: Course) -> EnrollmentDecision {
  case student.age >= course.min_age {
    False -> Rejected(AgeRequirementNotMet)
    True ->
      case course.seats > course.seats_used {
        False ->
          case list.length(course.waitlist) >= course.max_waitlist_size {
            True -> Rejected(NoSeats)
            False -> Waitlisted
          }
        True -> Enrolled
      }
  }
}
Enter fullscreen mode Exit fullscreen mode

The final part will be done in Elixir. We have decided that a person should go to the waitlist, but now we have to persist it. Again, let's start with a test:

test "waitlist a student" do
  student1 = student_fixture()
  student2 = student_fixture()
  course = course_fixture(%{max_students: 1, waitlist_size: 10})
  Enrollment.enroll(course.id, student1.id)
  Enrollment.enroll(course.id, student2.id)
  assert Enrollment.waitlisted?(course.id, student2.id)
end
Enter fullscreen mode Exit fullscreen mode

Then we need the waitlisted? function:

def waitlisted?(course_id, student_id) do
  from(e in Enrollment,
    where:
      e.student_id == ^student_id and
        e.course_id == ^course_id and not is_nil(e.waitlisted_at)
  )
  |> Repo.exists?()
end
Enter fullscreen mode Exit fullscreen mode

Finally, we implement the infrastructure part, persisting the waitlist in the database. In the
enroll function's case statement, we need to handle the :waitlisted return type case:

:waitlisted ->
  %Enrollment{}
  |> Enrollment.changeset(%{student_id: student_id, course_id: course_id, waitlisted_at: DateTime.utc_now()})
  |> Repo.insert()
Enter fullscreen mode Exit fullscreen mode

All the tests pass now. We have, therefore, successfully implemented a waitlist function in our Gleam/Elixir application!

Looking at the requirements, we still have some way to go. We haven't touched on canceling an enrollment (when a user has successfully enrolled in the past but is no longer interested). Hint: this handles moving a person from a waitlist to a regular enrollment, so we at least need this type as a return value:

pub type CancellingEnrollmentDecision {
  Cancelled
  CancelledWithWaitlistProcessed(Student)
}
Enter fullscreen mode Exit fullscreen mode

There's also a big topic that can be explored further but was not even covered in our requirements: how we should handle uniqueness. Everyone should be able to enroll in a given course only once, and they should not be able to cancel if they are not enrolled. There are at least three ways to model that behavior between Gleam and Elixir, but that's a topic for a separate article.

Why Did We Do It Again?

Everything we've done here could, of course, all have been written just in Elixir. The code would be shorter and in one language. In most cases, splitting the responsibilities between Elixir and Gleam probably would be a questionable choice. However, I personally think it's something worth considering if:

  • You like to model your application with types and are missing this from Elixir.
  • You expect your business logic to grow in complexity and want guarantees from a statically typed language to help you manage that complexity.
  • You want a strong separation between pure business logic and stateful application code.

If any of these are true for you, consider this arcane-looking setup. In any case, it's good to be aware that it is possible.

Wrapping Up

I have shown you how to call Gleam from an Elixir application and how to interpret the results. We wrote tests in both
languages and some glue code.

Happy coding, whether you choose to do it only in Elixir or you also make use of Gleam!

P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

💖 💪 🙅 🚩
katafrakt
Paweł Świątkowski

Posted on August 6, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related