7 min read
Building in Layers
Why Elixir Programs Stay Maintainable When the Core, Boundary, and API Each Do One Job

The first time you write a real program in Elixir, you notice something. The code that defines your rules is short and clean. The code that prompts the user, reads input, and loops until they exit takes up half the file. Both are doing real work. But they’re not the same kind of work, and the program gets harder to change every time you let them touch.

That mix is where most maintenance pain comes from. It’s also avoidable. Real Elixir programs are organized in layers: a functional core that holds the rules, a boundary that holds the IO and the looping, and an API that stitches them together. Each layer answers one question at a time.

Bruce frames it through cooking:

“We are building our lasagna, right? We’re layering the application so that we only have to focus on one layer of the dish at a time.”

  • Bruce Tate

The lasagna isn’t about tidiness. It’s a way to keep two kinds of complexity from touching each other.

Mix is where the layers live

Real Elixir projects start with mix new. That command scaffolds a directory with everything you need to compile, test, and ship: a mix.exs for project configuration and dependencies, a lib/ folder where your modules live, and a test/ folder ready to go. It’s the door between the playground of iex and a project you can hand to someone else.

You don’t need to memorize every file the generator drops. lib/ is where your code lives. mix.exs is where the project describes itself. Everything else gets out of the way.

We’ll build a small program called Homer, a bot that responds the way Homer Simpson might. The point isn’t the bot. The point is what the structure teaches us.

🎯 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.

The functional core

Inside lib/homer/, we put a module called Homer.Sentence. It does one thing: take a sentence and decide what Homer says back, with no IO, no user input, and no side effects.

defmodule Homer.Sentence do
  def say(sentence \\ "doh") do
    cond do
      String.ends_with?(sentence, "?") -> "Trying is the first step toward failure."
      String.ends_with?(sentence, "!") -> "Mmm, donuts."
      sentence == String.upcase(sentence) -> "Why you little..."
      true -> "doh"
    end
  end
end

That’s the entire core. Same input, same output, every time. You can read it, test it, and reason about it without thinking about anything outside it. This is what Bruce calls “an inner core of the programming logic.” It exists to be predictable.

The boundary

A real bot needs to hold a conversation. That means listening for input, printing output, and looping until the user wants to leave. None of that belongs in the core. It belongs in the boundary.

defmodule Homer.Conversation do
  alias Homer.Sentence

  def loop("bye"), do: IO.puts("Woo-hoo!")
  def loop(_) do
    input =
      "say something to homer> "
      |> IO.gets()
      |> String.trim()

    input |> Sentence.say() |> IO.puts()
    loop(input)
  end
end

The pipe at the top reads top to bottom: IO.gets produces a string with a trailing newline, String.trim cleans it up, and the result lands in input. The last line, loop(input), calls the function again with the new value, and that’s how the conversation keeps going. Recursion is how Elixir loops, and the first clause, loop("bye"), is how the loop ends.

Bruce makes the split explicit:

“We have an inner core of the programming logic, and then we have the machinery that goes around that. And that’s called our boundary.”

  • Bruce Tate

The API layer

There’s one more layer worth naming: the public face of the application. In Homer, that’s lib/homer.ex. It’s the function the outside world calls.

defmodule Homer do
  alias Homer.Conversation

  def talk, do: Conversation.loop("start")
end

alias is a small convenience. Without it, every reference here would be Homer.Conversation. With it, the same module is just Conversation for the rest of the file. The full name is still implied; only the typing got shorter, and the code reads like prose.

The API layer doesn’t compute anything. It coordinates. Three layers, three jobs, no overlap.

A rule of thumb you can use today

The clearest sign you’re missing the layers is a single function doing too much at once. Watch what happens when input, decision, and output all share one body:

def respond do
  input = "say something> " |> IO.gets() |> String.trim()
  response =
    cond do
      String.ends_with?(input, "?") -> "Trying is the first step toward failure."
      true -> "doh"
    end
  IO.puts(response)
end

The function reads from the world, decides what’s true, and writes back to the world all in one breath. You can’t test the decision without a terminal. You can’t change the prompt without touching the rules. Split it along the seam:

# core: pure decision
def say(sentence) do
  cond do
    String.ends_with?(sentence, "?") -> "Trying is the first step toward failure."
    true -> "doh"
  end
end

# boundary: the world
"say something> "
|> IO.gets()
|> String.trim()
|> Sentence.say()
|> IO.puts()

Same behavior, two homes. The rule of thumb is short: if a function reads from the world, it can’t also decide what’s true. The moment those jobs share a body, both get harder to change.

Why this pays off

“This design is going to make it easy to think about and maintain the systems that we create with Elixir.”

  • Bruce Tate

When pure logic lives in the core and side effects live at the boundary, every kind of change has a home. A new way of greeting Homer is a core change. A new input device is a boundary change. The two never have to negotiate.

It also matches how programs actually get written. In Bruce’s class, he wires part of the pipe incorrectly, notices that Homer is not printing what he expects, and fixes it on camera.

“Real programmers make mistakes. I’m not going to delete all the mistakes from my program because you need to see the process.”

  • Bruce Tate

That is the same lesson the architecture teaches. When each layer holds one kind of complexity, you can debug one kind of problem at a time. The mistakes don’t compound.

Once you start thinking in layers, you stop asking “where does this code go?” and start asking “what kind of complexity is this?” That shift is what turns a script into a program you can grow.

In the next post, we’ll take this same pattern further: Mix tasks are just Elixir, and the core/boundary split shows up there too.


📚 Production Architecture Starts with Clear Boundaries

This comes from our Learning Elixir course, where Bruce teaches the mental models behind production architecture decisions through real-world scenarios. Learn how to separate pure logic, IO boundaries, and API coordination so your code stays easier to test, debug, and grow. Available via monthly subscription - try it for one month.

See you in the next chapter.

  • 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.