DEV Community

Matheus de Camargo Marques
Matheus de Camargo Marques

Posted on

Retrospective: lessons from building a reactive rules engine in Elixir

If this helped you, you can support the author with a coffee on dev.to.

Retrospective: lessons from building a reactive rules engine in Elixir

Part 12 of 12 — This post closes the arc that started with why the BEAM fits reactive rules and ends with how we measure them under load. Part 11 on dev.to — Dev profiling: CPU, memory, and what changed after optimizations · repo draft was about profilers and reproducible workloads; here I zoom out: integrations that paid off, costs we accepted, and what I would try next if I were green-fielding tomorrow.

This is not a substitute for the earlier technical posts—treat it as a map and a checklist for your own PON-style systems. Each part now ends with a References and further reading section; the consolidated list lives in Bibliography on dev.to — PON + Smart Brewery series (EN drafts) · repo draft.


What the twelve parts built

Stretch Idea
1–2 Notifications as the organizing principle; Facts, Rules, and Premises as OTP processes and Registry topics.
3–4 Less boilerplate via defrule / defpremissa; ports and adapters so rules do not hard-code IO.
5–6 Smart Brewery as a serious lab; LiveView as the operator’s window with batching and streams.
7–8 Broadway / GenStage, TimescaleDB, star-schema BI and safe read-only roles.
9 ML export / import and ml_predictions without blocking the engine.
10–11 Part 10 on dev.tomessage storms, dedup and fan-out coalescence; Part 11 on dev.toCPU/memory profiling with PipelineWorkload.

Together they describe one opinionated path: reactive core, async edges, honest measurement.


What worked well

Process boundaries match mental boundaries. A Fato is a named mailbox with a value; a Regra subscribes and reacts. That maps cleanly to drawings on a whiteboard and to Elixir supervision.

Explicit message shapes. Consumers understand {:notificacao, name, value} and {:notificacoes_lote, map}—whether they live in tec0301_pon or in the Phoenix app. Ambiguity is expensive at scale.

Two applications, one story. Keeping tec0301_pon as the engine and simulacoes_visuais as warehouse + UI avoided turning the rule engine into an Ecto or HTTP library, while still shipping a full twin demo.

Batching at the pain points. LiveView batchers, Broadway batchers, Fanout.atualizar_lote/1, and async DB writers share the same philosophy: coalesce before fan-out or disk.

Telemetry as a spine, not an afterthought. From rule firings to Broadway flushes, the codebase repeatedly uses :telemetry.execute/3 and named events so you can correlate “the twin moved” with “the UI updated” and “rows landed in TimescaleDB.” That does not replace profilers, but it anchors them: when CPU spikes, you want a span or counter that says which stage grew.


Trade-offs we accepted

DSL complexity. Macros that generate rule modules are powerful and testable, but they are a onboarding cliff. Teams that fear metaprogramming will push for YAML or database rules—each has its own cost.

Simulation vs. fidelity vs. CPU. Monte Carlo noise, strict === deduplication, and float quantization are linked: quieter logs mean less work, but also less “physical” continuity unless you design for it.

Pragmatic coupling in examples. Rules that call Adapters.PredioIO directly are easy to read; “production-shaped” indirection via Application.get_env/3 is more wiring. The series kept both stories visible.

Operational surface. TimescaleDB, CAGGs, retention, Power BI roles, and ML export paths are optional but real: the default mix phx.server story is heavier than a pure library.

English drafts, Portuguese publication layer. Keeping docs/devto/en/*.md as the paste-ready source while devto_serie_pon_smart_brewery.md tracks PT titles and dev.to slugs adds a bit of bookkeeping. The upside is a single technical narrative in one language and localized packaging when you hit publish.


What I would try earlier next time

  • Profiling harness from week onePipelineWorkload-style reproducible ticks save arguments about regressions.
  • One canonical env matrix — document SIMULACOES_TSDB_ENABLED, Monte Carlo interval, and Broadway batch sizes in a single table (the repo now spreads this across performance-dev.md and config; still easy to drift).
  • Stable join keys — align persisted rule_events.regra_id with dimension tables (r_01 vs "1") in one migration or view, so BI and ML exports do not need ad hoc bridges.
  • Stricter “no work if unchanged” policy — extend the dedup story to any hot path that allocates (e.g. telemetry encode) once profiling proves it matters.

These are hypotheses, not promises—verify on your workload.


Code spine (the same system, twelve lenses)

Part 2 / 10 — fact update and dispatch only when the value changes:

# lib/tec0301_pon/pon/fato.ex (concept)
def handle_cast({:atualizar, novo_valor}, estado) do
  if valor_igual?(estado.valor, novo_valor) do
    {:noreply, estado}
  else
    # … update state, ETS, Registry.dispatch → {:notificacao, nome, valor}
    {:noreply, novo_estado}
  end
end
Enter fullscreen mode Exit fullscreen mode

Part 3 — DSL-shaped rule:

defrule RegraIrrigacao,
  watch: [:temp_ambiente, :umidade_solo, :estado_bomba, :nivel_tanque],
  when: memoria[:temp_ambiente] > 30 and memoria[:umidade_solo] < 40,
  do: (
    Adapters.BombaDeAgua.ligar()
    Tec0301Pon.PON.Fato.atualizar(:estado_bomba, :ligada)
  )
Enter fullscreen mode Exit fullscreen mode

Part 4 — port = behaviour:

defmodule Tec0301Pon.Ports.PredioAtuadores do
  @callback ligar_luz() :: :ok
  @callback trancar_porta() :: :ok
end
Enter fullscreen mode Exit fullscreen mode

Part 6 — operator route:

live "/smart-brewery", SmartBreweryLive, :index
live "/smart-brewery/ml-predictions", MlPredictionsLive, :index
Enter fullscreen mode Exit fullscreen mode

Part 7 — Broadway flush to PubSub and optional TSDB:

if Application.get_env(:simulacoes_visuais, :tsdb_enabled, false) do
  SimulacoesVisuais.SmartBrewery.TelemetryAsyncWriter.cast_batch(list)
end
Enter fullscreen mode Exit fullscreen mode

Part 9 — export and import (shell):

mix export.ml --out /tmp/ml_export --since-hours 168
mix import.ml.predictions --file /tmp/preds.jsonl
Enter fullscreen mode Exit fullscreen mode

[Part 11 on dev.to](https://dev.to/matheuscamarques/dev-profiling-cpu-memory-and-what-changed-after-optimizations-28hb) — reproducible load:

mix profile.cprof -e "SimulacoesVisuais.Profile.PipelineWorkload.run()"
Enter fullscreen mode Exit fullscreen mode

End-to-end picture

flowchart LR
  subgraph engine [tec0301_pon]
    F[Fato]
    R[Regra]
    A[Adapters]
  end
  subgraph app [simulacoes_visuais]
    MC[MonteCarlo]
    BW[Broadway]
    LV[LiveView]
    TSDB[(TimescaleDB)]
  end
  MC --> F
  F --> R
  R --> A
  F --> BW
  BW --> TSDB
  BW --> LV
Enter fullscreen mode Exit fullscreen mode

Series index (parts 1–12 + consolidated bibliography)

Part Title Draft / published
1 Notification-Oriented Paradigm (PON) in Elixir: why the BEAM fits reactive rules dev.to · repo draft
2 From whiteboard to code: mapping Facts, Rules, and Premises to OTP processes dev.to · repo draft
3 A metaprogrammed DSL: defrule and defpremissa with less PON boilerplate dev.to · repo draft
4 Hexagonal architecture + PON: Ports & Adapters to decouple the engine dev.to · repo draft
5 Smart Brewery: a digital twin brewery as a PON lab dev.to · repo draft
6 Phoenix LiveView in real time: an operations UI on top of a rules engine dev.to · repo draft
7 From simulation to storage: telemetry, Broadway/GenStage, and TimescaleDB dev.to · repo draft
8 BI without mystery: dimensions, facts, and consuming the data (e.g. Power BI) dev.to · repo draft
9 ML on the digital twin: export, train pilots, and import predictions back into the app dev.to · repo draft
10 When notifications explode: message storms, deduplication, and back-pressure in PON dev.to · repo draft
11 Dev profiling: CPU, memory, and what changed after optimizations dev.to · repo draft
12 Retrospective: lessons from building a reactive rules engine in Elixir this post
13 Bibliography — PON + Smart Brewery dev.to series (EN drafts) dev.to · repo draft

Portuguese titles and publication notes for dev.to live in docs/devto_serie_pon_smart_brewery.md.

References and further reading (series-level)


Thank you

If you followed from Part 1 on dev.to: you have seen one way to marry reactive rules, OTP, and industrial-style twins—not the only way. Take the patterns (boundaries, batching, measurement) and adapt the machinery to your domain.

End of series.


Previous: Part 11 on dev.to — Dev profiling: CPU, memory, and what changed after optimizations · repo draft

Code map (monorepo root): lib/tec0301_pon/, apps/simulacoes_visuais/, docs/performance-dev.md, docs/devto_serie_pon_smart_brewery.md.

Top comments (0)