elixir

Enhancing Your Elixir Codebase with Gleam

Paweł Świątkowski

Paweł Świątkowski on

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:

Shell
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

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:

Shell
mix archive.install hex mix_gleam

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

Elixir
@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

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:

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

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

Shell
mix deps.get mkdir src

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:

gleam
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 } } }

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:

gleam
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) }

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:

Shell
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

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:

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) }

Now, the output is much more readable:

Shell
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

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

Shell
Running student_roll_test.main . Finished in 0.012 seconds 1 tests, 0 failures

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:

Elixir
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

We will write a test checking the enrollment logic:

Elixir
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

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.

Elixir
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

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.

Elixir
# 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}

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

Shell
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)

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

gleam
pub type RejectionReason { NoSeats AgeRequirementNotMet } pub type EnrollmentDecision { Enrolled Rejected(reason: RejectionReason) } # in Elixir: # :enrolled | {:rejected, :age_requirement_not_met} | {:rejected | :no_seats}

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.

Elixir
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

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:

gleam
pub type Course { Course( id: Int, min_age: Int, seats: Int, seats_used: Int, waitlist: List(Student), max_waitlist_size: Int, ) }

And then the list of enrollment decisions:

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

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

Shell
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

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

Elixir
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

Now let's write a failing test in Gleeunit:

gleam
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) }

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:

gleam
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 } } }

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:

Elixir
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

Then we need the waitlisted? function:

Elixir
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

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:

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

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:

gleam
pub type CancellingEnrollmentDecision { Cancelled CancelledWithWaitlistProcessed(Student) }

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!

Paweł Świątkowski

Paweł Świątkowski

Our guest author Paweł is a mostly backend-focused developer, always looking for new things to try and learn. When he's not looking at a screen, he can be found playing curling (in winter) or hiking (in summer).

All articles by Paweł Świątkowski

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps