When an Elixir project outgrows a handful of modules, the habit of pasting definitions into IEx starts to break down — redefinition warnings pile up, names collide, and nothing survives the session. One way to picture what comes next is a workshop: it begins as a single shelf of drawers and, as the work grows, turns into a whole room with labeled aisles and a storage room for materials that are not tools at all. In the last few articles I filled those drawers — modules — and learned how to reach between them with alias, import, and require. A project structure is the map of that room: it tells me where each module lives on disk, how file names line up with module names, and which aisles other people are allowed to walk into. In this article, we will build a real Mix project from scratch and watch it grow: starting from mix new, adding directories like lib, test, priv, and config, naming modules so they mirror their paths, and drawing boundaries so the project stays navigable as it grows.
Note: The examples in this article use Elixir 1.20.1. While most operations should work across different versions, some functionality might vary.
This is also a small turning point for the series: instead of pasting examples into iex, everything from now on lives in real files inside a Mix project. Each code block shows the file path, and every example is verified with mix run, with the output shown right after — and when we just want to poke at our functions, iex -S mix brings back the interactive shell, now with the project compiled from its files.
Table of Contents
- Introduction
- Starting From Mix New
- The Anatomy of the Default Project
- Growing Lib: Modules That Mirror Paths
- Running the Project: Scripts and the Shell
- Namespaces and Boundaries
- The Priv Directory
- Configuration and Environments
- Practical Guidelines
- Conclusion
- Further Reading
- Next Steps
Introduction
In the previous articles, every example was self-contained: one or two defmodule blocks pasted into an iex session. That worked beautifully while we were learning single ideas. But the moment a project has more than a handful of modules, pasting into IEx starts fighting us — redefinition warnings pile up, name collisions appear, and nothing survives the session.
Mix is the tool that solves this. It creates the skeleton of a project, compiles it, runs it, runs its tests, and manages its dependencies. What I learned about Mix projects:
-
mix newgives a working starting point — a compilable, testable project in seconds -
lib/is the heart — all application code lives there -
File paths mirror module names —
LearningElixir.TodoListlives inlib/learning_elixir/todo_list.ex -
priv/holds non-code assets — files that ship with the application -
config/separates configuration from code — with environments for dev, test, and prod - Structure scales with discipline — namespaces and boundaries keep growth navigable
One thing that helped me: none of these conventions are enforced by the compiler. I could put everything in one giant file. But every Elixir project I have opened follows the same layout, and following it myself made reading other people's code dramatically easier.
Starting From Mix New
Let's create the project this whole article builds on. The name matters — it becomes both the OTP application name and the root namespace for our modules:
$ mix new learning_elixir
* creating README.md
* creating .formatter.exs
* creating .gitignore
* creating mix.exs
* creating lib
* creating lib/learning_elixir.ex
* creating test
* creating test/test_helper.exs
* creating test/learning_elixir_test.exs
Your Mix project was created successfully.
You can use "mix" to compile it, test it, and more:
cd learning_elixir
mix test
Run "mix help" for more commands.
A few seconds later, we have a complete project. Running mix new --sup learning_elixir would add a supervision tree on top (lib/learning_elixir/application.ex) — we will meet supervisors much later in the series, so plain mix new is all we need now.
Two commands are worth trying immediately:
$ cd learning_elixir
$ mix run -e "LearningElixir.hello() |> IO.puts()"
Compiling 1 file (.ex)
Generated learning_elixir app
world
$ mix test
Compiling 1 file (.ex)
Generated learning_elixir app
Running ExUnit with seed: 365463, max_cases: 64
..
Finished in 0.00 seconds (0.00s async, 0.00s sync)
Result: 2 passed (1 doctest, 1 test)
mix run -e "code" compiles the project, starts the application, and evaluates the given expression — that will be our main verification tool throughout this article. mix test runs the test suite, and it already passes out of the box because mix new generates one example test plus a doctest from the generated module.
The Anatomy of the Default Project
Here is everything mix new created:
learning_elixir/
├── .formatter.exs
├── .gitignore
├── README.md
├── lib/
│ └── learning_elixir.ex
├── mix.exs
└── test/
├── learning_elixir_test.exs
└── test_helper.exs
lib/ — Application Code
The heart of the project. Every .ex file under lib/ gets compiled into the application. The generated starter module looks like this:
# lib/learning_elixir.ex
defmodule LearningElixir do
@moduledoc """
Documentation for `LearningElixir`.
"""
@doc """
Hello world.
## Examples
iex> LearningElixir.hello()
:world
"""
def hello do
:world
end
end
Notice the module attributes from earlier articles doing their job — @moduledoc and @doc document the module, and the indented iex> block inside @doc is actually a doctest, which is why mix test reported "1 doctest".
mix.exs — The Project Definition
defmodule LearningElixir.MixProject do
use Mix.Project
def project do
[
app: :learning_elixir,
version: "0.1.0",
elixir: "~> 1.20",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
]
end
end
The project/0 function describes metadata: the application name (:learning_elixir), the version, the minimum Elixir version, and dependencies. The application/0 function describes the OTP application itself — which applications to start alongside ours. We will touch this function again when configuration comes up.
test/ — Tests
# test/learning_elixir_test.exs
defmodule LearningElixirTest do
use ExUnit.Case
doctest LearningElixir
test "greets the world" do
assert LearningElixir.hello() == :world
end
end
# test/test_helper.exs
ExUnit.start()
Tests mirror the structure of lib/ — test/learning_elixir_test.exs tests lib/learning_elixir.ex. That mirroring rule is one of the strongest conventions in the ecosystem, and testing gets several full articles later in the series.
.formatter.exs and .gitignore
Small but useful. .formatter.exs configures mix format, which keeps the whole codebase consistently formatted — running mix format before committing became a habit for me. .gitignore already excludes _build/ and deps/, the generated directories we never want in version control.
Growing Lib: Modules That Mirror Paths
The default project has one module. Real projects have dozens. The convention that keeps them findable is simple: a module named Foo.Bar.Baz lives in the file path foo/bar/baz.ex, relative to lib/.
Each segment of the module name becomes a directory, lowercased with underscores. Let's grow the project with a small todo feature. First, a data structure:
# lib/learning_elixir/todo_list.ex
defmodule LearningElixir.TodoList do
@moduledoc """
A tiny in-memory todo list.
"""
defstruct items: %{}, next_id: 1
@type t :: %__MODULE__{items: %{pos_integer() => String.t()}, next_id: pos_integer()}
@spec new() :: t()
def new, do: %__MODULE__{}
@spec add(t(), String.t()) :: t()
def add(%__MODULE__{} = list, title) do
id = list.next_id
%{list | items: Map.put(list.items, id, title), next_id: id + 1}
end
@spec titles(t()) :: [String.t()]
def titles(%__MODULE__{} = list), do: Map.values(list.items)
end
The struct, types, and specs reuse what we covered in the structs and module attributes articles. The important structural point: LearningElixir.TodoList lives in lib/learning_elixir/todo_list.ex.
Now a parser, nested one level deeper:
# lib/learning_elixir/todo/parser.ex
defmodule LearningElixir.Todo.Parser do
@moduledoc false
@spec parse_line(String.t()) :: {:ok, {pos_integer(), String.t()}} | :error
def parse_line(line) do
case String.split(line, ";", parts: 2) do
[id, title] ->
case Integer.parse(id) do
{int, ""} -> {:ok, {int, String.trim(title)}}
_ -> :error
end
_other ->
:error
end
end
end
LearningElixir.Todo.Parser lives in lib/learning_elixir/todo/parser.ex — the mapping holds at any depth. And finally, a public entry point that ties them together:
# lib/learning_elixir/todo.ex
defmodule LearningElixir.Todo do
alias LearningElixir.Todo.Parser
alias LearningElixir.TodoList
def load(lines) do
Enum.reduce(lines, TodoList.new(), fn line, acc ->
case Parser.parse_line(line) do
{:ok, {_id, title}} -> TodoList.add(acc, title)
:error -> acc
end
end)
end
end
The tree now looks like this:
lib/
├── learning_elixir.ex
└── learning_elixir/
├── todo.ex
├── todo_list.ex
└── todo/
└── parser.ex
Every module can be found by translating dots into slashes. This is also why alias from the previous article pairs so well with this convention: seeing alias LearningElixir.Todo.Parser at the top of a file tells me the exact path of the file that defines it.
One detail worth knowing: the correspondence is a convention, not a compiler requirement. Mix finds modules wherever they are. Tools like mix format and editors with ElixirLS rely on the convention to jump to definitions quickly, though — breaking it costs real navigation time.
Verifying the whole thing works together:
$ mix run -e 'list = LearningElixir.Todo.load(["1; buy milk", "oops", "2; call mom"]); IO.inspect(LearningElixir.TodoList.titles(list))'
Compiling 3 files (.ex)
Generated learning_elixir app
["buy milk", "call mom"]
The malformed "oops" line was silently skipped by the :error clause, and the two valid lines made it into the list.
Running the Project: Scripts and the Shell
With the project now holding real modules, it is worth settling how we run code from here on. I found two tools with two distinct jobs: mix run for verifying that examples work exactly as written, and iex -S mix for freely exploring what we built.
Scripts With mix run
For anything longer than a one-liner, pasting a long expression into mix run -e '...' gets fragile fast — shells mangle quotes and brackets, and nobody enjoys reading a 200-character single line. The friendlier form is saving the code as an .exs script file at the root of the project (next to mix.exs) and handing the file to Mix:
# load_todos.exs (at the project root, next to mix.exs)
list = LearningElixir.Todo.load(["1; buy milk", "oops", "2; call mom"])
LearningElixir.TodoList.titles(list) |> IO.inspect()
$ mix run load_todos.exs
["buy milk", "call mom"]
An .exs file is just Elixir source that is evaluated instead of compiled to disk — the same distinction between test/*.exs and lib/*.ex we saw in the anatomy section. From here on, longer examples in this series appear as small script files like this one, so every command stays copy-paste friendly.
Exploring With iex -S mix
The interactive shell did not go away — it got better. iex -S mix starts IEx with the whole project already compiled from its files. (If Mix has nothing new to compile — for instance, right after the mix run -e above — you may land straight on the Interactive Elixir prompt without the Compiling 4 files line.)
$ iex -S mix
Interactive Elixir (1.20.1) - press Ctrl+C to exit (type h() ENTER for help)
iex> list = LearningElixir.Todo.load(["1; buy milk", "2; call mom"])
%LearningElixir.TodoList{
items: %{1 => "buy milk", 2 => "call mom"},
next_id: 3
}
iex> LearningElixir.TodoList.titles(list)
["buy milk", "call mom"]
This solves the exact problem that pushed us away from pasting modules into plain iex: the modules come from real files, already compiled once by Mix — no redefinition warnings, no name collisions, and any change means editing the file and restarting, which keeps the file and the session honest.
My working split since switching formats: write the code in files, verify with mix run, explore with iex -S mix. The scripts prove the examples; the shell is where I poke at them.
Namespaces and Boundaries
Once files mirror modules, the next question is how to group them. A namespace is a prefix shared by related modules — like LearningElixir.Todo.*. A boundary is a decision about who may call whom.
Namespaces Emerge Naturally
Our project now has three levels of depth, and each level means something:
-
LearningElixir— the project itself -
LearningElixir.Todo— the todo feature -
LearningElixir.Todo.Parser— an internal piece of the feature
Nothing forced me into these groupings. They emerged from one habit: whenever two modules belong to the same feature, they share a namespace prefix. When a third todo module appears, its home is obvious before I even write it.
Boundaries: One Entry Point Per Feature
Here is the part that took me longest to appreciate. Look again at LearningElixir.Todo.load/1: it calls Parser.parse_line/1 directly. Nothing stops any other module from doing the same — reaching past Todo straight into its internals. That works today, and bites later, when refactoring Parser breaks callers nobody knew existed.
The fix is a boundary drawn by discipline: LearningElixir.Todo is the public entry point for the feature; Parser is internal; and TodoList is the feature's data structure, which exposes just enough to work with it. Elixir has no namespace-based visibility, so this boundary is a convention, not something the compiler enforces. Three habits make it visible:
Mark internals with @moduledoc false, as Parser does above. It signals to readers that the module is internal and keeps it out of generated documentation. It does not make the module private or prevent other modules from calling it.
Expose behavior through functions, not data. Notice that load/1 returns a TodoList struct, but callers manipulate it through TodoList.titles/1 rather than poking at %{items: ...} directly. If the representation changes, no caller notices.
Keep the public module thin. Todo has no business logic at all — it coordinates. Logic lives in focused modules underneath. When I open a namespace's main module and see hundreds of lines, that is usually a sign the boundary is leaking.
There is also tooling for this: libraries like boundary enforce these rules at compile time. I have not needed that yet — for my scale, the naming discipline alone has been enough — but knowing enforcement exists changed how seriously I take the convention.
Reading Someone Else's Tree
These conventions pay off most when reading unfamiliar code. Given a tree like ours, questions answer themselves:
- Where is
X.Y.Zdefined? →lib/x/y/z.ex - What belongs to the todo feature? → everything under
lib/learning_elixir/todo* - What is safe to call from outside? →
LearningElixir.Todo, plus theTodoListoperations it works with
The Priv Directory
Some things a project needs are not code: template files, seed data, schemas, certificates, static assets. The Erlang/Elixir convention is to put them in priv/. Mix does not create it by default — we create it when we need it.
Why a special directory? Because at runtime, code cannot assume the source tree exists (in production, releases ship compiled artifacts). But Mix guarantees that priv/ travels with the compiled application, and there is a built-in way to find it:
# priv/examples/todos.txt
1; buy milk
2; call mom
not-a-number; broken line
3; write article
$ mix run -e 'path = Path.join(:code.priv_dir(:learning_elixir), "examples/todos.txt"); lines = File.read!(path) |> String.split("\n", trim: true); list = LearningElixir.Todo.load(lines); IO.inspect(LearningElixir.TodoList.titles(list))'
["buy milk", "call mom", "write article"]
:code.priv_dir(:learning_elixir) returns the absolute path to the application's priv/ directory regardless of where the code is deployed. The broken line was skipped, and the three valid todos loaded from disk.
I use priv/ sparingly at this stage, but knowing the pattern early meant that when a web framework later insisted on putting assets in priv/static, it did not feel arbitrary — it is the standard place for files that must ship with the application.
Configuration and Environments
So far every value in our project is hard-coded. Configuration moves settings out of the code — limits, titles, URLs, credentials — so they can change per environment without touching modules.
Creating the Config Directory
Like priv/, the config/ directory is optional and not generated by default. We create it with a config/config.exs file:
# config/config.exs
import Config
config :learning_elixir, :max_items, 50
config :learning_elixir, :default_title, "Untitled"
Each config call sets values in the application environment of :learning_elixir — a keyword-list store keyed by app, key. Mix imports config/config.exs automatically when the directory exists; no changes to mix.exs needed.
Reading the values back at runtime uses Application.get_env/3. A small script keeps the checks readable:
# check_config.exs (at the project root, next to mix.exs)
IO.inspect(Mix.env())
IO.inspect(Application.get_env(:learning_elixir, :max_items))
IO.inspect(Application.get_env(:learning_elixir, :default_title))
$ mix run check_config.exs
Compiling 4 files (.ex)
:dev
50
"Untitled"
Build-Time vs Runtime Configuration
Config files come in two flavors, and telling them apart saved me a lot of confusion:
-
Build-time (
config/config.exs) — evaluated by Mix whenever the project is loaded (on compile, run, or test). Its values become part of the compiled application. -
Runtime (
config/runtime.exs) — evaluated when the application or release starts. This is where environment variables belong, since they may differ between deploys of the same build.
# config/runtime.exs
import Config
if config_env() == :dev do
config :learning_elixir, :max_items,
System.get_env("MAX_ITEMS", "50") |> String.to_integer()
end
config_env() returns the configuration environment in which the config file is being evaluated — it comes from the Config API and works in releases too, where Mix is not available. (By contrast, Mix.env() is Mix's own API and only works inside Mix tasks and project files.) Now the same script reads a different value depending on how it is started:
$ MAX_ITEMS=10 mix run check_config.exs
:dev
10
"Untitled"
My rule of thumb so far: constants that never vary go in the module as attributes or private functions (as in the module attributes article); anything that varies between machines or deployments goes through the application environment.
Environments: Dev, Test, Prod
Mix ships with three environments: :dev (the default), :test (used by mix test), and :prod (used by MIX_ENV=prod mix compile). Two places react to them:
Inside config files, config_env() lets us scope settings per environment. Adding a test override to our config:
# config/config.exs (addition)
if config_env() == :test do
config :learning_elixir, :max_items, 5
end
And in mix.exs, Mix.env() drives project settings — we saw it already in the generated file: start_permanent: Mix.env() == :prod.
We can check the current environment from anywhere with the same script as before:
$ mix run check_config.exs
:dev
50
"Untitled"
$ MIX_ENV=test mix run check_config.exs
Compiling 4 files (.ex)
Generated learning_elixir app
:test
5
"Untitled"
Same code, same build steps, different configuration — that is the whole point of environments.
Practical Guidelines
I let module names decide file paths — writing defmodule X.Y.Z and then hunting for where to put it feels backwards; deciding the path first makes the module name automatic.
I give each feature a single entry point — the public module that coordinates it. Implementation details stay behind it, marked with @moduledoc false if purely internal.
I keep the public module thin — coordination goes there, logic goes into focused modules below it in the namespace.
I put non-code assets in priv/ and reach them with :code.priv_dir/1 — never assuming the source tree exists at runtime.
I separate build-time from runtime config — static defaults in config/config.exs, environment variables in config/runtime.exs.
I write code in files, verify with mix run, and explore with iex -S mix — scripts prove the examples work as written; the shell is for poking at them afterward.
I run mix format and mix test constantly — the structure only helps if the project stays healthy, and these two commands are the cheapest health checks I know.
Conclusion
Zooming out from single modules to a whole project turned out to be less about learning new syntax and more about adopting conventions the whole community shares. Nothing in this article was enforced by the compiler — yet every Elixir codebase I open follows the same rules, which is precisely what makes them valuable.
Some things I learned:
-
mix newstarts a real project — compilable and testable in seconds, withlib/,test/, andmix.exsin place -
Paths mirror modules —
Foo.Bar.Bazlives infoo/bar/baz.ex, at any depth - Namespaces group features; boundaries protect them — one entry point per feature, internals kept behind it
-
priv/ships non-code assets — reachable at runtime via:code.priv_dir/1 -
Configuration lives outside code — build-time in
config/config.exs, runtime inconfig/runtime.exs, scoped by dev, test, and prod environments
The workshop picture held together: drawers became shelves, shelves became labeled aisles, and the storage room got a name. What surprised me most is how little effort this takes to maintain if I follow the conventions from the very first file — structure compounds just like technical debt does, only in the good direction.
Further Reading
Next Steps
With the project laid out on disk, the natural next step is zooming back in — not on what functions do, but on which ones the rest of the project is allowed to see.
In the next article, we'll explore:
- Public functions (
def) versus private functions (defp) - Encapsulation: hiding helpers behind a small public surface
- How privacy shapes API design within our namespaces
- Practical patterns for keeping module interfaces intentional
Everything we just built gives us the perfect playground for this — our Todo, Parser, and TodoList modules are already exercising the line between what a module shares and what it keeps to itself.
This article was originally published by DEV Community and written by João Paulo Abreu.
Read original article on DEV Community