Hay un patron que sigue apareciendo en lugares que no esperarias. Una aplicacion de terminal en Rust. Un backend en Scala procesando comandos. El bucle principal de un motor de juegos. Un dispositivo embebido consultando sensores. Todos convergen en la misma estructura de tres piezas: un Model que contiene el estado, una funcion Update que lo transforma, y una funcion View que lo renderiza. Sin callbacks accediendo a estado mutable compartido. Sin cadenas de observers disparandose en orden impredecible. Solo un bucle.
Este patron tiene nombre: The Elm Architecture (TEA). Y a pesar del nombre, no necesitas el lenguaje Elm para usarlo. Ni siquiera necesitas un frontend. El patron es agnostico de lenguaje y de dominio. Es una forma de organizar cualquier programa que reacciona a eventos externos y necesita proyectar estado sobre alguna salida.
Este post explica el patron desde cero, con ejemplos en Rust y Scala.
De donde viene #
En 2012, Evan Czaplicki creo Elm, un lenguaje funcional que compila a JavaScript para construir interfaces web. Elm era obstinado: sin null, sin excepciones, sin estado mutable. El lenguaje forzaba cada aplicacion a una forma especifica:
- Define un Model: una estructura de datos que contiene todo el estado de la aplicacion.
- Define una funcion Update: dado el modelo actual y un mensaje (un evento), devuelve un nuevo modelo.
- Define una funcion View: dado el modelo actual, devuelve la descripcion de la UI.
El runtime se encarga del resto. Llama a View para renderizar, captura los eventos del usuario como mensajes, los pasa a Update, obtiene un nuevo modelo, llama a View otra vez. Para siempre.
Esto no se vendio como un patron de arquitectura. Era simplemente como funcionaban los programas en Elm. Pero los desarrolladores notaron algo: el patron era portable. Podias aplicar la misma estructura en Rust, en Scala, en cualquier lenguaje donde controlaras el bucle principal. El nombre se quedo: The Elm Architecture.
Las tres piezas #
Construyamos un modelo mental antes de tocar codigo.
Model #
El Model es una estructura de datos unica que contiene todo lo que la aplicacion necesita para renderizar y para decidir que hacer a continuacion. No parte del estado. Todo.
En una app de contador, el modelo es un numero. En una lista de tareas, es una lista de tareas mas un filtro mas lo que el usuario esta escribiendo en ese momento. En un juego, son las posiciones de todas las entidades, la puntuacion, el nivel y el estado del input.
La regla clave: el Model es la unica fuente de verdad. No hay estado separado escondido en la closure de un callback, en una variable global, o en un singleton mutable. Si no esta en el Model, no existe.
Update #
La funcion Update recibe dos cosas: el Model actual y un Message (a veces llamado Action o Event). Devuelve un nuevo Model.
Esa es toda la firma. La funcion no toca la pantalla, no lee input del usuario, no llama a una API. Recibe datos, devuelve datos. Una funcion pura en el sentido de la programacion funcional (o tan pura como tu lenguaje permita).
Los mensajes se modelan tipicamente como un tipo suma: un enum en Rust, un sealed trait (o enum desde Scala 3) en Scala. Cada variante representa algo que ocurrio. El usuario pulso una tecla. Un timer se disparo. Llego una respuesta de red. La funcion Update hace match sobre la variante y decide como cambia el modelo.
View #
La funcion View recibe el Model actual y devuelve una descripcion de la salida.
No muta el modelo. No produce efectos secundarios. Lee el modelo y describe como deberia verse la pantalla. Que esa descripcion sea widgets de terminal, HTML, o una linea de texto depende de la plataforma.
La propiedad critica: la View es una proyeccion. Dado el mismo modelo, siempre produce el mismo resultado. Si quieres saber que hay en pantalla, mira el modelo. Si quieres cambiar lo que hay en pantalla, cambia el modelo. La View es solo la lente.
El bucle #
Las tres piezas se conectan en un ciclo:
┌──────────────────────────────────┐
│ │
▼ │
Model ──▶ View ──▶ Pantalla │
│ │
evento usuario │
│ │
▼ │
Message ──▶ Update─┘- El Model se pasa a View, que renderiza la pantalla.
- El usuario hace algo (clic, teclea, pulsa una tecla). Esto produce un Message.
- El Message y el Model actual van a Update, que devuelve un nuevo Model.
- Vuelve al paso 1.
Esa es toda la arquitectura. Sin bus de eventos. Sin patron observer. Sin data binding bidireccional. Sin contenedor de inyeccion de dependencias. Un bucle, tres funciones, una estructura de datos.
Un ejemplo concreto: un contador #
Veamos el patron aplicado a la aplicacion mas simple posible: un contador con incrementar, decrementar y resetear. Lado a lado en Rust y Scala.
En Rust #
// -- Model --
struct Model {
count: i32,
}
// -- Message --
enum Msg {
Increment,
Decrement,
Reset,
}
// -- Update --
fn update(model: Model, msg: Msg) -> Model {
match msg {
Msg::Increment => Model { count: model.count + 1 },
Msg::Decrement => Model { count: model.count - 1 },
Msg::Reset => Model { count: 0 },
}
}
// -- View --
fn view(model: &Model) -> String {
format!("Count: {}", model.count)
}El Model es un struct. El Msg es un enum. El update consume el modelo antiguo y devuelve uno nuevo (transferencia de ownership: el estado viejo desaparece, el nuevo ocupa su lugar). La view toma prestado el modelo y produce una descripcion. Sin mutacion, sin efectos secundarios.
En Scala #
// -- Model --
case class Model(count: Int)
// -- Message --
enum Msg:
case Increment, Decrement, Reset
// -- Update --
def update(model: Model, msg: Msg): Model = msg match
case Msg.Increment => model.copy(count = model.count + 1)
case Msg.Decrement => model.copy(count = model.count - 1)
case Msg.Reset => Model(count = 0)
// -- View --
def view(model: Model): String =
s"Count: ${model.count}"El Model es una case class. El Msg es un enum de Scala 3. El update usa copy para derivar un nuevo modelo del existente, que es el patron estandar de actualizacion inmutable en Scala. El modelo antiguo queda intacto. La view es una funcion pura.
Recorriendo el bucle #
Estado inicial: Model { count: 0 } en Rust, Model(count = 0) en Scala.
- View renderiza:
"Count: 0". - El usuario envia
Increment. - Update recibe el modelo e
Increment. DevuelveModel { count: 1 }. - View renderiza:
"Count: 1". - El usuario envia
Incrementotra vez. - Update devuelve
Model { count: 2 }. - View renderiza:
"Count: 2". - El usuario envia
Reset. - Update devuelve
Model { count: 0 }. - View renderiza:
"Count: 0".
En cada paso, el modelo es la verdad completa. La view es una funcion determinista del modelo. El update es una funcion determinista del modelo y el mensaje. No hay estado oculto, no hay efectos secundarios dependientes del orden, no hay “pero que pasa si el usuario hizo clic antes de que terminara el render anterior.”
Conectando el bucle #
Los ejemplos de arriba definen las piezas. El bucle en si es directo. Aqui va una version minima para cada lenguaje.
Rust (un bucle de terminal simplificado leyendo de stdin):
fn main() {
let mut model = Model { count: 0 };
loop {
println!("{}", view(&model));
println!("[+] increment [-] decrement [r] reset [q] quit");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
let msg = match input.trim() {
"+" => Msg::Increment,
"-" => Msg::Decrement,
"r" => Msg::Reset,
"q" => break,
_ => continue,
};
model = update(model, msg);
}
}Scala (usando @tailrec para un bucle seguro en el stack):
import scala.annotation.tailrec
import scala.io.StdIn
@tailrec
def loop(model: Model): Unit =
println(view(model))
println("[+] increment [-] decrement [r] reset [q] quit")
StdIn.readLine().trim match
case "+" => loop(update(model, Msg.Increment))
case "-" => loop(update(model, Msg.Decrement))
case "r" => loop(update(model, Msg.Reset))
case "q" => ()
case _ => loop(model)
@main def run(): Unit = loop(Model(count = 0))Ambos bucles hacen lo mismo: renderizar, leer, despachar, repetir. La version Rust usa un binding mutable (let mut model) reasignado en cada iteracion. La version Scala usa tail recursion con el nuevo modelo como argumento, manteniendo todo inmutable. Idiomas distintos, misma estructura.
TEA en una TUI real de Rust #
El contador de arriba es un juguete. En una aplicacion real, el patron escala de forma natural.
En la serie Todo TUI en Rust, construimos un gestor de tareas interactivo usando ratatui. El mapeo a TEA fue directo:
| Concepto TEA | Implementacion | Archivo |
|---|---|---|
| Model | Struct App (tareas, filtro, buffer de entrada, InputMode) |
app.rs |
| Update | Metodos de App llamados desde el handler de eventos |
app.rs |
| View | fn draw(app: &App, frame: &mut Frame) |
ui.rs |
| Message | KeyEvent despachado por modo en handle_events |
event.rs |
El enum InputMode actuaba como maquina de estados dentro del modelo: Normal, Adding, Editing, ConfirmDelete. Cada modo definia que teclas estaban activas y que significaban. El handler de eventos hacia match primero sobre el modo, despues sobre la tecla, y luego llamaba al metodo apropiado de App. La view leia el modelo y lo proyectaba en la terminal. Un bucle, tres responsabilidades, cero sorpresas.
El event loop de ratatui en la practica:
loop {
// View: proyectar modelo en la terminal
terminal.draw(|frame| ui::draw(&app, frame, &mut table_state))?;
// Read: esperar evento del usuario
if event::poll(Duration::from_millis(16))? {
if let Event::Key(key) = event::read()? {
// Update: transformar modelo segun el mensaje
event::handle_events(&mut app, key)?;
}
}
}Dibujar, sondear, manejar. Las fases son secuenciales, nunca simultaneas. En Rust, esto no es solo una decision de diseno: el borrow checker lo impone. No puedes tener &app (para renderizar) y &mut app (para actualizar) al mismo tiempo. La estructura TEA satisface al borrow checker por diseno.
TEA en Scala: modelos inmutables y pattern matching #
Scala no tiene un framework TUI dominante como ratatui, pero el patron TEA encaja perfectamente con las fortalezas de Scala: case classes inmutables para el modelo, tipos sellados para los mensajes, y pattern matching exhaustivo para el update.
Consideremos un ejemplo un poco mas rico: un modelo de gestor de tareas.
// -- Model --
case class Task(title: String, done: Boolean)
case class Model(
tasks: List[Task],
filter: Filter,
input: String,
mode: Mode
)
// -- Messages --
enum Msg:
case AddTask
case ToggleTask(index: Int)
case DeleteTask(index: Int)
case SetInput(text: String)
case CycleFilter
case EnterAddMode
case Cancel
enum Mode:
case Normal, Adding
enum Filter:
case All, Todo, DoneLa funcion update es un unico match:
def update(model: Model, msg: Msg): Model = msg match
case Msg.AddTask =>
val task = Task(title = model.input, done = false)
model.copy(tasks = model.tasks :+ task, input = "", mode = Mode.Normal)
case Msg.ToggleTask(i) =>
val updated = model.tasks.updated(i, model.tasks(i).copy(done = !model.tasks(i).done))
model.copy(tasks = updated)
case Msg.DeleteTask(i) =>
model.copy(tasks = model.tasks.patch(i, Nil, 1))
case Msg.SetInput(text) =>
model.copy(input = text)
case Msg.CycleFilter =>
val next = model.filter match
case Filter.All => Filter.Todo
case Filter.Todo => Filter.Done
case Filter.Done => Filter.All
model.copy(filter = next)
case Msg.EnterAddMode =>
model.copy(mode = Mode.Adding, input = "")
case Msg.Cancel =>
model.copy(mode = Mode.Normal, input = "")Cada rama devuelve un nuevo Model via copy. El modelo anterior queda intacto. Anadir una nueva variante de Msg hace que el compilador avise del match no exhaustivo, igual que el match exhaustivo de Rust sobre enums. El sistema de tipos actua como verificador de maquina de estados en ambos lenguajes.
La funcion view en Scala seria una funcion pura que transforma el modelo en un string, un arbol de UI, o cualquier formato de salida que la aplicacion use. El punto es el mismo que en Rust: la view no decide, describe.
Como se relaciona TEA con MVC #
Si has trabajado con MVC (Model-View-Controller), la comparacion es instructiva.
| Aspecto | MVC | TEA |
|---|---|---|
| Ubicacion del estado | Distribuido entre objetos Model | Un unico struct/case class Model |
| Mutacion del estado | El Controller llama a metodos del Model | Update devuelve un nuevo Model |
| Actualizacion de la vista | Patron Observer (Model notifica a View) | Re-renderizar desde cero cada ciclo |
| Manejo de eventos | Controller recibe eventos, los enruta al Model | Messages despachados a la funcion Update |
| Flujo de datos | Bidireccional (View <-> Controller <-> Model) | Unidireccional (Model -> View -> Message -> Update -> Model) |
La diferencia fundamental es la direccion del flujo de datos. MVC permite comunicacion bidireccional: la vista puede hablar con el controlador, el controlador con el modelo, el modelo de vuelta a la vista. Cuando algo falla, el estado del sistema depende del orden en que se dispararon las notificaciones.
TEA es unidireccional. Los datos fluyen en una sola direccion: Model -> View -> Message -> Update -> Model. No hay canales traseros. Si quieres saber por que la pantalla muestra lo que muestra, miras el Model. Si quieres saber como el Model llego a su estado actual, reproduces los Messages a traves de Update. Toda la historia de la aplicacion es una secuencia de mensajes aplicados a un modelo inicial.
Para desarrolladores Scala que vienen de frameworks como Play o Akka HTTP: TEA no es un reemplazo para tu framework web. Es un patron para organizar las partes con estado e interactivas de tu aplicacion. Un REPL, un asistente CLI, un dashboard, un juego. En cualquier lugar donde tengas un bucle que lee eventos y produce salida.
Cuando TEA falla #
TEA no es una bala de plata. Hay escenarios donde el patron crea friccion en vez de reducirla.
Efectos secundarios #
El bucle puro de TEA no tiene sitio para efectos secundarios: peticiones HTTP, I/O de ficheros, timers, numeros aleatorios. En Elm, esto se resuelve con Commands: la funcion Update devuelve tanto un nuevo Model como una lista de efectos a ejecutar. El runtime maneja los efectos y alimenta los resultados como nuevos Messages.
Fuera de Elm, tienes que resolverlo tu. En Rust, puedes lanzar una tarea con tokio y enviar su resultado de vuelta por un canal mpsc. En Scala, puedes devolver un IO o Future junto al nuevo modelo. El patron sigue funcionando, pero el ideal de “Update puro” recibe compromisos pragmaticos. Un enfoque comun:
// Rust: Update devuelve modelo + comando opcional
fn update(model: Model, msg: Msg) -> (Model, Option<Command>) { ... }// Scala: Update devuelve modelo + efecto opcional
def update(model: Model, msg: Msg): (Model, Option[IO[Msg]]) = ...El Command/IO se ejecuta en el bucle, y su resultado se convierte en un nuevo Message. Esto preserva el flujo unidireccional mientras permite efectos secundarios.
Actualizaciones de alta frecuencia #
Si tu aplicacion procesa miles de eventos por segundo (una visualizacion de datos en tiempo real, una UI de trading de alta frecuencia), crear un nuevo Model en cada evento puede ser costoso. En Scala, las copias de case class son baratas para modelos pequenos pero anaden presion al GC a escala. En Rust, el coste depende de lo que haya dentro del struct: un Vec<Task> clone no es gratis.
El enfoque inmutable de “devolver un nuevo modelo” asume que el coste de crear un nuevo estado es despreciable. Cuando no lo es, necesitas estado mutable con actualizaciones dirigidas, y la simplicidad de TEA se convierte en overhead.
Arboles de componentes profundos #
En aplicaciones grandes con muchos componentes independientes, tener un unico Model que contenga todo puede volverse inmanejable. La funcion Update crece hasta ser un bloque match gigante. Por eso algunas arquitecturas introducen reducers componibles que manejan rebanadas del estado. El patron escala, pero necesita adiciones estructurales.
Cuando el modelo es la base de datos #
Si el estado de tu aplicacion es fundamentalmente una base de datos (piensa: una hoja de calculo, un CRM), la asuncion de “un unico Model en memoria” deja de tener sentido. El estado es demasiado grande para caber en un struct o case class, y las actualizaciones son demasiado complejas para expresarlas como funciones puras. TEA funciona mejor cuando el modelo cabe en memoria y la logica de update es autocontenida.
La checklist mental #
Antes de usar TEA, preguntate:
- Es el programa dirigido por eventos? Si reacciona a input del usuario, eventos de red o ticks de timer, TEA encaja de forma natural.
- Cabe el estado en un struct? Si es asi, el Model es directo. Si el estado esta distribuido entre bases de datos, caches y servicios externos, necesitas mas maquinaria.
- Es la logica de update autocontenida? Si cada mensaje se puede manejar sin llamar a servicios externos, Update se mantiene puro. Si cada mensaje necesita una llamada a API, necesitas un sistema de efectos encima.
- Es el renderizado lo bastante barato para rehacerlo desde cero? Si la view es una funcion que produce una descripcion ligera (arbol de widgets de terminal, string de salida), re-renderizar esta bien. Si el renderizado es costoso (una escena 3D sin deteccion de cambios), necesitas actualizaciones incrementales.
Si respondiste si a las cuatro, TEA probablemente simplificara tu codebase. Si respondiste no a una o dos, todavia puedes usar TEA como punto de partida y anadir valvulas de escape donde haga falta.
Lo que ganas #
El valor mas profundo de TEA no es tecnico. Es cognitivo.
Cuando miras una aplicacion TEA, puedes responder tres preguntas inmediatamente:
- Cual es el estado de la aplicacion? -> Mira el Model (
structen Rust,case classen Scala). - Que puede ocurrir? -> Mira el tipo Message (
enumen ambos lenguajes). - Como cambia el estado? -> Mira la funcion Update (
matchen ambos lenguajes).
Sin trazar callbacks. Sin depurar cadenas de observers. Sin “que middleware muto el estado antes de que mi handler se ejecutara.” El bucle cabe en tu cabeza. Y en un codebase que sera mantenido durante anios por personas que no lo escribieron, eso vale mas que cualquier optimizacion de rendimiento.
The Elm Architecture no es inteligente. Es obvio. Y ese es el mayor cumplido que un patron puede recibir.