Premiers pas en Elixir : quand le BEAM fait tout craquer
App simpliste pour comprendre pourquoi Elixir est le roi des serveurs

J’ai rencontré Erlang assez tôt dans ma carrière, lors d’une convention, présenté par Joe Armstrong en 2013. Où il parlait de la création du langage, ainsi que des features de celui-ci. Et ce fût pour moi un choc. A l’époque je faisais pas mal de c et c++, mais surtout du Php et du JS pour du serveur.
Et sa présentation m’a donné une seule envie: faire mon prochain projet avec. Je rentre chez moi et je cherche la doc… Et là… La syntaxe est juste… moche… super moche! Et dans mes recherches je trouve Elixir: Erlang 2.0!
La syntaxe est claire, la communauté est accueillante, la doc est nickel. Et du coup, j’ai fais un petit projet avec, puis proposé d’utiliser le langage dans ma boîte et là c’est le drame, trouver des devs qui font du Elixir est plus que compliqué. Ce qui créerait plein de problématiques lors de l’intégration du projet chez des clients…
Depuis, c’est mon langage préféré, que j’utilise presque jamais professionnellement.
Mais, avec LLM le problème change. La syntaxe n’est plus un problème. Ecrire/lire du code n’est plus le blocker.
Du coup, j’ai migré tous mes services PHP/JS sur elixir (et rust).
Ainsi j’aimerai vous proposer un petit projet simpliste et une explication des features du langage.
Lexique
C’est là mes définitions et si elles vous déplaisent, cassez-vous je suis pas là pour discuter lexique.
- BEAM: la machine virtuelle d’Erlang. Le truc qui fait tourner Elixir. Gère des millions de processus légers sans broncher. C’est un système d’exploitation pour processus.
- GenServer: un processus serveur générique. Chaque device dans notre app sera un GenServer qui tient son état en mémoire.
- DynamicSupervisor: un supervisor qui peut démarrer/arrêter des processus à la volée.
- LiveView: l’état vit sur le serveur, seuls les diffs sont envoyés au client via WebSocket. Pas de JavaScript.
- PubSub: système de publication/subscription intégré à Phoenix. Pas de Redis.
- Phoenix.LiveDashboard: tableau de bord built-in qui montre les processus, la mémoire, les ETS tables. Un APM gratuit.
- “Let it crash”: on laisse les processus crasher, et les supervisors les relancent. C’est tout.
- Supervisor: un processus parent qui surveille ses enfants. Si un enfant meurt, il le redémarre.
Pourquoi Elixir pour les serveurs ?
La réponse tient en trois mots : concurrence, fautes, temps réel.
En Node.js, un processus = un thread. Tu gères 10 000 connexions, tu commences à avoir des problèmes. En Elixir ? Un processus BEAM = 1 à 2 KB de mémoire. Tu peux en lancer des millions. WhatsApp tourne sur Erlang. Discord sur Elixir. RabbitMQ sur Erlang. Ces trois gèrent des millions de connexions simultanées.
Le pattern “let it crash” c’est pas un slogan. En Node.js, si un processus crash, ton serveur meurt. En Elixir, son supervisor le redémarre. Immédiatement. Les autres continuent de tourner. Isolation totale!
Et Phoenix LiveView ? L’état de ton UI vit sur le serveur. Le client reçoit des binary diffs — juste les bits qui ont changé. Pas de React. Pas de state management. Le serveur pousse les diffs au client via WebSocket. C’est built-in!
Notre cas d’usage : un dashboard IoT
On va construire un dashboard IoT qui monitor 100 devices simulés. Chaque device envoie température, humidité, batterie. Le dashboard se met à jour en temps réel. Et surtout le tout est vraiment distribué: un device peut crever et revivre n’importe quand..
Pas de BDD. Pas de Redis. Pas de JavaScript. Pure in-memory. Le code parle de lui-même.
Prérequis et setup
- Erlang/OTP 26+ et Elixir 1.15+. C’est tout. Pas de Node.js. Pas de PostgreSQL.
0$ asdf plugin add elixir
1$ asdf install elixir 1.15.7-otp-26
2$ asdf global elixir 1.15.7-otp-26
3$ mix phx.new iot_dashboard --no-ecto --live
4$ cd iot_dashboard && mix deps.get && mix phx.serverPhoenix 1.7+ inclut esbuild en natif. Pas besoin de Node.js. Tu lances le serveur, ça tourne sur http://localhost:4000.
Pour ma part j’utilise Podman comme moteur de conteneur mais vous avez le droit d’avoir des mauvais goûts et utiliser docker.
L’architecture
La supervision tree est le coeur de l’app :
0App
1├── Phoenix.PubSub (named: IotDashboard.PubSub)
2├── Registry (keys: :unique, name: IotDashboard.DeviceRegistry)
3├── DeviceSupervisor (DynamicSupervisor)
4│ ├── DeviceProcess (device_1)
5│ ├── DeviceProcess (device_2)
6│ └── ... (N devices)
7├── Simulator (GenServer)
8└── IotDashboardWeb.EndpointL’ordre compte. PubSub d’abord, Registry ensuite, DeviceSupervisor, Simulator, Endpoint. C’est comme ça que le BEAM résout les dépendances.
Le DeviceProcess : un GenServer par device
C’est là que la magie opère. Chaque device est un processus autonome qui tient son propre état.
0defmodule IotDashboard.Device.DeviceProcess do
1 use GenServer
2
3 def start_link(device_id) do
4 GenServer.start_link(__MODULE__, device_id,
5 name: via_tuple(device_id))
6 end
7
8 def tick(device_id), do: GenServer.cast(via_tuple(device_id), :tick)
9 def crash(device_id), do: GenServer.call(via_tuple(device_id), :crash)
10
11 def init(device_id) do
12 state = %{
13 device_id: device_id,
14 temperature: 20.0 + :rand.uniform(10),
15 humidity: 40.0 + :rand.uniform(30),
16 battery: 80.0 + :rand.uniform(20),
17 status: :online,
18 last_seen: DateTime.utc_now()
19 }
20 {:ok, state}
21 end
22
23 def handle_cast(:tick, state) do
24 new_state = state
25 |> Map.update!(:temperature, &(&1 + (:rand.uniform(20) - 10) / 10))
26 |> Map.update!(:humidity, &max(0, min(100, &1 + (:rand.uniform(10) - 5) / 10)))
27 |> Map.update!(:battery, &max(0, &1 - :rand.uniform(5) / 100))
28 |> Map.put(:last_seen, DateTime.utc_now())
29 |> Map.put(:status, if(state.battery > 10, do: :online, else: :critical))
30
31 Phoenix.PubSub.broadcast(IotDashboard.PubSub, "devices", {:update, new_state})
32 {:noreply, new_state}
33 end
34
35 def handle_call(:crash, _from, _state), do: raise("Device crashed!")
36
37 defp via_tuple(device_id) do
38 {:via, Registry, {IotDashboard.DeviceRegistry, device_id}}
39 end
40enduse GenServer: le pattern actor. On implémenteinit,handle_cast,handle_call.via_tuple/1: on trouve un device par son ID via le Registry, pas par le PID.broadcast: chaque tick notifie les LiveViews connectées.handle_call(:crash, ...): exception volontaire. Le supervisor s’en occupe.
Un device qui crash n’affecte PAS les autres. Isolation totale.
Le DynamicSupervisor
0defmodule IotDashboard.Device.DeviceSupervisor do
1 use DynamicSupervisor
2
3 def start_link(_opts) do
4 DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
5 end
6
7 def start_device(device_id) do
8 spec = %{
9 id: IotDashboard.Device.DeviceProcess,
10 start: {IotDashboard.Device.DeviceProcess, :start_link, [device_id]},
11 restart: :transient
12 }
13 DynamicSupervisor.start_child(__MODULE__, spec)
14 end
15
16 def init(:ok), do: DynamicSupervisor.init(strategy: :one_for_one)
17end:transient: crash → redémarré. Arrêt propre → reste mort. Exactement ce qu’on veut.:one_for_one: un device qui crash ne touche pas les autres.
En Node.js ? Un pool de workers, un mécanisme de restart, et tu pries pour que ton error handler fasse bien son boulot. Ici ? Quelques lignes, et l’assurance que ça run!
Le Simulator et le DashboardLive
Le Simulator est un GenServer qui spawn N devices au boot et leur envoie un :tick chaque seconde via le Registry. Pas de setInterval. Pas de setTimeout. Le scheduler BEAM gère tout.
Le DashboardLive est là où ça devient magique. Il s’abonne au PubSub "devices" et reçoit les broadcasts en temps réel. Pas de polling. Pas de fetch(). Le serveur pousse les diffs.
0defmodule IotDashboardWeb.DashboardLive do
1 use IotDashboardWeb, :live_view
2
3 def mount(_params, _session, socket) do
4 if connected?(socket) do
5 Phoenix.PubSub.subscribe(IotDashboard.PubSub, "devices")
6 end
7 {:ok, assign(socket, devices: %{}, total: 0, online: 0, critical: 0)}
8 end
9
10 def handle_info({:update, device_state}, socket) do
11 devices = Map.put(socket.assigns.devices, device_state.device_id, device_state)
12 total = map_size(devices)
13 online = Enum.count(devices, fn {_, d} -> d.status == :online end)
14
15 {:noreply, assign(socket,
16 devices: devices, total: total, online: online, critical: total - online)}
17 end
18
19 def handle_event("crash_device", %{"device_id" => id}, socket) do
20 IotDashboard.Device.DeviceProcess.crash(id)
21 {:noreply, socket}
22 end
23
24 def render(assigns) do
25 ~H"""
26 <div class="dashboard">
27 <h1>IoT Monitoring Dashboard</h1>
28 <div class="stats">
29 <div class="stat">Total: <%= @total %></div>
30 <div class="stat online">Online: <%= @online %></div>
31 <div class="stat critical">Critical: <%= @critical %></div>
32 </div>
33 <div class="devices">
34 <%= for {id, device} <- @devices do %>
35 <div class={"device #{device.status}"}>
36 <h3><%= id %></h3>
37 <p>Temp: <%= Float.round(device.temperature, 1) %>°C</p>
38 <p>Battery: <%= Float.round(device.battery, 1) %>%</p>
39 <button phx-click="crash_device" phx-value-device_id={id}>
40 Crash this device
41 </button>
42 </div>
43 <% end %>
44 </div>
45 </div>
46 """
47 end
48endconnected?(socket): on s’abonne au PubSub seulement quand le WebSocket est connecté.phx-click="crash_device": le bouton envoie un événement au serveur. Le device meurt. Le supervisor le relance. Le dashboard se met à jour. Automatiquement.- Pas une ligne de JavaScript. Tout est serveur. Le client reçoit des binary diffs.
Le template HEEx est compilé en bytecode. Les assigns sont typés. Tu as des erreurs de compilation si tu utilises un champ qui existe pas. C’est du TypeScript, mais en mieux.
Le router et l’application
0# router.ex
1scope "/", IotDashboardWeb do
2 pipe_through :browser
3 live "/", DashboardLive, :index
4end
5
6import Phoenix.LiveDashboard.Router
7scope "/live" do
8 pipe_through [:browser]
9 live_dashboard "/dashboard", metrics: IotDashboardWeb.Telemetry
10end
11
12# application.ex
13def start(_type, _args) do
14 children = [
15 IotDashboardWeb.Telemetry,
16 {Phoenix.PubSub, name: IotDashboard.PubSub},
17 {Registry, keys: :unique, name: IotDashboard.DeviceRegistry},
18 IotDashboard.Device.DeviceSupervisor,
19 IotDashboard.Simulator,
20 IotDashboardWeb.Endpoint
21 ]
22 Supervisor.start_link(children, strategy: :one_for_one, name: IotDashboard.Supervisor)
23endDeux routes : le dashboard sur /, Phoenix.LiveDashboard sur /live/dashboard. Un APM gratuit, built-in. En Node.js il faudrait Prometheus ou Datadog. Ici ? C’est intégré.
Les tests
0test "crash restarts device via supervisor" do
1 {:ok, _} = DeviceSupervisor.start_device("test_device_3")
2 original = Process.whereis(
3 {:via, Registry, {IotDashboard.DeviceRegistry, "test_device_3"}})
4
5 catch_exit(DeviceProcess.crash("test_device_3"))
6 Process.sleep(100)
7
8 new_pid = Process.whereis(
9 {:via, Registry, {IotDashboard.DeviceRegistry, "test_device_3"}})
10 assert new_pid != original
11 assert is_pid(new_pid)
12endOn crashe un device, on attend 100ms, on vérifie qu’il a un nouveau PID. Le supervisor l’a redémarré. C’est la preuve que le “let it crash” marche.
Et async: true ? Les tests tournent en parallèle. Chaque test a sa propre supervision tree, isolée. Pas de shared state. La même philosophie que le runtime : isolation.
Le Dockerfile
Comme statistiquement il est problable que vous ayez mauvais goût, je vous met un sample de dockerfile.
0FROM hexpm/elixir:1.15.7-erlang-26.2.5-debian-bookworm AS build
1WORKDIR /app
2ENV MIX_ENV=prod
3COPY mix.exs mix.lock ./
4RUN mix deps.get --only prod
5COPY config config lib lib priv priv
6RUN mix compile && mix release
7
8FROM debian:bookworm-slim AS runtime
9RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales && \
10 apt-get clean && rm -rf /var/lib/apt/lists/*
11WORKDIR /app
12COPY --from=build --chown=nobody:root /app/_build/prod/rel/iot_dashboard ./
13USER nobody
14EXPOSE 4000
15CMD ["bin/iot_dashboard", "start"]mix release crée un dossier auto-contenu avec le runtime OTP, le bytecode compilé, et un script de démarrage. Pas besoin d’installer Elixir ou Erlang sur le serveur de prod. Tu copies le dossier et tu le lances via bin/iot_dashboard start. Et tu as une image Docker de quelques dizaines de megas au lieu de quelques centaines !
Tu peux signer le tar.gz ou créer un manifeste avec le contenu du dossier pour en assurer l’authenticité.
Et surtout si tu déploies des conteneurs, signe-les et valide les signatures avant le run!
Mot de la fin
Elixir c’est pas un language “en plus”. C’est un changement de paradigme.
Si tu viens de Node.js ou Python, tu vas galérer une semaine avec les GenServers et les Supervisors. Mais après ? Tu vas te demander comment tu faisais sans.
Pas de Redis pour le pub/sub. Pas de JavaScript pour le temps réel. Pas de microservices pour la distribution. Le BEAM fait tout. Et il le fait bien.
L’essayer, c’est l’adopter.
See ya space-cowboy!