Be Careful When Using Elixir’s Module Attributes

Elixir’s module attributes are used in a few different ways.

First, there are all sorts of useful built-in attributes, like @doc, @moduledoc, @behaviour, etc. For the most part, these are all used for module metadata:

defmodule SomeModule do
@behaviour ExampleModule

@moduledoc """
Module documentation
"""

@doc """
Function documentation
"""
def some_function do
...
end
end

Second, module attributes can be used as constants:

defmodule SomeModule do
@some_constant 1234

def some_function(val) do
val / @some_constant
end
end

Using module attributes as constants, while sometimes useful, can also behave in unexpected ways.

The most important property of module attributes is that they are evaluated at compile time. Which means the following will almost certainly NOT behave correctly in a production environment:

defmodule SomeModule do
@some_constant System.get_env("API_KEY")
...
end

@some_constant will be assigned the value of of the API_KEY environment variable during compilation. The API_KEY environment variable at runtime will then be completely ignored.

Retrieving constants from mix configuration can also cause issues. The following is a popular pattern:

defmodule SomeModule do
@some_constant Application.get_env(:some_app, :some_setting)
...
end

This seems okay as long as you aren’t changing application settings at runtime using Application.put_env. However, in some cases (particularly when working with umbrella projects), a change to a configuration file will fail to force a recompile of the module. The module then continues to use the old config value.

This can lead to some really confusing problems that are only solved by doing a full rebuild of the project. This problem is most painful when encountered in a third party library.

Module attributes are also often used to facilitate mocks during testing:

defmodule SomeModule do
@some_other_module Application.get_env(:some_app, :other_module, SomeOtherModule)
end

This not only suffers from the usual problems with Application.get_env, but can also make it more difficult to write integration tests and unit tests side-by-side in the test environment.

If a function is used instead of a module attribute, it’s possible to dynamically change the module. Using a mock makes sense during a unit test, but for an integration test you’re likely to want the real thing:

defmodule SomeModule do
def some_other_module do
Application.get_env(:some_app, :other_module, SomeOtherModule)
end
end

defmodule SomeModuleTest do
describe "SomeModule.some_function/0 unit test" do
setup do
Application.put_env(:some_app, :other_module, MockModule)
end

test "it works" do
assert SomeModuleTest.some_other_module() === MockModule
end
end

describe "SomeModule.some_function/0 integration test" do
setup do
Application.put_env(:some_app, :other_module, RealModule)
end

test "it works" do
assert SomeModuleTest.some_other_module() === RealModule
end
end
end

In my opinion, it’s best to avoid using module attribute constants outside of simple values. The case could also be made for using them in places where performance is absolutely critical.

Using a module attribute does avoid the overhead of a function call. I’m willing to bet that, in the vast majority of cases, this small performance gain isn’t important.

Send this to a friend