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.
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.
| 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. |
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.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óndef 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
El código de dominio y las LiveViews son deliberadamente defensivos, y eso es correcto:
lib/autowebapp/shared/notificaciones.ex:147–159 — pendientes/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.
En el modelo de actores, la unidad fundamental de computación es el proceso (no el hilo del OS). Cada proceso:
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.
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:
lib/autowebapp/shared/notificaciones.ex:80–82def subscribe(rol, id) do
Phoenix.PubSub.subscribe(@pubsub, topic(rol, id))
end
lib/autowebapp/shared/notificaciones.ex:118–122Phoenix.PubSub.broadcast(
@pubsub,
topic(rol, id),
{:nueva_notificacion, notificacion}
)
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.
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.
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–49Primera 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–81El 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–169La 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.
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.
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.exdefmodule 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–190defp 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.exsMox.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.
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.
El BEAM (Bogdan/Björn's Erlang Abstract Machine) tiene un modelo de concurrencia único que lo distingue radicalmente de los runtimes convencionales:
config_change/3, que se invoca automáticamente cuando la configuración del endpoint cambia en caliente durante desarrollo.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.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 nodes — está 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.
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.
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
core-empresas vía HTTP antes de cualquier broadcast. Garantía: el usuario la verá aunque cierre la app y vuelva más tarde.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.
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.