Elixir by Example

Mix Projects

Every command ran when the site was built; the output is real Download project

Every page so far was one file, run with elixir file.exs. Real programs have many modules, tests, settings that differ between your laptop and production, and code written by other people. Mix, the build tool that ships with Elixir, handles all of that: it creates projects, compiles them, runs them and their tests, formats the code and fetches dependencies.

On this page:

  1. What mix new generates.
  2. A small library, bill, that adds up and splits a restaurant bill. It depends on a package from Hex, the package registry.
  3. Compile, run, test and format it.
  4. Build environments (MIX_ENV) and configuration files.
  5. Scripts that need a package, with Mix.install/1.

A new project

mix new <name> creates a project directory. We make one in a throwaway directory just to see what it contains, then delete it.

$ mix new demo
$ rm -rf demo
* creating README.md
* creating .formatter.exs
* creating .gitignore
* creating mix.exs
* creating lib
* creating lib/demo.ex
* creating test
* creating test/test_helper.exs
* creating test/demo_test.exs

Your Mix project was created successfully.
You can use "mix" to compile it, test it, and more:

    cd demo
    mix test

Run "mix help" for more commands.

Every Mix project has the same layout: mix.exs describes the project, lib/ holds the code, test/ the tests, and .formatter.exs tells mix format which files to format. Mix generates two more directories when it runs: deps/ for downloaded dependencies and _build/ for compiled code. Neither belongs in version control, which is what the generated .gitignore says.

The project

bill is laid out the same way. mix.exs is an ordinary module. use Mix.Project makes it the project definition, and Mix calls its functions:

  • project/0 names the app (:bill), gives its version, the Elixir versions it works with, and its dependencies. elixirc_options: [warnings_as_errors: true] makes every compiler warning fail the build, so none slip through. (mix compile --warnings-as-errors does the same for a single run.)
  • application/0 describes the OTP application the project compiles to: a named component that is loaded, started and stopped as a unit. extra_applications: [:logger] says Elixir's Logger must be started before bill. Applications and Configuration shows what else goes here once you have processes to start.
mix.exs
defmodule Bill.MixProject do
  use Mix.Project

  def project do
    [
      app: :bill,
      version: "0.1.0",
      elixir: "~> 1.20",
      elixirc_options: [warnings_as_errors: true],
      deps: deps()
    ]
  end

  def application do
    [extra_applications: [:logger]]
  end

  defp deps do
    [{:decimal, "~> 3.2"}]
  end
end

{:decimal, "~> 3.2"} is a dependency on the Decimal package from Hex, the package registry for Elixir and Erlang. Floats can't represent most decimal fractions exactly (0.1 + 0.2 is 0.30000000000000004), so money should never be a float. Decimal does exact decimal arithmetic.

The string after the name is a version requirement. ~> 3.2 means "3.2 or any later 3.x": updates that by convention add features without breaking anything, but never 4.0. With a third number, ~> 3.2.0 would allow only 3.2.x bug fixes. Elixir's Version module does the matching, and shows exactly what each form accepts:

$ elixir -e 'IO.inspect(for v <- ["3.2.0", "3.9.1", "4.0.0"], do: {v, Version.match?(v, "~> 3.2")})'
$ elixir -e 'IO.inspect(for v <- ["3.2.7", "3.3.0"], do: {v, Version.match?(v, "~> 3.2.0")})'
[{"3.2.0", true}, {"3.9.1", true}, {"4.0.0", false}]
[{"3.2.7", true}, {"3.3.0", false}]
.formatter.exs
[
  inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
.gitignore
/_build/
/deps/

The library itself: three public functions, with exact arithmetic delegated to Decimal. split/2 works in whole cents so the shares always add up to the total. format/1 reads the currency from the configuration (below).

lib/bill.ex
defmodule Bill do
  @moduledoc "Adds up a bill and splits it, in exact decimal arithmetic."

  @doc """
  Adds up prices given as strings.

      iex> Bill.total(["0.10", "0.20"])
      Decimal.new("0.30")
  """
  @spec total([String.t()]) :: Decimal.t()
  def total(prices) do
    Enum.reduce(prices, Decimal.new(0), &Decimal.add(Decimal.new(&1), &2))
  end

  @doc """
  Splits the total between `people`, to the cent. Shares differ by at most
  one cent and always add up to the total.
  """
  @spec split([String.t()], pos_integer()) :: [Decimal.t()]
  def split(prices, people) when is_integer(people) and people > 0 do
    cents = prices |> total() |> Decimal.mult(100) |> Decimal.to_integer()
    share = div(cents, people)
    extra = rem(cents, people)

    for n <- 1..people do
      Decimal.new(1, if(n <= extra, do: share + 1, else: share), -2)
    end
  end

  @doc "Formats an amount in the configured currency."
  @spec format(Decimal.t()) :: String.t()
  def format(amount) do
    Decimal.to_string(amount) <> " " <> Application.fetch_env!(:bill, :currency)
  end
end

Tests live in test/ and use ExUnit, Elixir's test framework. test_helper.exs runs first and starts it.

test/test_helper.exs
ExUnit.start()

use ExUnit.Case turns the module into a test case. Each test is a named block of asserts: an assert on a comparison that fails shows both sides. doctest Bill also runs the iex> example in Bill.total/1's @doc as a test, so documentation examples can't go stale.

test/bill_test.exs
defmodule BillTest do
  use ExUnit.Case
  doctest Bill

  test "shares add up to the total" do
    shares = Bill.split(["10.00"], 3)
    assert Enum.map(shares, &Decimal.to_string/1) == ["3.34", "3.33", "3.33"]
    assert Decimal.equal?(Enum.reduce(shares, &Decimal.add/2), Decimal.new("10.00"))
  end

  test "formats in the test currency" do
    assert Bill.format(Decimal.new("1.50")) == "1.50 XTS"
  end
end

Fetch dependencies and compile

mix deps.get downloads the dependencies into deps/ and records the exact versions in mix.lock. It prints download progress and timings, so its output is hidden here.

$ mix deps.get > /dev/null
$ ls deps
decimal

mix.lock pins every dependency, direct or not, to one version and a checksum of its contents. Commit it: everyone who builds the project, and every server, then gets exactly the same code, even after Decimal publishes 3.3. mix deps.update decimal moves the lock forward when you choose to.

mix.lock
%{
  "decimal": {:hex, :decimal, "3.2.0", "f1235f05d841a211fda78a08fdb9777f2ff51a71f18455233fae186ff76187c6", [:mix], [], "hexpm", "190bca80580ce61aa69bb0eacadd7677710205c770225c2c2c07e32a5c31b234"},
}

mix compile compiles the dependencies first, then the project, and writes one .beam file per module (see Compilation) into _build.

$ mix compile
$ ls _build/dev/lib
$ ls _build/dev/lib/bill/ebin
==> decimal
Compiling 4 files (.ex)
Generated decimal app
==> bill
Compiling 1 file (.ex)
Generated bill app
bill
decimal
Elixir.Bill.beam
bill.app

Besides the module, ebin holds bill.app: the application description Mix generated from mix.exs. Applications and Configuration looks inside it.

mix deps.tree shows which package needs which. Here the tree is short.

$ mix deps.tree
bill
└── decimal ~> 3.2 (Hex package)

Run it

mix run -e compiles the project if needed, starts it, and runs one expression with all of its modules and dependencies available. (mix run file.exs runs a script the same way.)

$ mix run -e 'IO.inspect(Bill.total(["1.10", "2.20"]))'
$ mix run -e 'IO.inspect(Bill.split(["10.00"], 3))'
$ mix run -e 'IO.puts(Bill.format(Bill.total(["1.10", "2.20"])))'
Decimal.new("3.30")
[Decimal.new("3.34"), Decimal.new("3.33"), Decimal.new("3.33")]
3.30 EUR

To try things by hand, iex -S mix starts IEx, Elixir's interactive shell, inside the project: everything mix run loads is there, and recompile() picks up the edits you make while it runs. It's interactive, so it can't run on this page.

Test it

mix test runs every *_test.exs file under test/. Tests run in a random order to flush out tests that depend on each other; the seed is printed so a failing order can be repeated with --seed. We fix the seed, pin --max-cases (how many tests may run at once; the default, twice the number of CPU cores, is printed too), and drop the line with the run time, so the output below is the same on every machine. mix test builds in the test environment (more below), so it compiles everything again first. Each . is a passing test. Testing with ExUnit covers tests in more depth.

$ mix test --seed 0 --max-cases 1 | grep -v '^Finished in'
==> decimal
Compiling 4 files (.ex)
Generated decimal app
==> bill
Compiling 1 file (.ex)
Generated bill app
Running ExUnit with seed: 0, max_cases: 1

...

Result: 3 passed (1 doctest, 2 tests)

Format it

mix format rewrites the files listed in .formatter.exs in the one standard Elixir style. With --check-formatted it changes nothing and fails if any file would change, which is what CI runs. Silence means every file is formatted.

$ mix format --check-formatted

Given - it formats standard input instead, which shows what it does:

$ printf 'defmodule Tip do\ndef of(amount),do: amount\nend\n' | mix format -
defmodule Tip do
  def of(amount), do: amount
end

mix help lists every task (built-in ones, those of your dependencies and your own), and mix help <task> documents one. --search filters the list:

$ mix help --search deps
mix deps         # Lists dependencies and their status
mix deps.clean   # Deletes the given dependencies' files
mix deps.compile # Compiles dependencies
mix deps.get     # Fetches unavailable and out of date dependencies
mix deps.tree    # Prints the dependency tree
mix deps.unlock  # Unlocks the given dependencies
mix deps.update  # Updates the given dependencies

Environments and configuration

Mix builds in one of three environments, chosen by the MIX_ENV variable: dev (the default), test (which mix test switches to by itself) and prod. Each compiles into its own directory under _build, which is why there was a _build/dev above.

Configuration goes in config/config.exs. Each config :app, key: value sets a key in that application's environment, a keyword store that belongs to the OTP application. import Config makes the config macro available, and the last line pulls in the file for the current environment, so it can override what came before.

config/config.exs
import Config

config :bill, currency: "EUR"

# Last, so the environment's file can override anything above.
import_config "#{config_env()}.exs"
config/dev.exs
import Config

# Nothing specific to development yet.
config/test.exs
import Config

# XTS is the ISO 4217 code reserved for testing.
config :bill, currency: "XTS"
config/prod.exs
import Config

config :bill, currency: "USD"

Code reads it with Application.fetch_env!(:bill, :currency), which raises if the key is missing, or Application.get_env(:bill, :currency, "EUR"), which returns a default instead. So Bill.format/1 says EUR under mix run, the test above expected XTS, and in prod:

$ MIX_ENV=prod mix run -e 'IO.puts(Bill.format(Decimal.new("9.99")))'
$ ls _build
==> decimal
Compiling 4 files (.ex)
Generated decimal app
==> bill
Compiling 1 file (.ex)
Generated bill app
9.99 USD
dev
prod
test

The prod build compiled everything again into _build/prod, and _build now has one directory per environment that has run.

These files are read when the project is built. Settings that must be read on the server when the program starts, such as secrets from environment variables, go in config/runtime.exs, covered in Runtime Configuration.

Scripts with dependencies

A single script can use Hex packages too. Mix.install/1 at the top fetches and compiles them on the first run and caches them, so later runs start quickly:

Mix.install([{:decimal, "~> 3.2"}])

IO.puts(Decimal.add(Decimal.new("0.1"), Decimal.new("0.2")))

Mix.install/1 prints its progress the first time but not later, so the scripts on this site that use it first call Mix.start() and Mix.shell(Mix.Shell.Quiet), which silences Mix's own output, so their output is the same every time.

Pitfall: reading configuration in a module body, as in @currency Application.fetch_env!(:bill, :currency), runs once at compile time and freezes the value into the compiled code. The compiler warns about it. Read configuration inside functions, as Bill.format/1 does; Applications and Configuration covers Application.compile_env/3 for the rare value that must be fixed at compile time.

Pitfall: Mix.install/1 is for scripts only. Inside a Mix project it raises "Mix.install/2 cannot be used inside a Mix project"; add the package to deps in mix.exs instead.

Pitfall: a version requirement without a lock file is a range, not a version. Without mix.lock in version control, two machines that run mix deps.get a week apart can build different code.