Elixir / OTP Phoenix LiveView 1.1 autowebapp — Tellevo

Filosofía Elixir / OTP
aplicada en autowebapp

Los 6 conceptos fundamentales del ecosistema BEAM — qué significa cada uno, dónde aparece en el código real y dónde (y por qué) se aplica distinto.

⚠️ Dato clave antes de leer

autowebapp no tiene base de datos ni estado de dominio propio. Es un frontend Phoenix LiveView stateless: todos los datos viven en microservicios externos a los que pega por HTTP con Req. Esto determina qué conceptos OTP aplican con fuerza, cuáles de forma parcial, y cuáles están deliberadamente ausentes — no por descuido, sino porque no tiene sentido tenerlos cuando no hay estado de dominio que custodiar.

Resumen de conceptos

Concepto Veredicto Nota
Let it crash + Árbol de supervisión
application.ex, supervision tree
✅ Infraestructura
⚠️ Dominio defensivo
El supervisor OTP existe y es real; el código de dominio es deliberadamente defensivo para no romper la UI del usuario.
Actor model / proceso por usuario
LiveView como proceso BEAM
✅ Fuerte Cada LiveView conectada = 1 proceso BEAM independiente. 5.000 pasajeros = 5.000 procesos aislados.
Estado e inmutabilidad
Agent, socket assigns
✅ Sí El Agent de Discovery y los assigns del socket son la reconciliación práctica entre inmutabilidad y estado mutable.
Behaviours / programar contra contratos
client behaviour + _impl + Mox
✅ Intensivo Todos los clientes HTTP son behaviours puros, con impl real en producción y Mox mock en tests.
Concurrencia y scheduler del BEAM
procesos baratos, preemptive, clustering
✅ Fundación
⚠️ Clustering latente
La concurrencia masiva se aprovecha implícitamente vía LiveView. DNSCluster está cableado pero en :ignore en dev.
PubSub / mensajería distribuida
Phoenix.PubSub, notificaciones en tiempo real
✅ Fuerte Notificaciones duales: HTTP (persistencia) + PubSub (tiempo real). Topics por rol e ID de usuario.
💥

Let it crash y Árboles de Supervisión

✅ Infraestructura ⚠️ Dominio defensivo
La idea general

En el ecosistema Erlang/OTP, la filosofía "let it crash" dice: no programes defensivamente contra cada error posible. En cambio, deja que el proceso muera limpiamente, y que el supervisor lo reinicie en un estado conocido y consistente. El resultado es un sistema que se recupera solo, sin memoria corrompida ni estado parcial acumulado.

Un árbol de supervisión (supervision tree) define la estructura de recuperación: qué procesos existen, en qué orden se inician, y qué pasa cuando uno falla. Las tres estrategias principales son:

  • :one_for_one — solo el proceso que falló se reinicia. Los hermanos no se tocan.
  • :one_for_all — si uno falla, todos los hermanos se reinician.
  • :rest_for_one — si uno falla, se reinician él y todos los que vinieron después en el orden de inicio.
En autowebapp

La aplicación tiene un supervisor :one_for_one real con cinco hijos declarados en orden estricto de inicio:

  • AutowebappWeb.Telemetry — métricas del sistema.
  • DNSCluster — clustering Erlang vía DNS.
  • Phoenix.PubSub — bus de mensajes en tiempo real.
  • Autowebapp.Shared.Discovery — Agent que resuelve URLs de microservicios al arrancar.
  • AutowebappWeb.Endpoint — servidor HTTP, siempre el último para que todo esté listo antes de aceptar tráfico.

El orden importa: con estrategia :one_for_one y arranque secuencial, si Discovery fallara al iniciar, el Endpoint no llegaría a arrancar.

lib/autowebapp/application.ex:31–48 — supervisor raíz de la aplicación
def start(_type, _args) do
  children = [
    AutowebappWeb.Telemetry,
    {DNSCluster, query: Application.get_env(:autowebapp, :dns_cluster_query) || :ignore},
    {Phoenix.PubSub, name: Autowebapp.PubSub},
    Autowebapp.Shared.Discovery,
    AutowebappWeb.Endpoint  # siempre el último
  ]
  opts = [strategy: :one_for_one, name: Autowebapp.Supervisor]
  Supervisor.start_link(children, opts)
end
Honestidad: qué no aplica

El código de dominio y las LiveViews son deliberadamente defensivos, y eso es correcto:

  • lib/autowebapp/shared/notificaciones.ex:147–159pendientes/3 atrapa el error y devuelve [] "para no bloquear el mount". No deja morir la LiveView.
  • lib/autowebapp/shared/discovery.ex:76–91 — todo el acceso HTTP está envuelto en {:ok, _} / {:error, _} con manejo explícito en cada rama.

¿Por qué? Porque cada LiveView es la sesión visible de un usuario real. Si una LiveView muere, ese usuario ve su pantalla romperse. El "let it crash" brilla en workers de fondo reemplazables —procesos que nadie ve directamente—, no en la interfaz interactiva de alguien.

La decisión es filosóficamente coherente con OTP, no un rechazo de él: se supervisa la infraestructura con confianza, y se protege la experiencia de usuario con manejo explícito de errores.

🎭

Actor Model: Un Proceso por Usuario

✅ Fuerte
La idea general

En el modelo de actores, la unidad fundamental de computación es el proceso (no el hilo del OS). Cada proceso:

  • Tiene su propia memoria (heap) — no comparte nada con otros procesos.
  • Solo puede comunicarse enviando mensajes a otros procesos, de forma asíncrona, que llegan al mailbox del destinatario.
  • Si un proceso muere, su memoria desaparece sin afectar a nadie más.

En el BEAM (la VM de Erlang/Elixir), crear un proceso es extremadamente barato: ~2 KB de memoria inicial y microsegundos de tiempo de creación. Millones de procesos coexistiendo es completamente normal.

En autowebapp

Cada LiveView conectada es un proceso BEAM. Cuando un pasajero abre el navegador en /pasajero/buscar, el servidor crea un proceso GenServer específico para esa conexión WebSocket. Si 5.000 pasajeros están conectados simultáneamente, hay 5.000 procesos LiveView independientes. Si el proceso de uno muere, los otros 4.999 ni se enteran.

La comunicación por mensajes es exactamente lo que ocurre con el sistema de notificaciones. El ciclo completo es:

1. Suscripción al montarlib/autowebapp/shared/notificaciones.ex:80–82
Cada LiveView se suscribe a su topic personal al inicializarse:
def subscribe(rol, id) do
  Phoenix.PubSub.subscribe(@pubsub, topic(rol, id))
end
2. Broadcast al ocurrir un eventolib/autowebapp/shared/notificaciones.ex:118–122
Cuando el conductor acepta un viaje, la app hace broadcast. El mensaje llega al mailbox del proceso LiveView del pasajero:
Phoenix.PubSub.broadcast(
  @pubsub,
  topic(rol, id),
  {:nueva_notificacion, notificacion}
)
3. Recepción en el mailbox — LiveView del pasajero
El handle_info/2 recoge el mensaje del mailbox y actualiza la UI sin que el cliente haya preguntado nada:
def handle_info({:nueva_notificacion, notif}, socket) do
  {:noreply, update(socket, :notificaciones, &[notif | &1])}
end

Este ciclo — subscribe → broadcast → handle_info — es el actor model en estado puro: procesos con mailboxes, comunicación asíncrona por mensajes y memoria completamente aislada. El pasajero no "consulta" periódicamente si hay novedades; el mensaje llega solo cuando hay algo que decirle. Y lo más importante: el fallo de cualquier proceso individual no afecta en absoluto a los demás usuarios ni al sistema global.

🧊

Estado e Inmutabilidad: OTP Reconcilia Ambos

✅ Sí
La idea general

En Elixir, todos los datos son inmutables: una vez creada una estructura, no puede modificarse. Si "modificas" un mapa, Elixir crea un mapa nuevo con el cambio, compartiendo partes del original (persistent data structures). Esto elimina los bugs de estado compartido y hace el código predecible.

Pero hay una paradoja: las aplicaciones reales necesitan estado que cambie. ¿Cómo reconciliar ambos?

OTP lo resuelve con procesos con estado: un proceso (GenServer, Agent) custodia un valor que va cambiando con el tiempo. Los datos que guarda son inmutables en cada momento, pero el proceso puede reemplazarlos con un valor nuevo. El proceso es el "guardián" del estado mutable — y como es el único que puede acceder a su propia memoria, no hay condiciones de carrera.

Agent es el caso más simple: un proceso cuyo único trabajo es guardar un valor y devolverlo cuando se le pide.

En autowebapp

a) El Agent de Discovery

Autowebapp.Shared.Discovery es el ejemplo más limpio de este patrón. Inicia con estado nil (sin configuración cargada) y lo reemplaza tras el primer fetch HTTP exitoso.

Arranque del Agent con estado inicial nil:

use Agent

def start_link(_opts) do
  Agent.start_link(fn -> nil end, name: __MODULE__)
end
lib/autowebapp/shared/discovery.ex:34,48–49

Primera llamada a get_config/0: descarga las URLs de los microservicios y las almacena en el Agent:

def get_config(force_reload \\ false) do
  cached = if force_reload, do: nil, else: Agent.get(__MODULE__, & &1)
  if cached do
    {:ok, cached}
  else
    case client.fetch_config() do
      {:ok, data} ->
        case parse_and_validate(data) do
          {:ok, config} ->
            Agent.update(__MODULE__, fn _ -> config end)  # ← estado mutable
            {:ok, config}
          # ...
        end
    end
  end
end
lib/autowebapp/shared/discovery.ex:66–81

El flujo de estado es: nil → fetch HTTP → %Config{core: ..., pagos: ..., login: ..., htmls: ..., images: ...}. A partir de ahí, cada LiveView llama get_config/0 y obtiene la caché sin volver a hacer HTTP.


b) Socket assigns: inmutabilidad en LiveView

Los assigns del socket siguen el mismo principio. En LiveView, el socket es una struct inmutable. Cuando "asignas" algo, creas un socket nuevo con ese valor; el original nunca se modifica. Esto hace que el estado del socket sea trazable y libre de efectos secundarios ocultos.

# Cada assign/3 devuelve un socket NUEVO — el anterior no se toca
socket =
  socket
  |> assign(:app_state, app_state)
  |> assign(:current_user_id, usuario_id)
  |> assign(:app_role, app_role)
lib/autowebapp_web/live_auth.ex:165–169

La cadena de pipes es azúcar sintáctico: cada llamada recibe el socket devuelto por la anterior y produce uno nuevo. Puedes razonar sobre el estado final del socket leyendo las líneas de arriba hacia abajo, sin preocuparte por mutaciones ocultas en otro hilo.

📋

Behaviours: Programar Contra Contratos

✅ Intensivo ❌ GenServer/GenStage — a propósito
La idea general

Un behaviour en Elixir es un contrato: define qué funciones deben existir (con @callback) sin implementar ninguna. Los módulos que declaran @behaviour ModuloContrato prometen implementar todas esas callbacks. Si no lo hacen, el compilador advierte. Es el equivalente a una interface en otros lenguajes, pero nativo al lenguaje y verificado en tiempo de compilación.

GenServer es el behaviour más fundamental de OTP: define el ciclo de vida de un proceso servidor (init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2). Encapsula el loop receive de Erlang y las respuestas al supervisor.

GenStage es para pipelines con backpressure: productor → consumidor, con control de demanda explícito.

En autowebapp

El patrón dominante de la aplicación es: cada cliente HTTP es un behaviour puro + una implementación real (_impl) + un mock de test (_mock). El código de dominio solo conoce el contrato; nunca sabe si habla con HTTP real o con un mock.

Ejemplo: behaviour puro sin implementación

lib/autowebapp/clients/login_client.ex
defmodule Autowebapp.Clients.LoginClient do
  @callback fetch_logos(login_url :: String.t()) ::
    {:ok, list(map())} | {:error, any()}

  @callback valida_existencia_habilitacion_correo(
    login_url :: String.t(),
    correo :: String.t(),
    id_app :: String.t(),
    enterprise_id :: integer()
  ) :: {:ok, map()} | {:error, any()}

  @callback valida_pin_email(
    login_url :: String.t(),
    pin :: String.t(),
    correo :: String.t(),
    id_app :: String.t()
  ) :: {:ok, map()} | {:error, any()}

  @callback obtener_usuario_token(
    login_url :: String.t(),
    token :: String.t()
  ) :: {:ok, map()} | {:error, any()}
end

Existen 8 behaviours de este tipo en la aplicación: discovery_client, login_client, viaje_client, pasajero_client, conductor_client, pagos_client, notificacion_client, htmls_client.

Resolución runtime: producción vs. test

lib/autowebapp/shared/notificaciones.ex:184–190
defp notificacion_client do
  Application.get_env(
    :autowebapp,
    :notificacion_client,
    Autowebapp.Clients.NotificacionClientImpl
  )
end

En producción devuelve NotificacionClientImpl (HTTP real). En tests, config/test.exs inyecta NotificacionClientMock generado por Mox. El código de dominio nunca distingue entre ambos — solo conoce el contrato del behaviour.

Definición de mocks en test

test/test_helper.exs
Mox.defmock(Autowebapp.Clients.LoginClientMock,
  for: Autowebapp.Clients.LoginClient)
Mox.defmock(Autowebapp.Clients.NotificacionClientMock,
  for: Autowebapp.Clients.NotificacionClient)
# ...ídem para los 7 clients restantes

Mox valida en tiempo de compilación que el mock implementa exactamente las callbacks del behaviour. Si el contrato cambia, los mocks fallan — no los tests en silencio.

Por qué no hay GenServer de dominio ni GenStage — y por qué eso es correcto

GenServer de dominio: ausente a propósito. Un GenServer de negocio custodiaría estado de dominio en memoria. Pero en autowebapp, el estado de negocio no vive aquí: vive en los microservicios externos. No hay sesiones de usuario que mantener localmente, no hay colas de pedidos, no hay contadores globales. No existe nada que custodiar. Añadir un GenServer de dominio solo crearía un cuello de botella sin justificación.

Lo único que existe es un Agent (que internamente es un GenServer degenerado de un solo estado), usado para almacenamiento auxiliar puntual, no para orquestar lógica de negocio.

GenStage: no aplica. No hay pipelines de procesamiento de datos, ni flujos productor-consumidor, ni necesidad de backpressure. La app es esencialmente un proxy HTTP que transforma y agrega respuestas de microservicios. El modelo request-response de Phoenix es suficiente.

Reconocer cuándo no usar una herramienta de OTP es tan importante como saber cuándo usarla.

Concurrencia y el Scheduler del BEAM

✅ Fundación ⚠️ Clustering latente
La idea general

El BEAM (Bogdan/Björn's Erlang Abstract Machine) tiene un modelo de concurrencia único que lo distingue radicalmente de los runtimes convencionales:

  • Procesos ultra-ligeros: no son threads del OS. Un proceso BEAM arranca con ~2 KB de RAM (frente a los ~8 MB de un thread OS típico). Crear millones de ellos es rutinario y barato.
  • Scheduler preemptivo por reducciones: cada proceso recibe una cuota de ~2.000 reducciones (operaciones básicas) antes de ser pausado y devuelto a la cola. Ningún proceso puede monopolizar la CPU, incluso dentro de un loop infinito — el scheduler lo expulsa sin que el proceso lo sepa.
  • Múltiples schedulers: el BEAM crea tantos schedulers como núcleos lógicos tenga la máquina. Cada scheduler corre en un thread OS y distribuye los procesos BEAM entre sí de forma automática y transparente.
  • GC por proceso: cada proceso tiene su propio heap y su propia garbage collection. No existe un stop-the-world global. La GC de un proceso no pausa a ningún otro proceso del sistema.
  • Hot code reload: el BEAM soporta reemplazar módulos en ejecución sin detener el sistema. Phoenix expone esto a través del callback config_change/3, que se invoca automáticamente cuando la configuración del endpoint cambia en caliente durante desarrollo.
En autowebapp

Concurrencia implícita sin configuración especial

La concurrencia masiva del BEAM es la fundación implícita de todo lo que hace autowebapp. No hay código que la "active" — simplemente está ahí por el hecho de usar Phoenix LiveView. Cuando 5.000 pasajeros están conectados a /pasajero/viaje_activo, el BEAM está manejando al menos 5.000 procesos LiveView en paralelo, más los procesos de socket, los supervisores, el PubSub, el Agent de Discovery, la telemetría... todo concurrente, sin que ningún proceso bloquee a otro.

Hot reload vía config_change/3

Phoenix llama automáticamente a este callback cuando la configuración del endpoint cambia en caliente en desarrollo, sin reiniciar la aplicación completa:

@impl true
def config_change(changed, _new, removed) do
  AutowebappWeb.Endpoint.config_change(changed, removed)
  :ok
end
lib/autowebapp/application.ex:55–58 — callback estándar de OTP Application, delegado al Endpoint de Phoenix para que actualice su configuración interna sin bajar los sockets activos.
Honestidad: clustering cableado, no ejercitado

DNSCluster está presente en el árbol de supervisión de application.ex:36, pero configurado con :ignore en desarrollo:

{DNSCluster, query: Application.get_env(:autowebapp, :dns_cluster_query) || :ignore}

Esto significa que la infraestructura para conectar nodos Erlang en un cluster distribuido — donde los procesos pueden verse entre máquinas y el PubSub funciona across nodesestá lista en la app, pero en modo standby. Solo necesita que se defina dns_cluster_query en la configuración de producción para activarse. Hasta entonces, DNSCluster arranca, recibe :ignore y se detiene silenciosamente. Es concurrencia distribuida que existe en el código pero no en el runtime actual.

📡

PubSub: Mensajería en Tiempo Real

✅ Fuerte
La idea general

Phoenix.PubSub es un sistema de mensajería publish/subscribe construido sobre los primitivos nativos del BEAM. El modelo es directo: los procesos se suscriben a un topic (una cadena que identifica un canal), y cualquier proceso puede hacer broadcast a ese topic. Todos los suscriptores reciben el mensaje directamente en su mailbox.

En un cluster Erlang/OTP, el PubSub enruta automáticamente los mensajes al nodo correcto sin configuración adicional — la distribución es transparente para el código de aplicación.

La diferencia fundamental respecto a un event bus de otros lenguajes es que aquí no hay locks ni memoria compartida. Cada proceso es un actor aislado con su propio mailbox. El PubSub es simplemente una capa de direccionamiento sobre el paso de mensajes que el BEAM ya provee de forma nativa — lo que lo hace extremadamente eficiente y libre de condiciones de carrera por diseño.

En autowebapp: el sistema de notificaciones de viaje

Este es el concepto más rico de la app, con un patrón especialmente elegante que combina persistencia HTTP con entrega en tiempo real.

1. Registro del bus en el árbol de supervisión

lib/autowebapp/application.ex:38 — el bus se levanta como proceso supervisado antes que el Endpoint, con nombre global Autowebapp.PubSub:
{Phoenix.PubSub, name: Autowebapp.PubSub}

2. Naming centralizado de topics

lib/autowebapp/shared/notificaciones.ex:62–63 — un módulo centralizado define todas las cadenas de topic. Nadie en la app escribe estas strings directamente:
def topic("A", id), do: "conductor:#{id}"
def topic("P", id), do: "pasajero:#{id}"

Solo se llama Notificaciones.topic(rol, id). Si mañana cambia el formato del topic, hay un único lugar donde actualizar.

3. Suscripción en el mount de la LiveView

lib/autowebapp/shared/notificaciones.ex:80–82 — la suscripción se hace dentro de if connected?(socket) para evitar la suscripción doble durante el dead render inicial del servidor:
def subscribe(rol, id) do
  Phoenix.PubSub.subscribe(@pubsub, topic(rol, id))
end

4. Broadcast al ocurrir un evento — el patrón dual

lib/autowebapp/shared/notificaciones.ex:105–128 — el corazón del sistema: primero persiste, luego notifica en tiempo real:
def notificar(core_url, attrs) do
  client = notificacion_client()
  case client.crear(core_url, attrs) do
    {:ok, notificacion} ->
      rol = Map.get(attrs, :destinatario_rol)
      id  = Map.get(attrs, :destinatario_id)
      Phoenix.PubSub.broadcast(
        @pubsub,
        topic(rol, id),
        {:nueva_notificacion, notificacion}
      )
      {:ok, notificacion}
    {:error, reason} = error ->
      Logger.error("[Notificaciones] Error: #{inspect(reason)}")
      error
  end
end
  • Paso 1 — persistencia HTTP: la notificación se crea en core-empresas vía HTTP antes de cualquier broadcast. Garantía: el usuario la verá aunque cierre la app y vuelva más tarde.
  • Paso 2 — broadcast PubSub (best-effort): solo se ejecuta si la persistencia fue exitosa. Si el usuario está conectado, recibe la notificación al instante. Si no hay nadie suscrito, el mensaje se descarta silenciosamente — sin errores, sin reintentos necesarios.

5. Recepción en el handle_info de la LiveView

El proceso LiveView del conductor o pasajero recibe el mensaje en su mailbox, actualiza sus assigns y Phoenix re-renderiza únicamente los componentes que cambiaron:

def handle_info({:nueva_notificacion, notif}, socket) do
  {:noreply, update(socket, :notificaciones, &[notif | &1])}
end

Lo elegante de este diseño es la separación clara de responsabilidades: el naming centralizado protege contra strings dispersas, el patrón dual garantiza que la persistencia siempre precede al tiempo real, y el broadcast es inherentemente fire-and-forget — si el destinatario está offline, simplemente no hay nadie escuchando y el sistema sigue funcionando sin bloquearse. Es el actor model aplicado de forma práctica: cada proceso vive su propio ciclo de vida y la comunicación nunca requiere que ambos extremos estén sincronizados al mismo tiempo.

Síntesis

autowebapp aprovecha con fuerza la mitad concurrencia y aislamiento de OTP — actor model, procesos por usuario, PubSub, behaviours, Agent — y aplica poco la mitad "let it crash / resiliencia por reinicio", y eso es correcto.

"Let it crash" brilla en workers de fondo reemplazables, donde matar y reiniciar un proceso no tiene costo visible para el usuario. En un frontend interactivo, dejar morir la LiveView de alguien es dejar que su pantalla se rompa. Por eso el código de dominio es deliberadamente defensivo con tuplas {:ok, _} / {:error, _} y fallbacks seguros. No es una omisión filosófica — es la filosofía aplicada correctamente al contexto.

"La filosofía OTP no es un dogma. Es un conjunto de herramientas: usas las que encajan con el dominio, y entiendes por qué no usas las otras."