6 min read
Mix Tasks Are Just Elixir
How Mix Turns Plain Modules Into First-Class Project Tools

The first time you run mix test or mix compile, it feels like Mix is something separate from your code. A wrapper around the language, a build tool the Elixir team wrote that you call from the outside.

Then you notice something that brings the picture into focus. Those commands you’ve been running aren’t built into Mix at all. They’re modules. Modules someone wrote in plain Elixir, dropped in a particular folder, and made callable from the command line. Which means the ones you’ve been using and the ones you might write tomorrow are the same kind of thing.

Bruce puts it plainly:

“Mix is a relatively easy system to understand and you don’t have to be limited to the mix tasks that are already created for you. You can roll your own.” — Bruce Tate

Once you see that, Mix stops feeling like infrastructure and starts feeling like an extension point.

What a Mix Task Actually Is

A Mix task is a module that implements a single behaviour: Mix.Task. The behaviour requires one function, run/1, which takes a list of command-line arguments. That’s the whole contract.

defmodule Mix.Tasks.Rps do
  use Mix.Task

  @shortdoc "Play one round of rock paper scissors"

  def run(args) do
    # ...
  end
end

Two things to notice. The module name follows a convention, Mix.Tasks.<Name>, and Mix uses that name to find your task when someone types mix rps at the terminal. The use Mix.Task line is what opts the module into the contract. Bruce describes that line in class:

“This tells me that I am implementing the contract for this mix behaviour.” — Bruce Tate

You’re not inheriting machinery. You’re declaring “here is a module that promises to behave like a Mix task.” Mix handles the rest: discovery, dispatch, help text.

The file lives in lib/mix/tasks/rps.ex. That folder isn’t magic. It’s just where Mix looks. Your tasks compile alongside your application code, with the same warnings and the same tooling.

🎯 Join Groxio's Newsletter

Weekly lessons on Elixir, system design, and AI-assisted development -- plus stories from our training and mentoring sessions.

We respect your privacy. No spam, unsubscribe anytime.


Pattern Matching the Command Line

When you run mix rps rock, the string "rock" arrives in args as part of a list. Mix splits the arguments and hands them to run/1 as ordinary Elixir data, which means the parser you already know how to use is pattern matching.

def run(args) do
  [move] = args
  move |> RPS.play() |> IO.puts()
end

The pattern match [move] = args says “this task expects exactly one argument, and I’ll call it move.” If a different number of arguments comes in, the match fails loudly. That’s the right kind of failure. The contract is visible in the code.

From there, the task does almost nothing. It hands the move to RPS.play/1 and prints whatever comes back.

The Core Behind the Task

RPS.play/1 is where the rules of the game live. It picks a move for the opponent and judges the result.

defmodule RPS do
  def play(move) do
    opponent = Enum.random(["rock", "paper", "scissors"])
    judge(move, opponent)
  end

  defp judge(m, m), do: "tie"
  defp judge("rock", "scissors"), do: "win"
  defp judge("paper", "rock"), do: "win"
  defp judge("scissors", "paper"), do: "win"
  defp judge(_, _), do: "loss"
end

This module knows nothing about Mix, the command line, or printing. It takes a move, returns a result, and could be called from a test, a Phoenix controller, or another task without a single change.

That split is the point of the whole exercise:

“What we have is a functional core that implements the rules of the game and a mix task that implements the interface into our functional core.” — Bruce Tate

The Mix task is the boundary. It deals with the outside world: argv, IO, exit conditions. The core is pure, dealing only with the rules of the game. Two layers with no overlap, each one easy to read on its own.

Discoverability Is Part of the Contract

The @shortdoc line at the top of the task isn’t decoration. When someone runs mix help in your project, that string is what they see next to your task name, a one-line summary that tells them what mix rps is for.

mix rps    # Play one round of rock paper scissors

That hook is the only difference between a task that exists and a task someone can find. Skipping the @shortdoc doesn’t break anything; it just makes the task invisible to the help system. Treat it like a public signature, not a comment.

What This Gives You

Once you internalize that Mix tasks are just Elixir modules, the question changes. You stop asking “is there a task that does X?” and start asking “should I write one?”

A repetitive setup step in your project doesn’t need a shell script. A team-specific data import doesn’t need a one-off file you iex your way through. Both are tasks waiting to be named, dropped in lib/mix/tasks/, and made first-class members of your tooling.

The boundary you write keeps doing what every boundary does: parse the input, call the core, format the output. The core stays clean and testable. The shell command you type is just the mouth of a function you wrote yourself.

Mix isn’t a closed system pretending to be open. It’s open all the way down. When the tool you depend on is the same kind of code you write, extending it isn’t a special skill, it’s part of the language.

In the next post, we’ll stay inside Mix and look at how Elixir treats documentation as a first-class artifact, with module attributes and doctests doing the work.

See you in the next chapter.


Want to Learn Elixir the Way It's Actually Designed?

This post comes from Bruce's structured Elixir course, where you build the mental models behind everyday tools like Mix, modules, and functional cores. Learn Elixir through real-world scenarios that make the language's design feel coherent instead of surprising.

— Paulo & Bruce

Bruce Tate's avatar
Bruce Tate
System architecture expert and author of 10+ Elixir books.
Paulo Valim's avatar
Paulo Valim
Full-stack Elixir developer and educator teaching modern Elixir and AI-assisted development.