DEV Community

Matheus de Camargo Marques
Matheus de Camargo Marques

Posted on

Hexagonal architecture + PON: Ports & Adapters to decouple the engine

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

Hexagonal architecture + PON: Ports & Adapters to decouple the engine

Part 4 of 12Part 3 on dev.to — A metaprogrammed DSL: defrule and defpremissa with less PON boilerplate · repo draft added defrule and defpremissa so you declare watch / when / do instead of hand-written Regra callbacks. This post draws a boundary between that reactive core and the messy world: databases, HTTP, MQTT, GPIO, SMS APIs. In Elixir, Ports & Adapters map cleanly to behaviours (@callback) and implementing modules—the same pattern this repository already uses under lib/tec0301_pon/ports/ and lib/tec0301_pon/adapters/.


Why hexagonal thinking matters for rules

A rule’s do: block is the natural place for side effects: sound an alarm, open a valve, enqueue a job. If you import HTTPoison, GPIO, or an Ecto repo inside the rule module, the PON graph becomes hard to test without the network, hardware, or database. Worse, you cannot swap implementations between dev, CI, and production without editing rule code.

Hexagonal architecture—as Cockburn described in his ports and adapters model (original article; see also Fowler’s summary)—keeps the application core ignorant of those technologies. The core defines ports—interfaces for what it needs. Adapters sit outside and satisfy those interfaces with concrete I/O.

For PON specifically:

  • Inbound (driving): anything that writes facts—sensors, Phoenix controllers, LiveView events, cron jobs—eventually calls Fato.atualizar/2 (or your own thin wrapper). Part 6 on dev.to revisits UI; here we only name the direction.
  • Outbound (driven): what rules trigger when conditions hold—notifications, actuators, persistence. This repo’s examples focus on outbound behaviours.
flowchart TB
  subgraph inbound [Inbound_adapters]
    Sensor[Sensor_or_UI]
  end
  subgraph core [PON_core]
    Fato[Fato]
    Regra[Regra]
  end
  subgraph port_layer [Port_behaviour]
    PortMod[Ports_PredioAtuadores]
  end
  subgraph outbound [Outbound_adapters]
    IO[Adapters_PredioIO]
    RealHW[Real_HVAC_API]
  end
  Sensor -->|"Fato.atualizar"| Fato
  Fato --> Regra
  Regra -->|"calls contract"| PortMod
  PortMod -.->|"implements"| IO
  IO --> RealHW
Enter fullscreen mode Exit fullscreen mode

Ports in Elixir: @callback modules

A port here is not the OTP Port for external OS processes—it is a behaviour contract. Example from the library:

# lib/tec0301_pon/ports/predio_atuadores.ex (excerpt)
defmodule Tec0301Pon.Ports.PredioAtuadores do
  @moduledoc """
  Porta (Behaviour) para atuadores do prédio inteligente: iluminação, HVAC, porta de segurança.
  """
  @callback ligar_luz() :: :ok
  @callback desligar_luz() :: :ok
  @callback ligar_ar() :: :ok
  @callback ventilar() :: :ok
  @callback trancar_porta() :: :ok
end
Enter fullscreen mode Exit fullscreen mode

Another outbound port is Tec0301Pon.Ports.Alarme with @callback disparar(motivo :: String.t()) :: :ok.

The core depends only on these function signatures, not on SMS gateways or relay boards.

Adapters: @behaviour + @impl true

An adapter implements the port. The PoC ships IO-based simulations you can replace with real clients later:

# lib/tec0301_pon/adapters/predio_io.ex (excerpt)
defmodule Tec0301Pon.Adapters.PredioIO do
  @moduledoc """
  Adaptador de saída para prédio inteligente (iluminação, HVAC, porta) — simulação com IO.
  """
  @behaviour Tec0301Pon.Ports.PredioAtuadores

  @impl true
  def ligar_luz do
    IO.puts("[Prédio] Iluminação: Luz LIGADA.")
    :ok
  end

  # ... desligar_luz, ligar_ar, ventilar, trancar_porta
end
Enter fullscreen mode Exit fullscreen mode
# lib/tec0301_pon/adapters/alarme_io.ex (excerpt)
defmodule Tec0301Pon.Adapters.AlarmeIO do
  @behaviour Tec0301Pon.Ports.Alarme

  @impl true
  def disparar(motivo) do
    IO.puts("[API] SMS/Push: ALARM - #{motivo}")
    :ok
  end
end
Enter fullscreen mode Exit fullscreen mode

The test suite smoke-checks the adapter against the port (examples_coverage_test.exs asserts each PredioIO callback returns :ok).

Rules that call adapters: PredioInteligente

Tec0301Pon.Examples.PredioInteligente.Regras uses the DSL and aliases concrete adapter modules:

# lib/tec0301_pon/examples/predio_inteligente_regras.ex (excerpt)
use Tec0301Pon.PON.Builder
alias Tec0301Pon.Adapters.AlarmeIO
alias Tec0301Pon.Adapters.PredioIO

defrule(RegraEmergencia,
  watch: [:predio_alarme_incendio],
  when: (memoria[:predio_alarme_incendio] || false) == true,
  do:
    (
      AlarmeIO.disparar("Alarme de incêndio — modo emergência ativado.")
      Tec0301Pon.PON.Fato.atualizar(:predio_modo_emergencia, true)
    )
)

defrule(RegraVentilarCO2,
  watch: [:predio_co2_alto, :predio_modo_emergencia],
  when:
    (memoria[:predio_co2_alto] || false) == true and
      (memoria[:predio_modo_emergencia] || true) == false,
  do:
    (
      PredioIO.ventilar()
    )
)
Enter fullscreen mode Exit fullscreen mode

This is pragmatic: the rules are readable, and the behaviour still documents the contract PredioIO must honor. For strict hexagonal purity, the rule would call only a port-facing module resolved at runtime (next section).

Production-shaped indirection: config or facade

To swap adapters in tests or per environment without changing rule source, introduce a thin facade that delegates to a module from config:

# Example pattern (not required by the PoC — add in your app)
defmodule MyApp.Building do
  @predio_mod Application.compile_env(:my_app, :predio_atuadores, Tec0301Pon.Adapters.PredioIO)

  def ventilar, do: @predio_mod.ventilar()
  def trancar_porta, do: @predio_mod.trancar_porta()
  # ...
end
Enter fullscreen mode Exit fullscreen mode

Runtime (non-compile) variant:

defmodule MyApp.Building do
  def predio_mod do
    Application.get_env(:my_app, :predio_atuadores, Tec0301Pon.Adapters.PredioIO)
  end

  def ventilar, do: predio_mod().ventilar()
end
Enter fullscreen mode Exit fullscreen mode

In config/test.exs:

config :my_app, :predio_atuadores, MyApp.Test.PredioStub
Enter fullscreen mode Exit fullscreen mode

Rules then call MyApp.Building.ventilar() instead of PredioIO.ventilar(). The port (PredioAtuadores) is what you @behaviour in both PredioIO and PredioStub.

Stub adapter for tests

defmodule MyApp.Test.PredioStub do
  @behaviour Tec0301Pon.Ports.PredioAtuadores

  @impl true
  def ligar_luz, do: record(:ligar_luz)
  @impl true
  def desligar_luz, do: record(:desligar_luz)
  @impl true
  def ligar_ar, do: record(:ligar_ar)
  @impl true
  def ventilar, do: record(:ventilar)
  @impl true
  def trancar_porta, do: record(:trancar_porta)

  defp record(op) do
    # Rule actions run in the Regra process — send to a named test process, not self().
    if pid = Process.whereis(:predio_stub_sink), do: send(pid, {:predio_stub, op})
    :ok
  end
end
Enter fullscreen mode Exit fullscreen mode

Register :predio_stub_sink as the test process (Process.register(self(), :predio_stub_sink) in setup), fire the graph, then assert_receive {:predio_stub, :ventilar}. Alternatively assert only on fact values and keep the stub as :ok no-ops.

Inbound in three lines

An inbound adapter translates an external event into a fact change:

# Conceptual sensor → PON
defmodule MyApp.Adapters.MqttTempSensor do
  def on_message(payload) do
    Tec0301Pon.PON.Fato.atualizar(:ambient_temp, payload.temperature_c)
  end
end
Enter fullscreen mode Exit fullscreen mode

The rule graph does not know MQTT exists; it only reacts once :ambient_temp updates.

What we defer

  • Smart Brewery domain, simulation volume, and telemetry—Part 5 on dev.to onward.
  • LiveView as an inbound adapter—Part 6 on dev.to.
  • Full dependency injection framework—behaviours + Application.get_env/3 are enough for many Elixir apps.

Summary

Layer In this repo Role
Port Tec0301Pon.Ports.* @callback contract for outbound effects
Adapter Tec0301Pon.Adapters.* @behaviour implementation (IO, HTTP, etc.)
Rules Examples.PredioInteligente.Regras DSL; today alias adapters directly; facades + config tighten the hexagon

Hexagonal boundaries do not replace PON—they wrap it: facts and rules stay reactive; adapters keep I/O at the edges.

References and further reading


Published on dev.to: Hexagonal architecture + PON: Ports & Adapters to decouple the engine — tracked in docs/devto_serie_pon_smart_brewery.md.

Previous: Part 3 on dev.to — A metaprogrammed DSL: defrule and defpremissa with less PON boilerplate · repo draft

Next: Part 5 on dev.to — Smart Brewery: a digital twin brewery as a PON lab · repo draft.

Top comments (0)