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:

  1. Its own name and version (so you can tell which brick you’re dealing with).
  2. What other bricks it needs to function (its dependencies).
  3. 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 mix and 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 release can 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 the Application behaviour (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:

  1. Standard applications – they have a callback module that starts a top‑level supervisor. Most of your own services will fall into this category.
  2. Library applications – they contain only modules and no process tree. They still have an .app file, but the mod key 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 run iex -S mix. Good for verbose logging and hot code reloading.
  • Testing (:test) – automatically selected for mix test. Ideal for using in‑memory databases or mock services.
  • Production (:prod) – chosen when you set MIX_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 … --sup scaffolds a ready‑to‑run application with a Application behaviour.
  • Library applications omit the mod: entry but still participate in the dependency graph.
  • Declare third‑party libraries in deps/0; fetch them with mix deps.get and lock versions in mix.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 release bundles 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?