Clients of a house only see the facade: the door, the windows, the porch. The boiler closet, the fuse box, and the plumbing stay out of sight — and that is exactly what lets someone rearrange them without telling anyone.
An Elixir module works the same way. The functions other modules call are the facade; helpers marked defp are the boiler closet. In the previous article about project structure we agreed that Todo is the front door of the todo feature and Parser is a back room. Nothing enforced that, though: every function we wrote with def was public, so any module could call Parser directly. defp turns the "back room" sign into an actual lock.
In this article I'll explore the difference between def and defp, what privacy actually protects, and the patterns I use to keep our todo modules' interfaces small.
Note: The examples in this article use Elixir 1.20.1. While most operations should work across different versions, some functionality might vary.
We keep building on the same learning_elixir Mix project from the previous article (Learning Elixir: Project Structure). If you no longer have it, recreate it with mix new learning_elixir and add the Todo modules back — we'll show every file in full anyway.
Two details that keep showing up: modules live under lib/, and multi-step scripts like try_todo.exs live at the project root, next to mix.exs. Short checks run inline with mix run -e '...', no file needed, and iex -S mix opens the same project in the shell for quick experiments.
One more note on the outputs: mix prints a Compiling N files (.ex) banner before running, and the count depends on your build cache. I trimmed that line from most examples below.
Table of Contents
- Introduction
- The Difference: def and defp
- What Privacy Actually Protects
- Refactoring the Todo Project
- Privacy Is Per Module, Not Per Namespace
- Multiple Clauses: One Name, One Visibility
- Docs, Specs, and Private Functions
- @doc false: Public but Hidden
- Reading a Module's Surface
- Import Only Brings Public Names
- How Private Should I Go?
- Practical Patterns
- Practical Guidelines
- Conclusion
- Further Reading
- Next Steps
Introduction
Every function we wrote so far was defined with def, which makes it public: callable from anywhere, listed in the docs. Elixir also has defp, the private counterpart — callable only from inside the module that defines it.
What I learned about the two:
-
defis the module's promise to the world — once other code calls it, changing it costs everyone -
defpis the module's freedom to change — private helpers can be renamed or removed quietly -
The compiler enforces privacy — outside calls fail: with a runtime
UndefinedFunctionError, and in compiled modules the compiler warns about it first - Privacy is per module — there is no "protected" and no friend modules
-
A small public surface is API design — fewer
defs, clearer role
One sentence made it all click: defp is about communication, not security. It tells the reader "this is an implementation detail; do not build on it" — and the compiler makes that message binding for ordinary code.
The Difference: def and defp
Let's meet the two side by side with a module that fits our project. Todo titles get messy — extra spaces, lowercase first letters. A small helper cleans them up:
# lib/learning_elixir/todo/title.ex
defmodule LearningElixir.Todo.Title do
@moduledoc false
@spec format(String.t()) :: String.t()
def format(title) do
title
|> trim()
|> capitalize()
end
defp trim(title), do: String.trim(title)
defp capitalize(title), do: String.capitalize(title)
end
format/1 is public; trim/1 and capitalize/1 are private. Inside the module, both are called the same way:
$ mix run -e 'IO.inspect(LearningElixir.Todo.Title.format(" buy milk "))'
"Buy milk"
What happens if we try to reach a private helper from outside?
$ mix run -e 'IO.inspect(LearningElixir.Todo.Title.trim(" buy milk "))'
** (UndefinedFunctionError) function LearningElixir.Todo.Title.trim/1 is undefined or private
(learning_elixir 0.1.0) LearningElixir.Todo.Title.trim(" buy milk ")
(stdlib 7.2) erl_eval.erl:924: :erl_eval.do_apply/7
...
The message says "or private", which is genuinely helpful while debugging. Note the word runtime, though. An inline -e expression (like an .exs script) is evaluated as-is, so the error only appears when the line runs. Compiled modules are different: a file in lib/ that calls another module's private function triggers a compile-time warning instead. Evaluated code fails at the call site; compiled code gets an early warning.
What Privacy Actually Protects
Honest part: a private function is not a bank vault, and low-level trickery can still get in. But every ordinary call path is blocked — even apply/3 only dispatches exported functions, so apply(Todo.Title, :trim, [...]) from outside raises the same error. What stays true for normal code:
- Ordinary code cannot call it accidentally — the compiler backend is the one enforcing the rule
- Docs stay clean — the reader only sees the real interface
- Refactoring is safe — if the project compiles, no other file was calling a private function
That last one is the one I rely on most. Inside the project, changing Parser's private helpers needs no research. In a library, a private function is one I'll never be blamed for removing.
Refactoring the Todo Project
Let me apply this to the modules we already have.
Extracting the Parser's Steps
In the previous article, all of parse_line/1's work lived inside two nested case expressions. Nested logic is hard to read and easy to change in the wrong place. Let me name each step and make both private:
# lib/learning_elixir/todo/parser.ex
defmodule LearningElixir.Todo.Parser do
@moduledoc false
@spec parse_line(String.t()) :: {:ok, String.t()} | :error
def parse_line(line) do
case split(line) do
{id_text, title} -> build_item(id_text, title)
:error -> :error
end
end
defp split(line) do
case String.split(line, ";", parts: 2) do
[id, title] -> {id, title}
_other -> :error
end
end
defp build_item(id_text, title) do
case Integer.parse(id_text) do
{_int, ""} -> {:ok, String.trim(title)}
_ -> :error
end
end
end
The whole interface is now one function, parse_line/1. Renaming split/1 or reshaping build_item/2 tomorrow cannot break anything outside.
Shrinking the Contract
The old parser returned {:ok, {id, title}}, and Todo.load/1 threw the id away immediately. A return value that every caller discards is probably leaking an implementation detail. So parse_line/1 now returns {:ok, title} | :error, and Todo matches the simpler shape:
# lib/learning_elixir/todo.ex (updated)
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, title} -> TodoList.add(acc, title)
:error -> acc
end
end)
end
end
Giving TodoList a Smaller Surface
TodoList exposes new/0, add/2, and titles/1. The id concept was still leaking, so let me drop it — and with it, the next_id counter nobody read:
# lib/learning_elixir/todo_list.ex (updated)
defmodule LearningElixir.TodoList do
@moduledoc """
A tiny in-memory todo list.
"""
defstruct items: []
@type t :: %__MODULE__{items: [String.t()]}
@spec new() :: t()
def new, do: %__MODULE__{}
@spec add(t(), String.t()) :: t()
def add(%__MODULE__{} = list, title) do
%{list | items: [title | list.items]}
end
@spec titles(t()) :: [String.t()]
def titles(%__MODULE__{} = list), do: Enum.reverse(list.items)
end
The struct went from a map keyed by id to a plain list — two fields to one — and the public functions did not change at all.
Verifying nothing broke:
# try_todo.exs (at the project root, next to mix.exs)
list = LearningElixir.Todo.load(["1; buy milk", "oops", "2; call mom"])
list |> LearningElixir.TodoList.titles() |> IO.inspect()
$ mix run try_todo.exs
["buy milk", "call mom"]
If you only compare the output, nothing changed: same titles as before. Under the hood, a lot changed — the parser's helpers became private, its return value dropped the unused id, and the todo list swapped a map of ids for a plain list. Inspect the struct and you'll see items: ["call mom", "buy milk"], with no next_id. Callers never noticed, because they only use new/0, add/2, and titles/1.
That is the loop I keep in mind: shrink the public surface, rework the internals, verify the output stayed the same.
Privacy Is Per Module, Not Per Namespace
defp does not know about namespaces. LearningElixir.Todo, TodoList, and Todo.Parser share the LearningElixir.Todo* prefix, and it changes nothing — a private function in one is invisible to the others.
The refactored Parser shows it clearly. The public parse_line/1 works from outside:
$ mix run -e 'IO.inspect(LearningElixir.Todo.Parser.parse_line("1; buy milk"))'
{:ok, "buy milk"}
But the private split/1, even though we can read its code, is unreachable:
$ mix run -e 'IO.inspect(LearningElixir.Todo.Parser.split("1; buy milk"))'
** (UndefinedFunctionError) function LearningElixir.Todo.Parser.split/1 is undefined or private
(learning_elixir 0.1.0) LearningElixir.Todo.Parser.split("1; buy milk")
(stdlib 7.2) erl_eval.erl:924: :erl_eval.do_apply/7
...
Inside Parser, parse_line/1 calls split/1 freely — same module. The moment the call crosses a module boundary, the compiler's promise kicks in.
Privacy also does not travel down a namespace. Todo.load/1 still depends on parse_line/1's contract and must match {:ok, title} exactly. Making helpers private narrows which functions others can touch; the public ones that remain are still a contract we answer for.
Multiple Clauses: One Name, One Visibility
All clauses of a function must share the same visibility. Mixing def and defp is a compile error — I hit it while trying to make add/2 skip empty titles (our parser happily accepts "5; " and produces an empty title). What I tried does not compile:
# lib/learning_elixir/todo_list.ex (broken example — for illustration only)
defmodule LearningElixir.TodoList do
@moduledoc """
A tiny in-memory todo list.
"""
defstruct items: []
@type t :: %__MODULE__{items: [String.t()]}
@spec new() :: t()
def new, do: %__MODULE__{}
@spec add(t(), String.t()) :: t()
def add(%__MODULE__{} = list, ""), do: list
defp add(%__MODULE__{} = list, title), do: %{list | items: [title | list.items]}
@spec titles(t()) :: [String.t()]
def titles(%__MODULE__{} = list), do: Enum.reverse(list.items)
end
The compiler refuses right away:
$ mix compile
error: defp add/2 already defined as def in lib/learning_elixir/todo_list.ex:14
│
15 │ defp add(%__MODULE__{} = list, title), do: %{list | items: [title | list.items]}
│ ^
│
└─ lib/learning_elixir/todo_list.ex:15:8
== Compilation error in file lib/learning_elixir/todo_list.ex ==
** (CompileError) lib/learning_elixir/todo_list.ex: cannot compile file (errors have been logged)
lib/learning_elixir/todo_list.ex:15: (module)
The rule is simple: one name, one visibility — decided once for the whole function. No file needs to change in your project: the broken version above was only to show the error, and the good version stays the one from the refactoring section.
Docs, Specs, and Private Functions
Attributes behave differently on private functions:
-
@docis useless ondefp— private functions are excluded from docs, so the attribute is discarded. If I ever write one out of habit (say, aboveParser.split/1), the compiler tells me:
@doc """
Splitting is an internal step, docs would be discarded.
"""
defp split(line) do
case String.split(line, ";", parts: 2) do
[id, title] -> {id, title}
_other -> :error
end
end
$ mix compile
warning: defp split/1 is private, @doc attribute is always discarded for private functions/macros/types
│
12 │ @doc """
│ ~~~~~~~~
│
└─ lib/learning_elixir/todo/parser.ex:12: LearningElixir.Todo.Parser.split/1
I made this mistake once out of habit, got the warning, and stopped — docs are for the interface; private helpers do not have one.
-
@specworks fine ondefp— Dialyzer checks private functions too. A spec on a complex private helper documents intent to me in six months. -
Doctests cannot cover
defp— theiex>examples only run for public functions. When a private helper deserves documentation, a spec plus a comment does the job.
The same logic applies to tests, which get proper articles later: I never test a private function directly, I test the public function that uses it. If a private helper feels like it needs its own tests, that often means it should become its own public module.
My habit since learning this: open a file, look at the defs — that is the interface. Everything under the first defp is the engine room.
Public but Hidden
There is also a middle ground between public and private: a function that is callable by anyone, but hidden from docs. That is @doc false. In the project structure article we hid whole modules with @moduledoc false; @doc false does the same for a single function.
Our printer module uses it for its normalization step:
# lib/learning_elixir/todo/printer.ex
defmodule LearningElixir.Todo.Printer do
@moduledoc false
alias LearningElixir.Todo.Title
@doc false
def normalize(title), do: Title.format(title)
def bullet_list(titles) do
titles
|> Enum.with_index(1)
|> Enum.map_join("\n", &numbered/1)
end
defp numbered({title, index}), do: "#{index}. #{normalize(title)}"
end
normalize/1 is not private — another module can call it. It just stays out of the docs. The difference from defp: @doc false allows any caller but makes the function undiscoverable; defp forbids every outside caller. And numbered/1 below is a real private function — display details no other module needs. (Enum.with_index/2 and Enum.map_join/3 get a proper introduction in the next article; for now, trust that they number and join the items.)
Running it over our titles:
# try_printer.exs (at the project root, next to mix.exs)
[" buy milk ", "call mom"]
|> LearningElixir.Todo.Printer.bullet_list()
|> IO.puts()
$ mix run try_printer.exs
1. Buy milk
2. Call mom
Reading a Module's Surface
__info__(:functions) lists a module's public functions — a quick way to see its interface:
$ mix run -e 'IO.inspect(LearningElixir.Todo.Printer.__info__(:functions))'
[bullet_list: 1, normalize: 1]
bullet_list/1 and normalize/1 show up; the private numbered/1 does not. As with docs, @doc false does not remove a function from the surface — defp does.
Import Only Brings Public Names
Back to the article about alias, import, and require: import can only bring public functions into scope. Private ones are not offered for import at all.
Todo.Title is the example. Its public format/1 is importable:
# lib/learning_elixir/todo/bullet.ex
defmodule LearningElixir.Todo.Bullet do
@moduledoc false
import LearningElixir.Todo.Title, only: [format: 1]
def render(title), do: "• #{format(title)}"
end
$ mix run -e 'IO.inspect(LearningElixir.Todo.Bullet.render(" call mom "))'
"• Call mom"
But no import will ever bring trim/1 in, because the module does not export it. Alias shortens names, import brings functions closer, and defp decides what exists to be brought at all.
How Private Should I Go?
A disclaimer first: none of these are laws. The compiler enforces only the binary part; how far to push privacy is taste, and even experienced Elixir developers disagree — linters like Credo ship checks that are themselves contested. These are my habits as someone still learning.
I default to defp and promote when a caller appears. Before reaching for def, I ask myself: will another module actually call this? If yes, it is public; if not, it stays private. I used to make everything public "just in case" — that is how surfaces grow. Now a real caller is what convinces me: Todo.load/1 needed parse_line/1, Printer and Bullet needed Title.format/1. Everything else kept defp.
To me, the return values are the real contract. {:ok, title} is harder to misuse than {:ok, {id, title}} where half the data is thrown away. If every caller discards part of a return value, that part is probably leaking.
Expose behavior, not data. TodoList.titles/1 produces a useful view; nobody outside knows about items. If the representation changes again, only TodoList edits.
All-public modules are fine too. TodoList has no private functions and does not need them. The smell is not "many defs"; it is "many defs half of which no one calls" — usually a sign of two jobs in one module.
Practical Patterns
Patterns we actually built in this project.
Public Wrapper, Private Engine
One public function delegating to private steps. Parser.parse_line/1 is the door; split/1 and build_item/2 are the engine. Todo.Printer.bullet_list/1 with its private numbered/1 is the same shape. Need titles truncated at 40 characters tomorrow? Change the private helpers; the public contract stays put.
Default Public, Refined Private
Public clauses handle the normal path; private clauses absorb edge cases. The summary label for our todo list is a natural fit — the empty list is the edge case:
# lib/learning_elixir/todo/summary.ex
defmodule LearningElixir.Todo.Summary do
@moduledoc false
alias LearningElixir.TodoList
def label(%TodoList{items: []}), do: empty_label()
def label(%TodoList{items: items}), do: "#{length(items)} items"
defp empty_label, do: "No todos yet"
end
# try_summary.exs (at the project root, next to mix.exs)
IO.inspect(LearningElixir.Todo.Summary.label(LearningElixir.TodoList.new()))
LearningElixir.TodoList.new()
|> LearningElixir.TodoList.add("buy milk")
|> LearningElixir.TodoList.add("call mom")
|> LearningElixir.Todo.Summary.label()
|> IO.inspect()
$ mix run try_summary.exs
"No todos yet"
"2 items"
If the empty wording gains logic later, it grows in one private place.
Public Entry, Private Recursive Worker
A pattern the recursion articles will build on: a public entry point and a private recursive worker carrying an accumulator. Todo.ValidCount counts how many lines actually become todos, reusing the refactored Parser:
# lib/learning_elixir/todo/valid_count.ex
defmodule LearningElixir.Todo.ValidCount do
@moduledoc false
alias LearningElixir.Todo.Parser
def count(lines), do: count(lines, 0)
defp count([], acc), do: acc
defp count([line | rest], acc) do
case Parser.parse_line(line) do
{:ok, _title} -> count(rest, acc + 1)
:error -> count(rest, acc)
end
end
end
$ mix run -e 'IO.inspect(LearningElixir.Todo.ValidCount.count(["1; buy milk", "oops", "2; call mom"]))'
2
Callers only see count/1. The two-argument worker is its own little world, recursion included.
Practical Guidelines
I default to defp and promote when there is a caller — public is justified by a real caller, not by "might be useful someday".
I let the public functions tell the module's story first — if I cannot guess what a module is for by reading its defs, the surface is too big or the module has two jobs.
I expose behavior, not data — titles/1 over direct access to items keeps representation changes internal.
I keep public return shapes as simple as callers need — discarding data on every call is a sign the shape should shrink.
I put @spec on complex private helpers but never @doc — specs check; docs are discarded for private functions.
I use @doc false for shared plumbing and defp for module-only details — the first hides a name, the second forbids it.
I verify shrink-refactor-verify loops with mix run and mix test — after any surface change, the cheapest safety net is the one that already exists. The tests from the project structure article still pass untouched:
$ mix test
..
Finished in 0.00 seconds (0.00s async, 0.00s sync)
Result: 2 passed (1 doctest, 1 test)
Conclusion
For me, defp is how a module keeps the freedom to change, and a small def surface is how it keeps its promise to the rest of the project cheap.
Some things I learned:
-
defpis compiler-enforced — outside callers getUndefinedFunctionError, with "or private" right in the message - Privacy is per module — namespaces, aliases, and imports do not widen it
- A small public surface gave me refactoring freedom — the parser and the todo list both changed internals invisibly
-
The return shape is where the contract shows up for me —
{:ok, title}over{:ok, {id, title}}when callers discard the id -
Multiple clauses share one visibility — the compiler refuses to mix
defanddefpfor the same function -
@doc falseis a softer lever thandefp— it hides a public name instead of forbidding it
The house picture held: the facade is the defs, the boiler closet is the defps, and a good module keeps the facade simple enough that the house behind it is free to change. Next, back to the data side — our printer, summary, and valid counter all leaned on list transformations without naming the module behind them. It is time to meet Enum properly.
Further Reading
- Elixir Official Documentation — Modules and Functions
- Elixir Official Documentation — Kernel.defp/2
- Elixir Official Documentation — Module Attributes
Next Steps
Every pipeline in this article — Todo.Printer.bullet_list/1, Todo.Summary.label/1, Todo.ValidCount.count/1, and Todo.load/1 from before — leaned on one module without naming it: Enum. The natural next step is to study it directly.
In the next article, we'll explore:
- The fundamentals of
Enum.map/2,Enum.filter/2, andEnum.reduce/3 - Why these three functions power most data transformations in Elixir
- Chaining
Enumfunctions into readable pipelines - How
reduce/3is the engine underneath the other functions
Our todo project is a good playground for this — loading lines, filtering valid ones, counting them, and building lists is exactly the work Enum was made for.
Top comments (0)