<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Gabriel Ortuño </title>
    <description>The latest articles on DEV Community by Gabriel Ortuño  (@arctarus).</description>
    <link>https://dev.to/arctarus</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1773983%2Fb23575d2-959e-41e7-925c-dda98997668b.png</url>
      <title>DEV Community: Gabriel Ortuño </title>
      <link>https://dev.to/arctarus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arctarus"/>
    <language>en</language>
    <item>
      <title>Four Mindset Shifts for Teams Adopting Elixir</title>
      <dc:creator>Gabriel Ortuño </dc:creator>
      <pubDate>Sun, 04 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/arctarus/four-mindset-shifts-for-teams-adopting-elixir-nk8</link>
      <guid>https://dev.to/arctarus/four-mindset-shifts-for-teams-adopting-elixir-nk8</guid>
      <description>&lt;h2&gt;
  
  
  How immutability, explicit data, pattern matching, and result values change software design.
&lt;/h2&gt;

&lt;p&gt;The first warning sign usually appears after the team is already productive.&lt;/p&gt;

&lt;p&gt;People know the syntax. They can build Phoenix controllers, write Ecto queries, use pipelines, and get features into production.&lt;/p&gt;

&lt;p&gt;And yet the code still feels wrong.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;GenServer&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The team has learned Elixir syntax without changing how it thinks about software.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;If I were introducing Elixir to an experienced engineering team, I would focus much less on syntax and much more on four mindset shifts.&lt;/p&gt;

&lt;p&gt;Here is the short version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Move from mutable state to immutable values.&lt;/li&gt;
&lt;li&gt;Move from imperative instructions to declarative transformations.&lt;/li&gt;
&lt;li&gt;Move from methods on objects to functions over explicit data.&lt;/li&gt;
&lt;li&gt;Move from inspect-then-branch code to patterns and explicit results.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8hlo7t3d2w0wulq10tx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8hlo7t3d2w0wulq10tx.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. From mutable state to immutable values
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The inherited habit.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it creates awkward Elixir.&lt;/strong&gt; 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 &lt;code&gt;Enum.at(items, index)&lt;/code&gt;, or replacing them with &lt;code&gt;List.replace_at(items, index, value)&lt;/code&gt;, requires traversal and is usually the wrong shape for the problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Elixir mental model.&lt;/strong&gt; 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 &lt;code&gt;user&lt;/code&gt; must come from an operation that returns a new value.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;%&lt;/span&gt;&lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"Ana"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;%{&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="ss"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"Anna"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second expression creates a new struct and rebinds &lt;code&gt;user&lt;/code&gt;; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A representative example.&lt;/strong&gt; Instead of mutating a running total, pass each intermediate value to the next step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accumulator&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;accumulator&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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?”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I’ve seen in practice.&lt;/strong&gt; A mistake I have seen many times is building a list by appending every new element with &lt;code&gt;acc ++ [item]&lt;/code&gt;. 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 &lt;code&gt;[item | acc]&lt;/code&gt; and reversing once at the end keeps it linear. Knowing how the list is represented would have pointed directly to the better implementation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A code-review question.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this code modeling a sequence of values, or reproducing mutation with more complicated syntax?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Do not over-apply the lesson.&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  2. From imperative instructions to declarative transformations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The inherited habit.&lt;/strong&gt; Imperative code explains how to perform work: initialize a result, control an index, traverse a collection, branch, mutate the result, and continue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it creates awkward Elixir.&lt;/strong&gt; 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.&lt;/p&gt;

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

&lt;p&gt;For example, a conditional can produce the next value directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="ss"&gt;:available&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="ss"&gt;:unavailable&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The branch returns &lt;code&gt;:available&lt;/code&gt; or &lt;code&gt;:unavailable&lt;/code&gt;. No outer variable has to be updated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A representative example.&lt;/strong&gt; The intent of filtering active users and normalizing their addresses can appear directly in the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="n"&gt;active_users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active?&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;emails&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;active_users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="no"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;downcase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;&amp;amp;1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When normalization has domain meaning, name it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;active_users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;normalized_email&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The same principle shapes APIs. Put the value being transformed in the first argument so it composes naturally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="n"&gt;user&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;normalize_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;validate_user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;What I’ve seen in practice.&lt;/strong&gt; I have also seen &lt;code&gt;Enum.each/2&lt;/code&gt; 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. &lt;code&gt;Enum.each/2&lt;/code&gt; is intended for side effects. To construct a new collection, use &lt;code&gt;map&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, or &lt;code&gt;reduce&lt;/code&gt;, which return the intermediate state directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A code-review question.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does this code describe a meaningful transformation, or expose iteration and plumbing that an abstraction should own?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Do not over-apply the lesson.&lt;/strong&gt; 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, &lt;code&gt;case&lt;/code&gt;, or &lt;code&gt;with&lt;/code&gt; usually make the code easier to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. From methods on objects to functions over explicit data
&lt;/h2&gt;

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

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

&lt;p&gt;&lt;strong&gt;The Elixir mental model.&lt;/strong&gt; 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 &lt;code&gt;Order.cancel/1&lt;/code&gt; rather than updating &lt;code&gt;%Order{status: ...}&lt;/code&gt; directly.&lt;/p&gt;

&lt;p&gt;Elixir offers several ways to organize behavior: ordinary functions, behaviours, protocols, and, when compile-time code injection is useful, &lt;code&gt;use&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A representative example.&lt;/strong&gt; An &lt;code&gt;Email&lt;/code&gt; constructor can validate its input before returning the struct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;defmodule&lt;/span&gt; &lt;span class="no"&gt;Email&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;defstruct&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;when&lt;/span&gt; &lt;span class="n"&gt;is_binary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;valid?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;%&lt;/span&gt;&lt;span class="bp"&gt;__MODULE__&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;value:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:invalid_email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;valid?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;Regex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;match?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The validation rule is deliberately simplified here; the important part is the construction path, not email syntax.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What I’ve seen in practice.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do not turn every domain entity into a process
&lt;/h3&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A code-review question.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does this abstraction express a domain rule or a real runtime boundary, or is it recreating an object hierarchy with structs, macros, and processes?&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;h2&gt;
  
  
  4. From inspect-then-branch code to patterns and explicit results
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;Why it creates awkward Elixir.&lt;/strong&gt; 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.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A representative example.&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(%{&lt;/span&gt;
      &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"user.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"user_id"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="ow"&gt;when&lt;/span&gt; &lt;span class="n"&gt;is_binary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;create_user_projection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(%{&lt;/span&gt;
      &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"user.deleted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"user_id"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="ow"&gt;when&lt;/span&gt; &lt;span class="n"&gt;is_binary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;delete_user_projection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(%{&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="ow"&gt;when&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"user.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"user.deleted"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:invalid_event&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(%{&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_unsupported_type&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:unsupported_event&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:invalid_event&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each clause shows which input shape it handles. Guards can add constraints on the matched values. Here is a deliberately non-monetary numerical example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;percentage_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="ow"&gt;when&lt;/span&gt; &lt;span class="n"&gt;is_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;is_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt;
           &lt;span class="n"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected business failures can then remain ordinary return values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:invalid_email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a pipe when each operation accepts the complete value returned by the previous one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="n"&gt;params&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;build_user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pipe does not understand &lt;code&gt;{:ok, value}&lt;/code&gt; or &lt;code&gt;{:error, reason}&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;with&lt;/code&gt; when each step should run only after a particular success shape. The &lt;code&gt;&amp;lt;-&lt;/code&gt; clauses show those shapes at the call site, and &lt;code&gt;with&lt;/code&gt; returns the first value that does not match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="n"&gt;validate_registration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
     &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="n"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
     &lt;span class="ss"&gt;:ok&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="n"&gt;publish_user_created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;case&lt;/code&gt; when the alternatives themselves matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;Payments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;confirm_payment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:card_declined&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;request_another_payment_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;handle_payment_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I’ve seen in practice.&lt;/strong&gt; I have often seen a function accept any value and then ask, several times, what it received through nested &lt;code&gt;if&lt;/code&gt; and &lt;code&gt;case&lt;/code&gt; expressions. &lt;code&gt;case&lt;/code&gt; 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 &lt;code&gt;{:ok, value}&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A code-review question.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Are the accepted states and expected failures visible at the boundary, and does the control flow preserve their shape?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Do not over-apply the lesson.&lt;/strong&gt; 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 &lt;code&gt;_ -&amp;gt;&lt;/code&gt; may keep the code running while hiding a new state that should force a code change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical implications for evaluating and onboarding a team
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When evaluating an Elixir adoption.&lt;/strong&gt; 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.&lt;/p&gt;

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

&lt;p&gt;The four code-review questions in this article provide a practical evaluation framework:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Are we modeling a sequence of values or recreating mutation?&lt;/li&gt;
&lt;li&gt;Does the code name the transformation, or expose the iteration mechanics?&lt;/li&gt;
&lt;li&gt;Are our abstractions expressing domain rules and real runtime boundaries, or imitating objects?&lt;/li&gt;
&lt;li&gt;Can callers tell which inputs are accepted and which failures they can recover from?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;When onboarding the team.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>elixir</category>
      <category>functional</category>
      <category>architecture</category>
      <category>softwaredesign</category>
    </item>
    <item>
      <title>Is Elixir’s Observability Ready for Production? A Guide for Skeptical Engineers</title>
      <dc:creator>Gabriel Ortuño </dc:creator>
      <pubDate>Sun, 01 Feb 2026 09:25:31 +0000</pubDate>
      <link>https://dev.to/arctarus/is-elixirs-observability-ready-for-production-a-guide-for-skeptical-engineers-2hj2</link>
      <guid>https://dev.to/arctarus/is-elixirs-observability-ready-for-production-a-guide-for-skeptical-engineers-2hj2</guid>
      <description>&lt;p&gt;As a software engineer coming from ecosystems like the JVM, Go, or Node.js you have a deep understanding of Site Reliability Engineering (SRE) principles and the modern observability stack. You know what it takes to operate systems at scale. When evaluating a new technology like Elixir, a critical question inevitably arises: “Can I really observe and operate this in production?” It’s a valid concern, born from experience with runtimes that can often feel like black boxes.&lt;/p&gt;

&lt;p&gt;This article provides a direct, technically-grounded answer to that question. We will explore how Elixir offers a unique, two-pronged observability strategy: unparalleled real-time introspection thanks to its runtime design, combined with full integration into the standardized world of OpenTelemetry and Prometheus. The goal is to demonstrate that Elixir’s observability isn’t just an afterthought; it’s a powerful combination of deep, intrinsic runtime visibility and modern, standards-based instrumentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A Different Foundation: Observability by Design in the BEAM VM
&lt;/h2&gt;

&lt;p&gt;To understand observability in Elixir, we must first look at its foundation. Elixir runs on the BEAM virtual machine, a core component of the Erlang Run-Time System (ERTS). The BEAM was designed from the ground up for concurrent, fault-tolerant systems, and the ability to inspect a live system is a fundamental part of its architecture, not a feature added later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lightweight Processes: The Core of Concurrency
&lt;/h3&gt;

&lt;p&gt;The core unit of execution in an Elixir application is a “process.” Unlike heavyweight OS threads, BEAM processes are extremely lightweight. Each process is an isolated entity with its own stack, heap, and message queue (mailbox), all managed by the BEAM’s preemptive scheduler. This is fundamentally different from OS threads, which are managed by the kernel and have a much larger memory footprint. The BEAM’s ability to manage millions of lightweight processes within a single OS process is central to both its concurrency model and its observability, as the state of every single unit of work is visible to the runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Built-in Introspection with Observer
&lt;/h3&gt;

&lt;p&gt;The most striking feature for engineers new to the BEAM is Observer, a powerful graphical tool that is built directly into the runtime. It allows you to connect to a live Elixir node—in development or production—and inspect its internal state in real-time. This is not a third-party add-on; it’s a core capability of ERTS.&lt;/p&gt;

&lt;p&gt;Observer provides critical insights across several areas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Application Inspection&lt;/strong&gt;: Visualize the complete supervision tree of your application. This shows exactly how your processes are structured, which processes are supervising others, and how they are linked for fault tolerance.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Process Information&lt;/strong&gt;: Get a real-time, sortable list of every process running on the node. For each process, you can see its current memory usage, the length of its message queue, and its reduction count—a proxy for how much CPU work it has done. This is invaluable for identifying bottlenecks or memory-hungry processes instantly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;System Overview&lt;/strong&gt;: View detailed statistics about the BEAM itself, including scheduler utilization across all cores, memory allocator statistics for different data types, and I/O activity.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These same introspection capabilities are available programmatically through functions like erlang:process_info/2, allowing you to build custom monitoring and diagnostic tools that tap directly into the runtime’s own data.&lt;/p&gt;

&lt;p&gt;Observer is unparalleled for interactive, live-system debugging, but it is not designed for the automated, long-term, and aggregated analysis required for production SRE practices like alerting and SLO tracking. For that, we turn to the industry-standard three pillars.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Bridging to the Familiar: The Three Pillars in Elixir
&lt;/h2&gt;

&lt;p&gt;While Observer provides a powerful ‘point-in-time’ view into the VM, a robust observability strategy also requires historical data and distributed context. This is where the ecosystem bridges from its unique internal capabilities to the familiar ‘three pillars’ model, with the telemetry library serving as the cornerstone.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Foundation: telemetry
&lt;/h3&gt;

&lt;p&gt;Telemetry is a lightweight, universal event-dispatching library that has become the standard foundation for instrumentation in Elixir. Key libraries like the Phoenix web framework and the Ecto database wrapper use telemetry to emit standardized events about their internal operations (e.g., request timings, query performance). Other libraries can then attach to these events to generate logs, metrics, or traces. This architecture decouples instrumentation from consumption. Library authors (like the Phoenix team) can emit events without dictating how they are used, allowing operations teams to plug in any number of handlers for metrics, logging, or tracing without modifying the core library code. This is a crucial design choice for a flexible and maintainable ecosystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Three Pillars of Observability
&lt;/h3&gt;

&lt;p&gt;This telemetry-based approach provides a clean bridge to the three pillars of observability, which are essential for understanding the why behind a system’s behavior, not just the what.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Logs&lt;/strong&gt;: Discrete, timestamped records of events. They are essential for troubleshooting specific occurrences, providing the rich, detailed context needed to understand the steps leading to an error or a specific transaction.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Metrics&lt;/strong&gt;: Aggregated numerical representations of system health over time (e.g., request latency, error rates, CPU utilization). They are ideal for building dashboards, defining alerts on known thresholds, and analyzing historical trends.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Traces&lt;/strong&gt;: A detailed view of a single request’s end-to-end journey as it flows through a distributed system. Traces are crucial for diagnosing latency issues, identifying bottlenecks, and understanding inter-service dependencies in a microservices architecture.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3y39yyy31v5bipnsc9a9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3y39yyy31v5bipnsc9a9.webp" alt="3 Pilars of observability" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The telemetry library provides the universal event bus that allows specialized libraries to transform raw events into the metrics and traces needed for a complete observability picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Logs: Structured Logging as a First-Class Citizen
&lt;/h2&gt;

&lt;p&gt;Before discussing metrics and dashboards, it’s worth addressing logging, because this is often the first observability signal teams rely on in production—and an area where Elixir is frequently underestimated.&lt;/p&gt;

&lt;p&gt;Elixir’s logging system is built around the Logger module, which is part of the standard library and deeply integrated with the runtime. While Logger historically emitted plain text logs by default, modern Elixir applications almost universally use structured logging, and the ecosystem makes this both straightforward and idiomatic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structured Logs by Default, Not by Convention
&lt;/h3&gt;

&lt;p&gt;Logger supports structured, key–value metadata natively. Instead of treating logs as unstructured strings that must later be parsed, Elixir encourages attaching contextual metadata—such as request IDs, user identifiers, or domain-specific fields—directly to log events.&lt;/p&gt;

&lt;p&gt;This metadata propagates naturally through the process model, meaning contextual information (like a request ID set at the boundary of a web request) is automatically available to all logs emitted by that process and its children. This aligns well with modern logging practices and avoids the need for thread-local hacks or manual context passing common in other ecosystems.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flexible Encoding and Output
&lt;/h3&gt;

&lt;p&gt;In production, Logger can be configured to emit logs in structured formats such as JSON, making it trivial to integrate with standard log aggregation systems (e.g., Elasticsearch, Loki, or commercial SaaS platforms). This is typically achieved by swapping the default formatter for a JSON formatter, without changing application code.&lt;/p&gt;

&lt;p&gt;Because Logger is part of the runtime and not a third-party abstraction, this configuration is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Centralized&lt;/li&gt;
&lt;li&gt;Consistent across the ecosystem&lt;/li&gt;
&lt;li&gt;Decoupled from any specific log backend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes it easy to evolve logging strategies over time—from local development logs to fully structured production pipelines—without rewriting instrumentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logs as Part of a Cohesive Observability Strategy
&lt;/h2&gt;

&lt;p&gt;Crucially, logging in Elixir does not exist in isolation. Logger integrates cleanly with the broader observability stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Logs can include trace and span identifiers when OpenTelemetry is enabled&lt;/li&gt;
&lt;li&gt;Log volume and levels can be dynamically adjusted at runtime&lt;/li&gt;
&lt;li&gt;Logging remains lightweight, with backpressure handled by the BEAM rather than the application&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a logging system that feels modern, production-oriented, and aligned with today’s expectations: structured by default, context-aware, and easy to route into both open-source and commercial observability platforms.&lt;/p&gt;

&lt;p&gt;When OpenTelemetry is enabled, this structured logging model becomes even more powerful. Trace and span identifiers (trace_id, span_id) can be automatically injected into Logger metadata, allowing logs to be directly correlated with distributed traces. This makes it trivial to pivot from a slow or failing trace to the exact log lines emitted within that span, using standard tooling in both open-source and commercial platforms. For teams used to trace–log correlation in JVM or Go-based systems, this behavior is fully supported and idiomatic in Elixir.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Metrics: The Prometheus &amp;amp; Grafana Story
&lt;/h2&gt;

&lt;p&gt;For metrics, the Elixir ecosystem has a go-to solution for integrating with the industry-standard Prometheus and Grafana stack: PromEx.&lt;/p&gt;

&lt;p&gt;PromEx is a library designed to make exposing Prometheus-compatible metrics from an Elixir application as simple and streamlined as possible. It works by attaching its own handlers to the telemetry events emitted by your application and its dependencies, converting them into the metrics format that Prometheus scrapes.&lt;/p&gt;

&lt;p&gt;The setup and features highlight its power and ease of use:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Simple Configuration&lt;/strong&gt;: A single mix task, mix prom_ex.gen.config, generates the necessary configuration module, removing boilerplate and guesswork.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Effortless Phoenix Integration&lt;/strong&gt;: PromEx.Plug is a simple plug that can be added to your Phoenix endpoint to expose a /metrics endpoint. Prometheus can then be configured to scrape this endpoint automatically.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Rich Ecosystem Plugins&lt;/strong&gt;: PromEx offers a suite of pre-built plugins that provide deep visibility into the most common parts of an Elixir stack, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PromEx.Plugins.Beam&lt;/code&gt; (for BEAM VM metrics)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PromEx.Plugins.Phoenix&lt;/code&gt; (for web request metrics)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PromEx.Plugins.Ecto&lt;/code&gt; (for database query metrics)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PromEx.Plugins.Oban&lt;/code&gt; (for background job processing metrics)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pre-built Dashboards&lt;/strong&gt;: Crucially, each plugin comes with a tailored Grafana dashboard. This means you get meaningful, out-of-the-box visualizations for your key metrics without having to build dashboards from scratch.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  5. Tracing: Embracing the OpenTelemetry Standard
&lt;/h2&gt;

&lt;p&gt;For distributed tracing, the Elixir community has fully adopted OpenTelemetry (OTel), the Cloud Native Computing Foundation (CNCF) framework that provides a vendor-neutral standard for generating and collecting telemetry data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core Components and Instrumentation
&lt;/h3&gt;

&lt;p&gt;Setting up OTel in a typical Phoenix application involves adding a few key libraries: opentelemetry_api, opentelemetry, and instrumentation-specific packages like opentelemetry_phoenix.&lt;/p&gt;

&lt;p&gt;Instrumentation works in two primary ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Automatic Instrumentation&lt;/strong&gt;: Libraries like opentelemetry_phoenix listen for standard :telemetry events and automatically create OTel spans from them. This provides a baseline level of tracing for web requests and database queries with minimal setup.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Manual Instrumentation&lt;/strong&gt;: For business-logic-specific insights, you can create custom spans programmatically using functions like Tracer.with_span. This is essential for tracing business-critical logic that isn’t captured by default framework events, such as the steps within a complex financial transaction or a multi-stage data processing pipeline.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Exporting Data via the OTel Collector
&lt;/h3&gt;

&lt;p&gt;The best practice for sending telemetry data in production is to use the OpenTelemetry Collector. This is a vendor-agnostic agent that receives data from your application (typically via the OTLP protocol) and can then process and export it to any number of backends. This decouples your application from the specific tracing backend (e.g., Jaeger, Zipkin, or a commercial platform), preventing vendor lock-in.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Note on Maturity and Performance
&lt;/h3&gt;

&lt;p&gt;The OpenTelemetry SDK for Elixir/Erlang is mature and widely used in production. However, for systems with very high request throughput, some performance tuning may be required. Specifically, operators may need to adjust the configuration of the batch span processor (e.g., OTEL_BSP_SCHEDULE_DELAY_MILLIS) to balance memory usage and latency, ensuring the observability layer itself does not become a bottleneck.&lt;/p&gt;

&lt;p&gt;One important distinction to be explicit about is profiling. Unlike the JVM (JFR) or Go (pprof), the BEAM does not yet offer a ubiquitous, always-on, low-overhead continuous profiler as part of the standard production toolchain. Instead, profiling in Elixir relies on a combination of built-in tools (:eprof, :fprof, :cprof), runtime introspection, and situational diagnostics using Observer or libraries like recon. This means profiling is typically more targeted and investigative rather than continuously sampled. For many teams this is sufficient—especially when combined with strong metrics and tracing—but it is a real trade-off to be aware of for workloads where continuous CPU or allocation profiling is a hard requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Integrating with Commercial Platforms (Datadog, New Relic, AppSignal, etc.)
&lt;/h2&gt;

&lt;p&gt;Elixir applications integrate reliably with major commercial observability platforms such as Datadog, New Relic, and AppSignal. For most production systems today, the recommended and future-proof integration path is OpenTelemetry, which provides a vendor-neutral foundation for traces, metrics, and logs.&lt;/p&gt;

&lt;p&gt;By instrumenting your application using OpenTelemetry APIs, you decouple observability concerns from any specific backend. Telemetry data is typically exported to an OpenTelemetry Collector, which then forwards it to one or more destinations—self-hosted systems like Jaeger or Prometheus, or commercial platforms such as Datadog or New Relic. Switching backends does not require changes to application code; it is a configuration concern at the Collector level.&lt;/p&gt;

&lt;p&gt;This OTel-first approach has two important implications:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Consistency and portability&lt;/strong&gt;: Instrumentation remains stable even if vendors change, pricing shifts, or organizational preferences evolve.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reduced vendor lock-in&lt;/strong&gt;: The application depends on open standards rather than proprietary agents or SDKs.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some platforms also offer Elixir-specific or vendor-native integrations. AppSignal, in particular, provides a tightly integrated, Elixir-first experience with minimal setup and strong defaults. This can be an attractive option for teams that prefer a managed solution and are comfortable trading some flexibility for simplicity. However, such integrations typically couple instrumentation more closely to the vendor and make later migrations more expensive.&lt;/p&gt;

&lt;p&gt;At a high level, the trade-off is familiar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Self-Hosted Open Source (Prometheus / Grafana / Jaeger)&lt;/strong&gt;: Maximum control and flexibility with no direct vendor costs. Requires operational ownership and expertise.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Commercial SaaS Platforms&lt;/strong&gt;: Managed infrastructure, advanced analytics, and lower operational overhead. Cost scales with usage.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice, many teams adopt a hybrid model: open-source tooling for core metrics and dashboards, combined with commercial platforms for tracing, alerting, or cross-service analysis. Elixir’s OpenTelemetry-based integrations support this model well.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The Verdict: Is Elixir Production-Ready?
&lt;/h2&gt;

&lt;p&gt;So, is Elixir’s observability stack ready for the demands of modern production systems? The answer is yes. The ecosystem provides a clear two-pronged approach: deep, real-time runtime introspection via the BEAM, combined with full support for industry-standard observability practices through Telemetry, OpenTelemetry, and Prometheus-based tooling.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe567bx85h9a3s3iijgrh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe567bx85h9a3s3iijgrh.png" alt="Elixir observability" width="800" height="1433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The BEAM’s intrinsic visibility enables operators to inspect scheduler behavior, memory usage, and individual process state directly on a live system, answering the question “what is happening right now?” with a level of precision that is difficult to achieve in many other runtimes. At the same time, standardized metrics, logs, and traces provide the historical context required for alerting, SLOs, and distributed debugging.&lt;/p&gt;

&lt;p&gt;This contrasts with platforms where the runtime is largely opaque and observability relies primarily on sampling and post-hoc inference. In JVM or Go ecosystems, visibility is often reconstructed from external signals; in the BEAM, it is exposed directly by the runtime itself.&lt;/p&gt;

&lt;p&gt;That said, Elixir’s observability model will not be a perfect fit for every organization. Teams that depend heavily on always-on, low-overhead continuous profiling, that are unwilling to operate BEAM-specific tooling, or that mandate vendor-specific agents may find a more natural fit elsewhere. Elixir rewards teams willing to understand the runtime it runs on—and for those teams, it offers a level of operational transparency that is difficult to replicate.&lt;/p&gt;

</description>
      <category>elixir</category>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>monitoring</category>
    </item>
  </channel>
</rss>
