5 min read
Your Code Should Explain Itself
How Module Attributes Turn Elixir Documentation Into Testable Code

Most comments have a short half-life.

You write one because the code needs context. Six weeks later, the code changes. The comment does not. Now the next person has two problems instead of one: they have to understand the code, and they have to decide whether the explanation can still be trusted.

Elixir takes a different path. Documentation is not a separate artifact that lives next to your program. It is part of the module, queryable from the shell, publishable as a reference site, and testable by your test suite. The mechanism behind all of this is one of the simplest features in the language: module attributes.

Module Attributes Live With the Module

A module attribute is a name attached to a value at compile time. It starts with @, and it belongs to the module being compiled.

defmodule Mama do
  @greeting "world"

  def hello do
    @greeting
  end
end

This looks a little like a variable, but it is not a runtime binding. The value of @greeting is available while the module is being compiled. By the time Mama.hello/0 runs, "world" has already been placed into the function.

Bruce puts the first version of the mental model this way:

“Module attributes are going to work a lot like bindings do.” — Bruce Tate

The important phrase is “a lot like.” You can rebind a module attribute as the compiler walks through the file, but that rebinding happens at compile time, not while the program is running.

defmodule Mama do
  @greeting "world"
  def hello, do: @greeting

  @greeting "universe"
  def hi, do: @greeting
end

Here Mama.hello() returns "world", while Mama.hi() returns "universe". Nothing changed at runtime. The compiler read the file from top to bottom, and each function captured the value that existed when it was defined.

That same mechanism powers Elixir documentation.

🎯 Join Groxio's Newsletter

Weekly lessons on Elixir, system design, and AI-assisted development -- plus stories from our training and mentoring sessions.

We respect your privacy. No spam, unsubscribe anytime.


Documentation Is Built on Attributes

When Mix creates a new module, you usually see two attributes immediately: @moduledoc and @doc.

defmodule Mama do
  @moduledoc """
  All things related to module attributes.
  """

  @doc """
  Returns a greeting.
  """
  def hello, do: "world"
end

These are not comments. They are known module attributes. Elixir recognizes them, stores their values with the compiled module, and makes them available to the documentation system.

That is why your code can participate in the same help system as the standard library.

“Just like I can say h Enum to give me more information about the Enum module, I could say h Mama. And this is going to give me the module documentation that I specified.” — Bruce Tate

Documentation becomes queryable at runtime. You can sit in IEx and type h Mama or h Mama.hello, and Elixir will show the documentation attached to that module or function. Later, ExDoc uses the same source to build HTML pages. You are not maintaining a separate manual that has to be synchronized by hand.

Examples That Prove Themselves

If you write an IEx-style example inside @doc, ExUnit can run it.

defmodule Mama do
  @greeting "world"

  @doc """
  Returns a greeting.

  ## Examples

      iex> Mama.hello()
      "world"
  """
  def hello, do: @greeting
end

Then your test file opts into doctests:

defmodule MamaTest do
  use ExUnit.Case
  doctest Mama
end

Run mix test, and you will see two dots: one for the test ExUnit generated, and one for the example in the doc. Bruce names what just happened:

“This is called a documentation test. If I run my test, I get a documentation test here.” — Bruce Tate

If Mama.hello() starts returning "universe" and the documentation still says "world", the doctest fails. Documentation stops being a comment about the code and becomes a claim the code has to honor.

Use Attributes for Names Worth Keeping

Custom module attributes are useful when a value deserves a name inside the module.

defmodule Lobby do
  @max_players 4

  def full?(players) do
    length(players) >= @max_players
  end
end

This is not a global variable. It is a compile-time label the module can use while it is being built. That makes it good for constants, configuration, and metadata the compiler or tools can inspect. Module attributes also support more advanced uses, including macro-heavy code, but you do not need macros to benefit from the idea.

The Practical Habit

When you write @doc, include the example you would naturally type in IEx.

Many functions only need a short sentence and one honest example:

@doc """
Normalizes a name for display.

## Examples

    iex> Names.display(" paulo ")
    "Paulo"
"""
def display(name) do
  name |> String.trim() |> String.capitalize()
end

Keep doctest Names in the test module. If the behavior changes, either the function or the example has to change with it. That is the whole point. Documentation is for humans, but it does not have to trust humans alone.

Closing

Once you see @doc as an attribute and not a comment, you stop writing documentation that hopes to stay accurate. You start writing documentation the compiler can check.

In the next post, we will look at the three ways to bring external clarity into your modules: alias, import, and require.

See you in the next chapter.


📚 Build Elixir Mental Models That Hold Up

This comes from our Learning Elixir course, where Bruce teaches the mental models behind module attributes, documentation, and testable examples through real-world scenarios. Learn the patterns that make Elixir code explain itself without second-guessing the structure later. Available via monthly subscription - try it for one month.

— Paulo & Bruce

Bruce Tate's avatar
Bruce Tate
System architecture expert and author of 10+ Elixir books.
Paulo Valim's avatar
Paulo Valim
Full-stack Elixir developer and educator teaching modern Elixir and AI-assisted development.