DEV Community

Cover image for Learning Elixir: Creating Modules
João Paulo Abreu
João Paulo Abreu

Posted on

Learning Elixir: Creating Modules

I like to think of modules in Elixir as labeled drawers in a well-organized toolbox. Each drawer holds only the tools that belong together — one for cutting, one for measuring, one for fastening — and every tool has a clear home. Modules work the same way: they group related functions under a name, keep the workspace tidy, and make it easy to find exactly the tool I need. Without them, all of our functions would be a pile of loose parts on the bench. In this article, I will explore how to define modules with defmodule, create public and private functions, use module attributes as constants and metadata, see how modules map to files and directories, and build a small module from scratch to tie it all together.

Note: The examples in this article use Elixir 1.20.1. While most operations should work across different versions, some functionality might vary.

Table of Contents

Introduction

In our previous articles, I have been defining modules without stopping to explain them. Every time I wrote defmodule, I was creating a container — a labeled drawer — for a set of related functions. Modules are the backbone of code organization in Elixir, and understanding them well makes everything else easier.

What I learned about modules:

  • They group related functions — everything that belongs together lives in one place
  • They provide a namespaceString.upcase/1 and MyApp.upcase/1 can coexist without conflict
  • They have a name — module names are atoms that follow a CamelCase convention
  • They hide implementation details — private functions are only visible inside the module
  • They carry metadata — module attributes store constants and documentation
  • They map to files — a module usually lives in a file with a predictable name and path

I like to think of a module as the smallest unit of "how I organize my Elixir code". When I open a codebase, the first thing I look at is how the code is split into modules, because that tells me how the author thinks about the problem.

Understanding Modules

What Is a Module, Really?

A module is a named collection of functions. That is the whole idea. When I write:

defmodule Greeter do
  def hello do
    "Hello, Elixir!"
  end
end
Enter fullscreen mode Exit fullscreen mode

I am creating a container called Greeter with one function inside it, hello/0. After that, I can call the function through the module:

iex> Greeter.hello()
"Hello, Elixir!"
Enter fullscreen mode Exit fullscreen mode

Module Names Are Atoms

One of the first things that surprised me was that module names are actually atoms. Greeter is shorthand for the atom :"Elixir.Greeter". Elixir reserves an internal namespace for modules, which is why the full atom always starts with Elixir.

iex> Greeter == :"Elixir.Greeter"
true
Enter fullscreen mode Exit fullscreen mode

Because modules are atoms, I can use them in the same places I use atoms — as keys, in pattern matching, in data structures. This is a detail that becomes important later when I work with things like processes and registries.

Naming Conventions

Module names follow the CamelCase convention: each word starts with an uppercase letter, and words are joined without spaces or underscores.

defmodule ShoppingCart do
end

defmodule UserProfile do
end

defmodule Reports.Generator do
end
Enter fullscreen mode Exit fullscreen mode

I found it helpful to think of the dots in Reports.Generator as a way to create a hierarchy or namespace. It reads like a path — reports, the generator. This is different from function and variable names, which are in snake_case.

Defining a Module with defmodule

The Basic Structure

The defmodule keyword starts a module definition. It takes the module name and a do block containing the functions:

defmodule Math do
  def add(a, b) do
    a + b
  end

  def multiply(a, b) do
    a * b
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Math.add(2, 3)
5

iex> Math.multiply(4, 5)
20
Enter fullscreen mode Exit fullscreen mode

Note: If I paste the same defmodule twice in the same IEx session, Elixir shows a redefining module warning. It is harmless while exploring — the new definition simply replaces the old one.

Functions Inside a Module

Every function I have used so far in this series — Enum.map/2, String.upcase/1, Map.fetch/2 — lives inside a module. When I define my own functions with def, I am adding them to the module's collection of tools.

The signature name/arity is how Elixir refers to a function. The arity is the number of arguments:

iex> function_exported?(Math, :add, 2)
true

iex> function_exported?(Math, :multiply, 2)
true
Enter fullscreen mode Exit fullscreen mode

function_exported?/3 checks whether a module exports a given function. I found this handy when exploring unfamiliar libraries — it tells me whether a function exists before I try to call it.

Function Clauses and Pattern Matching

A single function name can have multiple clauses. Elixir tries them in order and uses pattern matching to pick the first one that matches:

defmodule FizzBuzz do
  def convert(n) when rem(n, 15) == 0, do: "FizzBuzz"
  def convert(n) when rem(n, 3) == 0, do: "Fizz"
  def convert(n) when rem(n, 5) == 0, do: "Buzz"
  def convert(n), do: n
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> FizzBuzz.convert(3)
"Fizz"

iex> FizzBuzz.convert(5)
"Buzz"

iex> FizzBuzz.convert(15)
"FizzBuzz"

iex> FizzBuzz.convert(7)
7
Enter fullscreen mode Exit fullscreen mode

This builds directly on the pattern matching and guard ideas from our earlier articles. All four clauses belong to the same function convert/1, and the guards decide which clause runs for each input.

Public and Private Functions

def vs defp

So far I have only used def, which creates a public function — anyone can call it from outside the module. Elixir also has defp, which creates a private function that only the module itself can call.

defmodule StringFormatter do
  def format_name(first, last) do
    capitalize(first) <> " " <> capitalize(last)
  end

  defp capitalize(word) do
    String.capitalize(word)
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> StringFormatter.format_name("joao", "silva")
"Joao Silva"
Enter fullscreen mode Exit fullscreen mode

Here format_name/2 is public — the tool users reach for. capitalize/1 is private — an internal helper that only the module needs.

Why Keep Functions Private?

I like to think of private functions as the parts of the drawer that are meant to stay inside. The public functions are the handle you pull, the private ones are the internal springs and levers.

  • It communicates intent — readers see what the module exposes on purpose
  • It hides details — I can change internal helpers without breaking callers
  • It prevents misuse — a helper may assume invariants that external callers would not respect

What Happens When I Call a Private Function?

If I try to call a private function from outside, Elixir raises an UndefinedFunctionError. From the outside, the function does not exist at all:

iex> StringFormatter.capitalize("joao")
** (UndefinedFunctionError) function StringFormatter.capitalize/1 is undefined or private
Enter fullscreen mode Exit fullscreen mode

And function_exported?/3 reports false for private functions, confirming they are not part of the module's public API:

iex> function_exported?(StringFormatter, :format_name, 2)
true

iex> function_exported?(StringFormatter, :capitalize, 1)
false
Enter fullscreen mode Exit fullscreen mode

Private Helpers in Practice

Private functions shine when a public function needs several small steps. The public function reads like a summary, and the private functions hold the details:

defmodule Temperature do
  def describe(celsius) do
    label = classify(celsius)
    "#{celsius}°C is #{label}"
  end

  defp classify(temperature) when temperature <= 0, do: "freezing"
  defp classify(temperature) when temperature < 25, do: "mild"
  defp classify(_temperature), do: "hot"
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Temperature.describe(-5)
"-5°C is freezing"

iex> Temperature.describe(15)
"15°C is mild"

iex> Temperature.describe(35)
"35°C is hot"
Enter fullscreen mode Exit fullscreen mode

I noticed the private function classify/1 uses guards and clauses just like a public one. The only difference is visibility, not behavior.

Module Attributes as Constants

Setting an Attribute

Module attributes are a way to attach metadata to a module. The syntax uses @name value inside the module. The most common use I have found is defining constants:

defmodule AppConfig do
  @app_name "my_app"
  @max_attempts 5
  @retry_delays [100, 200, 500]

  def app_name, do: @app_name
  def max_attempts, do: @max_attempts
  def retry_delays, do: @retry_delays
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> AppConfig.app_name()
"my_app"

iex> AppConfig.max_attempts()
5

iex> AppConfig.retry_delays()
[100, 200, 500]
Enter fullscreen mode Exit fullscreen mode

Attributes Are Evaluated at Compile Time

The important detail is that attributes are resolved when the module is compiled, not when the function runs. This makes them efficient — the value is baked into the compiled function. It also means I can compute the value once at compile time:

defmodule Limits do
  @max_limit 100
  @half_max div(@max_limit, 2)

  def max_limit, do: @max_limit
  def half_max, do: @half_max
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Limits.max_limit()
100

iex> Limits.half_max()
50
Enter fullscreen mode Exit fullscreen mode

Here @half_max is computed from @max_limit during compilation, and both are frozen into the module. I found this mental model helpful: attributes are compile-time values, functions are runtime behavior.

Attributes Can Be Read Inside Functions

I have been writing def max_attempts, do: @max_attempts — a function that returns the attribute value. This is a common pattern: expose a constant to the outside world through a public function.

Attributes can also be used directly inside function bodies, not just as the return value:

defmodule Greeting do
  @greeting "Hello"

  def greet(name) do
    "#{@greeting}, #{name}!"
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Greeting.greet("world")
"Hello, world!"
Enter fullscreen mode Exit fullscreen mode

The @greeting attribute is available anywhere inside the module's functions.

Attributes Are Not Variables

One thing I had to unlearn: attributes are not variables. I cannot reassign them at runtime or use them as mutable state. Once the module is compiled, the attribute is just a value the compiler substituted. If I want changeable state, that is a different mechanism entirely — one we will meet when we talk about processes.

I also learned there is a set of reserved attributes that Elixir itself understands, like @moduledoc, @doc, and @spec. We will look at documentation attributes right now, and the deeper set in the next article.

Documenting Modules and Functions

@moduledoc

The @moduledoc attribute stores the documentation for the whole module. Tools like ExDoc use it to generate documentation sites, and developers use it to understand what a module does:

defmodule StringUtils do
  @moduledoc "Utilities for working with strings."

  def upcase(string), do: String.upcase(string)
  def downcase(string), do: String.downcase(string)
end
Enter fullscreen mode Exit fullscreen mode

The value is usually a string, and for longer descriptions Elixir developers use a heredoc — three double quotes — to span multiple lines:

defmodule Calculator do
  @moduledoc """
  Provides basic arithmetic operations.

  Every function returns an integer and raises on invalid input.
  """

  def add(a, b), do: a + b
  def subtract(a, b), do: a - b
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Calculator.add(1, 2)
3

iex> Calculator.subtract(5, 3)
2
Enter fullscreen mode Exit fullscreen mode

@doc

The @doc attribute documents a single function. It appears right above the function it describes:

defmodule TitleCase do
  @moduledoc "Converts titles to title case."

  @doc """
  Capitalizes each word in the given string.

  ## Examples

      iex> TitleCase.title("hello world")
      "Hello World"
  """
  def title(string) do
    string
    |> String.split(" ")
    |> Enum.map(&String.capitalize/1)
    |> Enum.join(" ")
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> TitleCase.title("hello world")
"Hello World"
Enter fullscreen mode Exit fullscreen mode

I found @doc valuable because the documentation lives right next to the code it describes. When I change a function, the doc is there to update too.

Documentation Is Metadata

Both @moduledoc and @doc are metadata — they do not change what the functions do. They exist to be read by other tools and by people. I like to write a short @moduledoc for every module I create, even small ones, because it forces me to summarize the module's purpose in one sentence.

We will go much deeper into documentation attributes and the rest of the reserved attributes in the next article.

How Modules Map to Files and Directories

One Module Per File

In practice, Elixir projects follow a simple rule: one module per file, and the file name matches the module name. This is a convention, not a requirement — the compiler does not care — but following it makes a codebase much easier to navigate.

The conversion from module name to file name is straightforward:

  • Greeter lives in lib/greeter.ex
  • UserProfile lives in lib/user_profile.ex
  • MyApp.Payments.Processor lives in lib/my_app/payments/processor.ex

The CamelCase module name becomes a snake_case file name, and the dots become directories.

The lib Directory

A standard Mix project creates a lib directory where application code lives. Let me show the structure of a typical project:

my_app/
├── lib/
│   ├── my_app.ex
│   ├── my_app/
│   │   ├── payments/
│   │   │   ├── processor.ex
│   │   │   └── invoice.ex
│   │   └── users.ex
├── mix.exs
├── test/
└── README.md
Enter fullscreen mode Exit fullscreen mode

Reading this layout tells me a lot about the application before I open a single file:

  • lib/my_app.ex defines MyApp — often the entry point
  • lib/my_app/payments/processor.ex defines MyApp.Payments.Processor
  • lib/my_app/users.ex defines MyApp.Users

I like to think of the lib directory as the toolbox itself, and each .ex file as one drawer.

Why This Convention Matters

The predictable mapping between modules and files makes navigation almost automatic. When I need to look at MyApp.Payments.Invoice, I already know roughly where to find it without searching. This is one of those conventions that looks like a small detail but saves a lot of time in a large project.

Nested Modules and Namespacing

Dots Create Namespaces

I mentioned that dots in module names create a hierarchy. Let me look at how that actually works:

defmodule Outer do
  defmodule Inner do
    def hello, do: "inner"
  end
end
Enter fullscreen mode Exit fullscreen mode

This defines two modules: Outer and Outer.Inner. They are independent — Outer does not need to contain Inner, and defining one does not require the other:

iex> Outer.Inner.hello()
"inner"
Enter fullscreen mode Exit fullscreen mode

Defining Them Separately

The same namespace can be built from a single flat definition. A file named lib/my_app/payments/processor.ex would contain:

defmodule MyApp.Payments.Processor do
  def process, do: :done
end
Enter fullscreen mode Exit fullscreen mode

Whether I nest the definitions or write them flat, the resulting module is MyApp.Payments.Processor. Nesting is mostly a readability choice; the dots are what matter.

Inspecting Module Names

Since module names are atoms with structure, Elixir provides helpers to work with them. Module.split/1 breaks a name into its parts, and Module.concat/1 joins parts back together:

iex> Module.split(Outer.Inner)
["Outer", "Inner"]

iex> Module.split(MyApp.Payments.Processor)
["MyApp", "Payments", "Processor"]

iex> Module.concat(["MyApp", "Payments", "Processor"])
MyApp.Payments.Processor
Enter fullscreen mode Exit fullscreen mode

I found these helpers useful for understanding module names and for building them dynamically.

MODULE

Inside a module, __MODULE__ refers to the current module's atom. It is a shortcut for writing the module name explicitly:

defmodule Example do
  def current, do: __MODULE__
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Example.current()
Example

iex> Example.current() == :"Elixir.Example"
true
Enter fullscreen mode Exit fullscreen mode

__MODULE__ is handy when a function needs to refer to its own module — for example, to build fully qualified names or to pass itself to another function.

Alias for Convenience

Within a module, I can use alias to refer to another module by a shorter name. We will explore alias, import, and require in detail in a later article, but a quick example shows the idea:

defmodule MyApp.Invoice do
  alias MyApp.Payments.Processor

  def run do
    Processor.process()
  end
end
Enter fullscreen mode Exit fullscreen mode

Without the alias, I would write MyApp.Payments.Processor.process() every time. The alias keeps the code shorter without hiding where the function comes from.

Building a Module from Scratch

Let me put everything together by building a small module from scratch: a simple in-memory to-do list. It will use module attributes as constants, public and private functions, and the tagged tuple pattern from our error handling articles.

The Complete Module

Here is the whole module in one piece. After the code block I will walk through each part:

defmodule TodoList do
  @moduledoc """
  A simple in-memory todo list built to practice module organization.
  """

  @initial_status :pending
  @done_status :done

  def new, do: []

  def add(todos, task) when is_binary(task) and byte_size(task) > 0 do
    {:ok, [{task, @initial_status} | todos]}
  end

  def add(_todos, task), do: {:error, {:invalid_task, task}}

  def list(todos), do: {:ok, Enum.reverse(todos)}

  def done(todos, task) do
    todos = mark_as(todos, task, @done_status)
    {:ok, todos}
  end

  def pending(todos) do
    todos
    |> Enum.filter(fn {_task, status} -> status == @initial_status end)
    |> Enum.reverse()
  end

  defp mark_as(todos, task, status) do
    Enum.map(todos, fn
      {^task, _current} -> {task, status}
      item -> item
    end)
  end
end
Enter fullscreen mode Exit fullscreen mode

Step 1: Define the Module and Its Constants

I start with the module name, a @moduledoc, and the attributes that represent the states of a task. These constants appear at the top of the module, where they are easy to find and change:

@initial_status :pending
@done_status :done
Enter fullscreen mode Exit fullscreen mode

I use the attributes inside the public functions instead of repeating the atoms everywhere. If I ever want to rename a status, I change it in one place.

Step 2: Add Public Functions

Next, the public API. I want to create a list, add tasks, list tasks, mark tasks as done, and see pending tasks. I use tagged tuples so the module follows the conventions from our error handling article:

  • new/0 returns an empty list
  • add/2 prepends a task with the initial status, or returns {:error, {:invalid_task, task}} for invalid input
  • list/1 reverses the list back to insertion order
  • done/2 marks a task as done
  • pending/1 filters tasks that still have the initial status

Step 3: Hide Implementation Details

The done/2 function delegates the status change to the private helper mark_as/3. Keeping it private communicates that this is an internal detail — callers only need to know that done/2 exists, not how it works:

defp mark_as(todos, task, status) do
  Enum.map(todos, fn
    {^task, _current} -> {task, status}
    item -> item
  end)
end
Enter fullscreen mode Exit fullscreen mode

The helper uses pattern matching with the pin operator from our pattern matching article: each task whose text matches gets the new status, and every other task stays as it is.

Testing the Module

Let me test the module step by step in IEx:

iex> todos = TodoList.new()
[]

iex> {:ok, todos} = TodoList.add(todos, "Write module article")
{:ok, [{"Write module article", :pending}]}

iex> {:ok, todos} = TodoList.add(todos, "Test examples in IEx")
{:ok, [{"Test examples in IEx", :pending}, {"Write module article", :pending}]}

iex> TodoList.list(todos)
{:ok, [{"Write module article", :pending}, {"Test examples in IEx", :pending}]}

iex> TodoList.add(todos, "")
{:error, {:invalid_task, ""}}

iex> {:ok, todos} = TodoList.done(todos, "Write module article")
{:ok, [{"Test examples in IEx", :pending}, {"Write module article", :done}]}

iex> TodoList.pending(todos)
[{"Test examples in IEx", :pending}]
Enter fullscreen mode Exit fullscreen mode

I like how the module reads like a description of what it does: you can add tasks, list them, mark them done, and check what is pending. The constants live at the top, the public functions make up the API, and the private helper stays hidden.

Practical Guidelines

I group what changes together — functions that work on the same data or solve the same problem belong in the same module. When I am about to create a new module, I ask myself: does this have a single clear purpose?

I name modules like nounsTodoList, Invoice, PaymentProcessor. A module name answers "what is this?", while function names answer "what does it do?".

I keep the public API small — most functions can be private. A small, clear public surface makes a module easier to learn and harder to misuse.

I use attributes for constants — instead of repeating magic numbers and strings throughout functions, I define them once at the top of the module with an attribute.

I write a @moduledoc even for small modules — forcing myself to summarize the module in one sentence helps me notice when it is trying to do too much.

I follow the file naming convention — one module per file, with the file name matching the module name. It makes navigation predictable in any project.

Conclusion

Defining modules was the step where all the separate ideas from this series started coming together. Functions, pattern matching, and error handling all found their natural home once I understood the module as the container that organizes them.

Some things I learned:

  • Modules are containers — they group related functions under a name and keep code organized
  • Module names are atoms — with a CamelCase convention and a hierarchy created by dots
  • def and defp control visibility — public functions form the API, private ones hide the details
  • Attributes are compile-time constants — perfect for values that should not change
  • Documentation lives in attributes@moduledoc and @doc keep docs next to the code
  • Files mirror modules — the naming and directory conventions make projects easy to navigate

The mental model that helped me most is the toolbox with labeled drawers. Every module has a name on the drawer and a clear set of tools inside. When a module tries to hold too many unrelated things, I find it is time to split it — exactly like cleaning out an overstuffed drawer.

Further Reading

Next Steps

Now that I can define modules, organize functions, and use attributes as constants, the natural next step is exploring module attributes in depth. We touched on constants and documentation, but attributes have more to offer.

In the next article, we will explore:

  • The reserved attributes Elixir understands, like @moduledoc, @doc, and @spec
  • Using @spec to describe the types and contracts of functions
  • The @vsn and @derive attributes and what they do
  • Registering custom attributes with Module.register_attribute/3
  • Accumulating attributes with accumulate: true to collect values across multiple definitions

Module attributes tie directly into everything we just built — the constants in our to-do list are just the first taste of a deeper feature.

Top comments (0)