When you move from a handful of modules to a full‑blown system, you soon discover the need for a framework that can:
- Group related modules together.
- Describe what other components your code needs.
- Start everything with a single call.
- Provide a clean way to ship a self‑contained release.
That framework is the OTP application. In the Erlang/Elixir world an OTP application is a small, self‑describing package that the runtime can start, stop, and supervise. This article explains the anatomy of an OTP application, shows how mix helps you generate it, and demonstrates how to bring in third‑party libraries safely.
Why OTP Applications Matter
Think of an OTP application as a LEGO brick. Each brick knows:
- Its own name and version (so you can tell which brick you’re dealing with).
- What other bricks it needs to function (its dependencies).
- Which process should be started first (the callback module).
When you assemble a whole system, the Erlang VM walks the dependency graph, starts every required brick, and finally launches the top‑level process that ties everything together. This gives you:
- Consistent startup – run
iex -S mixand your whole system boots. - Isolation – each application runs in its own supervision tree, making crashes predictable.
- Reusability – you can publish an application on Hex and let others depend on it.
- Deployability – tools such as
mix releasecan bundle only the applications you actually need, producing a tiny, self‑contained executable.
Creating a New OTP Application with mix
The mix tool is the entry point for every Elixir project. It not only compiles your code, it also creates the application resource file (.app) that the BEAM runtime reads at runtime.
Step‑by‑step: Generating a Skeleton
mkdir inventory_demo
cd inventory_demo
mix new inventory --sup
The --sup flag tells mix to generate a supervision skeleton. After the command finishes you’ll see a directory layout similar to:
inventory/
├─ lib/
│ └─ inventory/
│ ├─ application.ex # Callback module
│ └─ ...
├─ test/
├─ mix.exs # Project definition
└─ README.md
Inspecting mix.exs
Open mix.exs. It contains two crucial functions:
defmodule Inventory.MixProject do
use Mix.Project
def project do
[
app: :inventory,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger],
mod: {Inventory.Application, []}
]
end
defp deps do
[]
end
end
Key points:
app: :inventory– the atom that identifies the application at runtime.mod: {Inventory.Application, []}– tells the VM which module implements theApplicationbehaviour (the “callback module”).extra_applications: [:logger]– declares that we depend on the built‑in Logger application. You’ll add more dependencies later.
The Callback Module
The generated lib/inventory/application.ex looks like this:
defmodule Inventory.Application do
use Application
@impl true
def start(_type, _args) do
children = [
# Define workers and supervisors here
]
opts = [strategy: :one_for_one, name: Inventory.Supervisor]
Supervisor.start_link(children, opts)
end
end
The start/2 function is called by the runtime when the application is launched. Its job is to start a top‑level supervisor (often called Inventory.Supervisor) that will, in turn, manage the rest of the processes.
Running the Application
Start an interactive session with your brand‑new OTP application:
iex -S mix
Inside iex you can verify that your application is alive:
iex(1)> Application.started_applications()
[
{:inventory, 'inventory', '0.1.0'},
{:logger, 'logger', '1.13.0'},
{:elixir, 'elixir', '1.14.0'},
{:iex, 'iex', '1.14.0'},
# … plus the usual Kernel, Stdlib, etc.
]
The first tuple contains the atom name, the internal description, and the version of your :inventory application.
Understanding Application Types
Two major flavours of OTP applications exist:
- Standard applications – they have a callback module that starts a top‑level supervisor. Most of your own services will fall into this category.
- Library applications – they contain only modules and no process tree. They still have an
.appfile, but themodkey is omitted.
Why does a library need an .app file? Because it may still depend on other applications or expose configuration that the runtime should know about. A classic example is the :crypto library, which provides cryptographic functions but never spawns a process.
Creating a Library Application
Suppose you want to publish a tiny utility that formats currency strings. You can turn it into a library application by simply removing the mod: line from application/0:
def application do
[
extra_applications: [:logger]
# No :mod key – this is a pure library.
]
end
Now the build produces a currency_formatter.app file, but no process is started when you call Application.start(:currency_formatter). The application still participates in the dependency graph.
Adding Third‑Party Dependencies
Real‑world projects rarely live in isolation. They borrow code from the broader ecosystem. In Elixir, you add dependencies to mix.exs and let mix fetch, compile, and lock them.
Example: Using a Process Pool Library
Imagine our Inventory service needs to talk to a remote warehouse API. Instead of writing a custom worker pool, we’ll use the proven Poolboy library.
First, declare the dependency:
defp deps do
[
{:poolboy, "~> 1.5"}
]
end
Run the fetch command:
mix deps.get
Mix downloads the source, writes exact version info to mix.lock, and makes the library available to your code.
Understanding mix.lock
The lock file guarantees that every developer, CI runner, and production build compiles against the same dependency versions. Commit mix.lock to version control; otherwise you risk “it works on my machine” bugs.
Integrating a Dependency into Your Supervision Tree
Poolboy’s API is designed around three core concepts:
- Pool manager – a process that holds a fixed set of worker processes.
- Checkout – a client asks the manager for a worker’s PID.
- Checkin – after the client finishes, the worker is returned to the pool.
We’ll wrap Poolboy inside a module called Inventory.WarehousePool and let the application’s supervisor start it for us.
Defining the Child Specification
In OTP, every process you want a supervisor to manage is described by a child spec. Poolboy already provides a helper that returns a spec ready to be inserted into a list of children.
defmodule Inventory.WarehousePool do
@pool_name __MODULE__
def child_spec(_args) do
# The folder where we keep cached API responses
cache_dir = "./warehouse_cache"
File.mkdir_p!(cache_dir)
:poolboy.child_spec(
@pool_name,
[
name: {:local, @pool_name},
worker_module: Inventory.WarehouseWorker,
size: 5,
max_overflow: 2
],
[cache_dir] # Arguments passed to each worker's start_link/1
)
end
end
Explanation of the options:
name: {:local, @pool_name}– registers the pool manager under a local atom, making it reachable via:poolboy.checkout(@pool_name, …).worker_module– the module that implements the actual logic to talk to the warehouse API.size– number of workers kept ready.max_overflow– additional workers that can be spawned on demand when the pool is exhausted.
Implementing the Worker
Each worker is a lightweight GenServer that performs a single HTTP request to the remote service. Because it’s tiny, we can keep the implementation in the same file.
defmodule Inventory.WarehouseWorker do
use GenServer
# Public API -------------------------------------------------------
def start_link(cache_dir) do
GenServer.start_link(__MODULE__, cache_dir, [])
end
def fetch(pid, item_id) do
GenServer.call(pid, {:fetch, item_id})
end
# GenServer callbacks -----------------------------------------------
@impl true
def init(cache_dir) do
{:ok, %{cache_dir: cache_dir}}
end
@impl true
def handle_call({:fetch, item_id}, _from, state) do
# Simulated HTTP request (replace with HTTPoison or Finch in real code)
result = "remote-data-for-#{item_id}"
{:reply, result, state}
end
end
Notice that the worker’s start_link/1 receives the cache_dir argument we passed in the child spec. This shows how arguments flow from the application supervisor down to each worker.
Using the Pool in Business Logic
Now we add a thin façade that hides the checkout/checkin dance from the rest of the codebase.
defmodule Inventory.Warehouse do
@pool_name Inventory.WarehousePool
def get_item(item_id) do
# :poolboy.transaction abstracts the checkout → work → checkin pattern.
:poolboy.transaction(@pool_name, fn worker_pid ->
Inventory.WarehouseWorker.fetch(worker_pid, item_id)
end)
end
end
Calling Inventory.Warehouse.get_item/1 feels like a regular function call, yet behind the scenes Poolboy efficiently reuses worker processes. If a client crashes while holding a worker, Poolboy detects the death via monitors and automatically returns the worker to the pool.
Wiring Everything Together
Finally, make the pool part of the overall supervision tree. Edit Inventory.Application:
defmodule Inventory.Application do
use Application
@impl true
def start(_type, _args) do
children = [
Inventory.WarehousePool # ← Our pool is now a supervised child
# other workers, e.g. Inventory.Catalog, can be added here
]
opts = [strategy: :one_for_one, name: Inventory.Supervisor]
Supervisor.start_link(children, opts)
end
end
With this change, running iex -S mix starts the warehouse pool automatically. You can test the whole flow in the console:
iex(1)> Inventory.Warehouse.get_item("widget-123")
"remote-data-for-widget-123"
Testing in the Context of an OTP Application
One of the benefits of having a proper OTP application is that test runs automatically start the required processes. No more manual start_link/0 calls in test files.
defmodule Inventory.WarehouseTest do
use ExUnit.Case, async: true
test "fetches data via the pool" do
assert "remote-data-for-foo" = Inventory.Warehouse.get_item("foo")
assert "remote-data-for-bar" = Inventory.Warehouse.get_item("bar")
end
end
Because mix test launches the application in the :test environment, the supervision tree (including the pool) is already up and running.
Understanding Mix Environments
Mix supports three built‑in environments: :dev, :test, and :prod. These environments affect compile‑time configuration, enabling you to tweak logging, dependencies, or even compile different code paths.
- Development (
:dev) – default when you runiex -S mix. Good for verbose logging and hot code reloading. - Testing (
:test) – automatically selected formix test. Ideal for using in‑memory databases or mock services. - Production (
:prod) – chosen when you setMIX_ENV=prod. Here you generally turn off debug logs, enable optimizations, and compile releases.
Switching environments is simple:
MIX_ENV=prod mix compile
MIX_ENV=prod iex -S mix
Compiled Artifacts and Directory Layout
After compilation, Mix stores BEAM files and the generated .app resource file under _build/<env>/lib/<app_name>/ebin. The layout looks like:
your_project/
_build/
dev/
lib/
inventory/
ebin/
Elixir.Inventory.beam
inventory.app
priv/
# Any private assets, e.g. static files
test/
# Same structure, compiled for test
While you usually never need to touch ebin, understanding where the runtime finds modules can aid debugging issues related to missing applications or path problems.
Deploying Your OTP Application
When the time comes to ship your system, you’ll use mix release – a tool that builds a self‑contained directory containing:
- The Erlang/Elixir runtime (
erts). - All compiled BEAM files of your application and its dependencies.
- A small boot script that starts the root OTP application.
Because every piece of code is packaged as an OTP application, mix release knows exactly which applications are required (thanks to the deps entries in each app’s .app file). It then strips out everything else, producing a compact bundle.
Creating a Release
MIX_ENV=prod mix release
This command generates _build/prod/rel/inventory. Inside, you’ll find an executable bin/inventory that you can copy to any Linux/Unix server and start with:
_build/prod/rel/inventory/bin/inventory start
The release will automatically start the :inventory application, which in turn launches its supervision tree (including the warehouse pool). No extra scripts needed.
Common Pitfalls and How to Avoid Them
1. Forgetting the mod: entry
If you intend your application to have a supervisor, but you omit mod: {MyApp.Application, []}, the VM will treat it as a library. Consequently, calling Application.start(:my_app) will do nothing, and your processes won’t be launched.
2. Starting an Application Twice
OTP applications are singletons. Attempting to call Application.start(:my_app) when it’s already running yields {:error, {:already_started, :my_app}}. This is not a bug – it’s a safeguard. If you need to reset state, either stop the app first (Application.stop/1) or design your processes to handle “reset” messages.
3. Mismatched Dependency Versions
When two libraries request different, incompatible versions of a shared dependency, Mix may be unable to resolve a single version. The solution is usually to add an explicit version constraint in mix.exs that satisfies both, or to choose alternate libraries that play nicely together.
4. Not Using child_spec/1 When Adding Custom Workers
Since OTP 21, a component that implements child_spec/1 can be added directly to a supervisor’s children list without wrapping it into a tuple. Forgetting to define child_spec/1 forces you to manually build a child spec map, which is error‑prone.
5. Ignoring the MIX_ENV in CI
CI pipelines often run in the default :dev environment unless you explicitly set MIX_ENV=test before mix test. This can cause accidental reliance on development‑only configuration (e.g., verbose logging) during automated tests.
Summary of Key Takeaways
- OTP applications group modules, declare dependencies, and expose a callback module that boots a supervision tree.
mix new … --supscaffolds a ready‑to‑run application with aApplicationbehaviour.- Library applications omit the
mod:entry but still participate in the dependency graph. - Declare third‑party libraries in
deps/0; fetch them withmix deps.getand lock versions inmix.lock. - Use
:poolboy.child_spec/3(or any other library’s child spec) to embed external processes into your supervision tree. - Wrap checkout/checkin logic inside a thin module so callers interact with a simple API.
- Tests automatically start the application when run with
mix test, eliminating manual bootstrapping. - Understanding Mix environments helps you tailor compiled code for development, testing, and production.
- When deploying,
mix releasebundles only the applications you declared, giving you a lightweight, self‑contained executable.
Armed with this knowledge, you can now organize any Elixir system into clean, reusable components that start with a single command, integrate third‑party libraries safely, and ship as tiny, production‑ready releases.
Test Your Understanding
Question 1: Which flag do you add to mix new to generate a supervision skeleton?
- --app
- --sup
- --module
- --start
Question 2: Library applications include a mod: entry in their application/0 definition.
- True
- False
Question 3: What file does mix generate that the BEAM runtime reads at runtime?
Question 4: Which Mix environment is used by default when you run iex -S mix?
- :prod
- :test
- :dev
- :staging
Question 5: The mix.lock file guarantees that every developer, CI runner, and production build uses the same dependency versions.
- True
- False
Question 6: In the Inventory.WarehousePool child spec, what does the size option specify?
- The number of worker processes kept ready
- The maximum number of concurrent requests
- The timeout value for each worker
- The number of supervisor children
Question 7: What command creates a self‑contained release ready for production deployment?