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
- Understanding Modules
- Defining a Module with defmodule
- Public and Private Functions
- Module Attributes as Constants
- Documenting Modules and Functions
- How Modules Map to Files and Directories
- Nested Modules and Namespacing
- Building a Module from Scratch
- Practical Guidelines
- Conclusion
- Further Reading
- Next Steps
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 namespace —
String.upcase/1andMyApp.upcase/1can 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
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!"
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
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
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
Testing in IEx:
iex> Math.add(2, 3)
5
iex> Math.multiply(4, 5)
20
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
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
Testing in IEx:
iex> FizzBuzz.convert(3)
"Fizz"
iex> FizzBuzz.convert(5)
"Buzz"
iex> FizzBuzz.convert(15)
"FizzBuzz"
iex> FizzBuzz.convert(7)
7
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
Testing in IEx:
iex> StringFormatter.format_name("joao", "silva")
"Joao Silva"
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
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
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
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"
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
Testing in IEx:
iex> AppConfig.app_name()
"my_app"
iex> AppConfig.max_attempts()
5
iex> AppConfig.retry_delays()
[100, 200, 500]
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
Testing in IEx:
iex> Limits.max_limit()
100
iex> Limits.half_max()
50
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
Testing in IEx:
iex> Greeting.greet("world")
"Hello, world!"
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
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
Testing in IEx:
iex> Calculator.add(1, 2)
3
iex> Calculator.subtract(5, 3)
2
@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
Testing in IEx:
iex> TitleCase.title("hello world")
"Hello World"
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:
-
Greeterlives inlib/greeter.ex -
UserProfilelives inlib/user_profile.ex -
MyApp.Payments.Processorlives inlib/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
Reading this layout tells me a lot about the application before I open a single file:
-
lib/my_app.exdefinesMyApp— often the entry point -
lib/my_app/payments/processor.exdefinesMyApp.Payments.Processor -
lib/my_app/users.exdefinesMyApp.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
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"
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
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
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
Testing in IEx:
iex> Example.current()
Example
iex> Example.current() == :"Elixir.Example"
true
__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
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
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
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/0returns an empty list -
add/2prepends a task with the initial status, or returns{:error, {:invalid_task, task}}for invalid input -
list/1reverses the list back to insertion order -
done/2marks a task as done -
pending/1filters 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
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}]
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 nouns — TodoList, 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 —
@moduledocand@dockeep 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
- Elixir Official Documentation — Modules and Functions
- Elixir Official Documentation — Module Attributes
- Elixir Official Documentation — Writing Documentation
- Elixir Official Documentation — Module module
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
@specto describe the types and contracts of functions - The
@vsnand@deriveattributes and what they do - Registering custom attributes with
Module.register_attribute/3 - Accumulating attributes with
accumulate: trueto 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)