Learning Elixir: Private vs Public Functions
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: - 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 def s, 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 illustra
Comments
No comments yet. Start the discussion.