DEV Community

Cover image for Four Mindset Shifts for Teams Adopting Elixir
Gabriel Ortuño
Gabriel Ortuño

Posted on Originally published at arctarus.com AI-assisted

Four Mindset Shifts for Teams Adopting Elixir

How immutability, explicit data, pattern matching, and result values change software design.

The first warning sign usually appears after the team is already productive.

People know the syntax. They can build Phoenix controllers, write Ecto queries, use pipelines, and get features into production.

And yet the code still feels wrong.

A GenServer starts looking suspiciously like a service object. Pipelines connect functions that return unrelated values. Structs are updated directly from everywhere. Business failures become exceptions. Loops disappear, but the underlying algorithms still assume mutable arrays.

The team has learned Elixir syntax without changing how it thinks about software.

I have worked with Elixir for about a decade and helped many engineers learn it. I have seen the same pattern across different teams and experience levels: the syntax comes quickly. Habits learned in imperative and object-oriented ecosystems take much longer to unlearn.

Experienced developers will learn case, with, and Enum.map/2. One important adoption risk lies elsewhere: whether the team can recognize which familiar assumptions no longer serve them.

If I were introducing Elixir to an experienced engineering team, I would focus much less on syntax and much more on four mindset shifts.

Here is the short version:

  1. Move from mutable state to immutable values.
  2. Move from imperative instructions to declarative transformations.
  3. Move from methods on objects to functions over explicit data.
  4. Move from inspect-then-branch code to patterns and explicit results.

1. From mutable state to immutable values

The inherited habit. In imperative languages, state is commonly represented by values that change over time. An accumulator, object, or array is updated in place, and the algorithm is understood as a sequence of mutations.

Why it creates awkward Elixir. A direct translation tends to reproduce mutation through excessive rebinding, indexed list access, or stateful processes. The syntax changes, but the algorithm still assumes that one location is being updated. This is especially awkward with Elixir lists, which are linked lists rather than mutable arrays: repeatedly accessing elements by position with Enum.at(items, index), or replacing them with List.replace_at(items, index, value), requires traversal and is usually the wrong shape for the problem.

The Elixir mental model. Values do not change. Variables can be rebound, but rebinding points a name at a new value; it does not modify the previous one. A changed user must come from an operation that returns a new value.

user = %User{name: "Ana"}
user = %{user | name: "Anna"}
Enter fullscreen mode Exit fullscreen mode

The second expression creates a new struct and rebinds user; the first struct was never changed. That distinction matters when values cross function or process boundaries. Sharing a value does not give another caller a mutable reference to your copy, so ownership and synchronization do not have to be designed around ordinary in-memory mutation.

This makes local reasoning easier. When reading a function, you can follow the values it receives and returns without wondering whether another reference changed them elsewhere. This is especially useful when several processes receive the same input value.

A representative example. Instead of mutating a running total, pass each intermediate value to the next step:

total =
  Enum.reduce(orders, 0, fn order, accumulator ->
    accumulator + order.amount
  end)
Enter fullscreen mode Exit fullscreen mode

The accumulator is not a mutable location hidden inside the loop. Each call returns the value received by the next call. The useful question becomes: “What should the next value be?”

What I’ve seen in practice. A mistake I have seen many times is building a list by appending every new element with acc ++ [item]. Elixir lists are linked lists, so each append traverses everything accumulated so far. Repeating it turns a linear transformation into an O(n²) algorithm. Prepending with [item | acc] and reversing once at the end keeps it linear. Knowing how the list is represented would have pointed directly to the better implementation.

A code-review question.

Is this code modeling a sequence of values, or reproducing mutation with more complicated syntax?

Do not over-apply the lesson. Elixir programs still have side effects. Functions can write to a database or ETS, send messages, access files, and call external services. Those effects still need careful design. Immutable data structures also have performance characteristics that matter. For example, this is valid:

Enum.at(items, index)
Enter fullscreen mode Exit fullscreen mode

Positional access still traverses the list. In practice, choose data structures and operations that fit the algorithm and make state changes easy to follow.

2. From imperative instructions to declarative transformations

The inherited habit. Imperative code explains how to perform work: initialize a result, control an index, traverse a collection, branch, mutate the result, and continue.

Why it creates awkward Elixir. Translating those mechanics literally produces manual recursion, unnecessary accumulators, or pipelines added only to make code look idiomatic. The reader sees how iteration happens but has to reconstruct what the transformation means.

The Elixir mental model. Describe operations on data and let the abstraction handle the traversal. Functions such as map, filter, and reduce name the transformation being performed. Saving lines is secondary. Elixir control-flow constructs, including if, case, and with, are expressions too: each branch returns a value.

For example, a conditional can produce the next value directly:

status =
  if user.active do
    :available
  else
    :unavailable
  end
Enter fullscreen mode Exit fullscreen mode

The branch returns :available or :unavailable. No outer variable has to be updated.

A representative example. The intent of filtering active users and normalizing their addresses can appear directly in the code:

active_users = Enum.filter(users, &User.active?/1)
emails = Enum.map(active_users, &String.downcase(&1.email))
Enter fullscreen mode Exit fullscreen mode

When normalization has domain meaning, name it:

Enum.map(active_users, &normalized_email/1)
Enter fullscreen mode Exit fullscreen mode

Here, normalized_email/1 tells the reader why the operation exists. The inline version is also fine when lowercasing is all that needs to be said.

The same principle shapes APIs. Put the value being transformed in the first argument so it composes naturally:

user
|> normalize_user(options)
|> validate_user()
Enter fullscreen mode Exit fullscreen mode

An API such as normalize_user(options, user) can work, but it fights the usual direction of the pipe. A well-shaped API makes piping possible; adding |> cannot fix an awkward argument order.

What I’ve seen in practice. I have also seen Enum.each/2 used while a developer tries to keep an accumulator outside the callback. Rebinding a variable inside the anonymous function does not update its outer binding. The code then fails to produce the expected result, or external mutable state is introduced to make it work. Enum.each/2 is intended for side effects. To construct a new collection, use map, filter, or reduce, which return the intermediate state directly.

A code-review question.

Does this code describe a meaningful transformation, or expose iteration and plumbing that an abstraction should own?

Do not over-apply the lesson. Putting everything in a pipeline does not make it declarative. A useful pipeline passes a compatible value from one step to the next and tells a coherent story. When that stops being true, intermediate names, case, or with usually make the code easier to read.

3. From methods on objects to functions over explicit data

The inherited habit. Object-oriented design often bundles data, behavior, identity, and lifecycle into an object. Teams naturally look for the Elixir construct that replaces that bundle.

Why it creates awkward Elixir. Elixir has no single replacement for that bundle. Problems start when structs are updated like mutable objects, use is treated as inheritance, or every domain entity is placed inside a process. These choices mix domain modeling with unrelated runtime mechanisms.

The Elixir mental model. Structs make the shape of domain data explicit, but they do not protect business rules by themselves. Put those rules in named functions that validate and apply each transition. For example, cancelling an order should go through Order.cancel/1 rather than updating %Order{status: ...} directly.

Elixir offers several ways to organize behavior: ordinary functions, behaviours, protocols, and, when compile-time code injection is useful, use. Start with the simplest option that solves the problem, often a function. Introduce a more specialized abstraction only when the requirements justify the additional structure.

A representative example. An Email constructor can validate its input before returning the struct:

defmodule Email do
  defstruct [:value]

  def new(value) when is_binary(value) do
    if valid?(value) do
      {:ok, %__MODULE__{value: value}}
    else
      {:error, :invalid_email}
    end
  end

  defp valid?(value) do
    Regex.match?(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, value)
  end
end
Enter fullscreen mode Exit fullscreen mode

The validation rule is deliberately simplified here; the important part is the construction path, not email syntax.

%Email{} remains public because Elixir cannot make struct construction private. Even so, Email.new/1 gives the application one place to reuse the validation rule.

What I’ve seen in practice. I have often seen structs updated from unrelated modules, bypassing the functions that contain the domain rules. Raw maps from external APIs cause a similar problem when they travel through several layers without normalization or a documented shape. Maps are fine at the system boundary. Trouble starts when that transport format becomes the domain model and every caller has to guess which keys exist. Validating the response once and converting it into a known structure gives later code something reliable to work with.

Do not turn every domain entity into a process

A process is a concurrency, lifecycle, and failure boundary, not Elixir's replacement for an object. A GenServer serializes access to its state and can participate in supervision, but that does not make it the natural home for every User, Order, or Invoice.

Keep ordinary domain transformations in functions over data. Introduce a process when the system needs serialization, coordination between callers, ownership of runtime state or resources, or a supervised lifecycle. Create the process because the runtime needs one, not simply because the domain contains an entity.

A code-review question.

Does this abstraction express a domain rule or a real runtime boundary, or is it recreating an object hierarchy with structs, macros, and processes?

Do not over-apply the lesson. Keep domain rules behind functions even when the data is represented explicitly. At the same time, reach for a behaviour, protocol, macro, or GenServer only when the problem requires it. Most ordinary transformations need none of them.

4. From inspect-then-branch code to patterns and explicit results

The inherited habit. Imperative code often accepts a broad value, inspects it inside the function, nests conditionals, and raises exceptions when an expected operation cannot continue.

Why it creates awkward Elixir. The function signature accepts states the implementation cannot actually handle. Basic assumptions only appear later in branches. Expected business failures become exceptions, and broad catch-all clauses can merge outcomes that callers need to distinguish.

The Elixir mental model. Put structural assumptions at function boundaries with patterns and guards. Represent expected outcomes with values such as {:ok, value} and {:error, reason}. Then choose case, with, or a pipe according to the shape of those results.

A representative example. Function clauses can distinguish supported event shapes with non-empty string identifiers, known event types with missing or invalid identifiers, unsupported types, and payloads without a type:

def handle(%{
      "type" => "user.created",
      "user_id" => user_id
    })
    when is_binary(user_id) and user_id != "" do
  create_user_projection(user_id)
end

def handle(%{
      "type" => "user.deleted",
      "user_id" => user_id
    })
    when is_binary(user_id) and user_id != "" do
  delete_user_projection(user_id)
end

def handle(%{"type" => type})
    when type in ["user.created", "user.deleted"] do
  {:error, :invalid_event}
end

def handle(%{"type" => _unsupported_type}) do
  {:error, :unsupported_event}
end

def handle(_payload) do
  {:error, :invalid_event}
end
Enter fullscreen mode Exit fullscreen mode

Each clause shows which input shape it handles. Guards can add constraints on the matched values. Here is a deliberately non-monetary numerical example:

def percentage_of(value, percentage)
    when is_number(value) and is_number(percentage) and
           percentage >= 0 and percentage <= 100 do
  value * percentage / 100
end
Enter fullscreen mode Exit fullscreen mode

Expected business failures can then remain ordinary return values:

{:ok, user}
{:error, :invalid_email}
Enter fullscreen mode Exit fullscreen mode

Use a pipe when each operation accepts the complete value returned by the previous one:

params
|> normalize()
|> build_user()
Enter fullscreen mode Exit fullscreen mode

A pipe does not understand {:ok, value} or {:error, reason} and does not short-circuit automatically. Functions can add clauses that propagate errors, although that moves the control-flow rules away from the call site.

Use with when each step should run only after a particular success shape. The <- clauses show those shapes at the call site, and with returns the first value that does not match:

with {:ok, attrs} <- validate_registration(params),
     {:ok, user} <- create_user(attrs),
     :ok <- publish_user_created(user) do
  {:ok, user}
end
Enter fullscreen mode Exit fullscreen mode

Use case when the alternatives themselves matter:

case Payments.charge(payment) do
  {:ok, receipt} ->
    confirm_payment(receipt)

  {:error, :card_declined} ->
    request_another_payment_method(payment)

  {:error, reason} ->
    handle_payment_error(reason)
end
Enter fullscreen mode Exit fullscreen mode

What I’ve seen in practice. I have often seen a function accept any value and then ask, several times, what it received through nested if and case expressions. case is useful; the issue is that basic validation happens too late. Patterns and guards could reject unsupported inputs at the boundary. I have also seen functions return {:ok, value} when no error outcome exists, exceptions used for recoverable failures, and pipelines connecting incompatible result shapes. Together, these choices leave the caller unsure about what the function will finally return.

A code-review question.

Are the accepted states and expected failures visible at the boundary, and does the control flow preserve their shape?

Do not over-apply the lesson. Return an explicit result when the caller can reasonably recover. Raise when the function's contract is violated or when the caller explicitly chooses a raising API. Be careful with defensive catch-all patterns as well. A broad _ -> may keep the code running while hiding a new state that should force a code change.

Practical implications for evaluating and onboarding a team

When evaluating an Elixir adoption. Learning the syntax is only one part of the work. Look at whether the team is willing to change how it represents state, designs APIs, protects domain rules, introduces concurrency, and communicates failure. A team that keeps its existing architecture and changes only the punctuation will pay for that mismatch in complexity.

These mindset shifts are only one part of technology selection; workload fit, ecosystem coverage, operational maturity, and integration constraints still need separate evaluation.

The four code-review questions in this article provide a practical evaluation framework:

  1. Are we modeling a sequence of values or recreating mutation?
  2. Does the code name the transformation, or expose the iteration mechanics?
  3. Are our abstractions expressing domain rules and real runtime boundaries, or imitating objects?
  4. Can callers tell which inputs are accepted and which failures they can recover from?

When onboarding the team. Organize training around those questions instead of giving only a tour of language features. Pair each mental model with one representative example and revisit it in production code reviews. Teach where a pipe, process, behaviour, or tagged tuple helps, and where it adds unnecessary machinery.

After ten years with Elixir, this is the part I would not leave implicit. Teams usually learn the syntax. The harder work is recognizing the design habits they brought with them and deciding, case by case, whether those habits still fit.

Top comments (0)