Learning Elixir: Private vs Public Functions

dev.to

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

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:

  • def is the module's promise to the world — once other code calls it, changing it costs everyone
  • defp is 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
    ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode
$mix run try_todo.exs
["buy milk", "call mom"]
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
    ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  • @doc is useless on defp — private functions are excluded from docs, so the attribute is discarded. If I ever write one out of habit (say, above Parser.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
Enter fullscreen mode Exit fullscreen mode
  $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
Enter fullscreen mode Exit fullscreen mode

I made this mistake once out of habit, got the warning, and stopped — docs are for the interface; private helpers do not have one.

  • @spec works fine on defp — Dialyzer checks private functions too. A spec on a complex private helper documents intent to me in six months.
  • Doctests cannot cover defp — the iex> 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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode
$mix run try_printer.exs
1. Buy milk
2. Call mom
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
$mix run -e 'IO.inspect(LearningElixir.Todo.Bullet.render("  call mom  "))'
"• Call mom"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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()
Enter fullscreen mode Exit fullscreen mode
$mix run try_summary.exs
"No todos yet"
"2 items"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
$mix run -e 'IO.inspect(LearningElixir.Todo.ValidCount.count(["1; buy milk", "oops", "2; call mom"]))'
2
Enter fullscreen mode Exit fullscreen mode

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 datatitles/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)
Enter fullscreen mode Exit fullscreen mode

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:

  • defp is compiler-enforced — outside callers get UndefinedFunctionError, 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 def and defp for the same function
  • @doc false is a softer lever than defp — 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

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, and Enum.reduce/3
  • Why these three functions power most data transformations in Elixir
  • Chaining Enum functions into readable pipelines
  • How reduce/3 is 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.

Source: dev.to

arrow_back Back to News