wandres.dev
NIVEL DIOS: SÍNTESIS · pensar funcionalmente

Construir una app real de principio a fin: el plano completo

Saber Elm y saber construir una aplicación en Elm son dos competencias distintas, y la segunda casi nunca se enseña porque exige tomar decisiones en un orden que ningún tutorial respeta. Esta lección es el plano de obra completo: qué se decide primero y por qué, cómo se deriva el Model del dominio en lugar de la pantalla, cómo se define el tipo Route antes que ninguna vista, dónde van exactamente las fronteras de decoders y ports, qué se prueba y qué no merece la pena probar, y cómo se llega a un artefacto desplegado sin sorpresas. El hilo conductor es que en Elm el orden correcto es de dentro hacia fuera —dominio, estado, mensajes, rutas, fronteras, interfaz— porque cada capa restringe a la siguiente, y quien empieza por la pantalla acaba con un Model que es un espejo del diseño visual y no una representación de lo que la aplicación sabe. Se recorre además el cierre del ciclo: elm-test, elm-review, la compilación optimizada, el tamaño del bundle y el despliegue como estático.

⏱ 24 min

El salto que separa a quien conoce un lenguaje de quien construye con él no está en la sintaxis restante, sino en el orden de las decisiones. Un tutorial empieza por la pantalla porque la pantalla se ve, y esa comodidad tiene un precio que se cobra tres meses después en forma de un Model que es un calco del diseño visual, lleno de banderas que existen para que un botón se ponga gris, y absolutamente incapaz de responder a la pregunta de qué sabe la aplicación en este instante. Elm castiga ese orden con una dureza inusual, y esa dureza es una ventaja: como el compilador no te deja mentir sobre los estados, el mal orden se manifiesta pronto y con síntomas legibles. Esta lección invierte la secuencia y la recorre entera. Primero el dominio, después el estado que lo representa, después el vocabulario de mensajes que lo transforma, después las rutas, después las fronteras con el mundo, y solo al final la interfaz. Y luego, porque una aplicación sin desplegar no es una aplicación, el cierre: pruebas, revisión automatizada, compilación optimizada y publicación.

🎯 Al terminar esta lección sabrás
  • Ordenar las decisiones de un proyecto de dentro hacia fuera y justificar por qué el Model se deriva del dominio y no de la pantalla.
  • Diseñar el tipo Route, el reparto de Msg por página y la composición de estado sin caer en la imitación de componentes.
  • Situar con precisión las fronteras del programa: banderas de arranque, decoders, encoders y ports, y decidir qué cruza por cada una.
  • Cerrar el ciclo con pruebas útiles, revisión automatizada, compilación optimizada y un despliegue estático reproducible.

El orden correcto de las decisiones

Antes de la primera línea hay una pregunta que decide la calidad de todo lo demás: qué sabe esta aplicación. No qué muestra, no qué pantallas tiene, sino qué información posee y en qué situaciones puede encontrarse respecto a esa información. La respuesta se escribe en tipos, y se escribe con uniones etiquetadas siempre que haya situaciones mutuamente excluyentes. Un panel que carga un informe no tiene un informe y un booleano de carga y un texto de error: tiene exactamente un valor de cuatro estados, y esa diferencia no es cosmética, porque la primera versión admite dieciséis combinaciones de las cuales doce son basura que alguien tendrá que ignorar en algún sitio y que alguien olvidará ignorar en algún otro.

-- Paso 1: el dominio, sin pensar todavia en pantallas.

type Peticion e a
    = Ociosa
    | Cargando
    | Fallida e
    | Lista a


type alias Informe =
    { id : IdInforme
    , titulo : String
    , periodo : Periodo
    , filas : List Fila
    }


-- Paso 2: el Model como composicion de esos estados.

type alias Model =
    { clave : Nav.Key
    , ruta : Route
    , sesion : Sesion
    , informe : Peticion ErrorApi Informe
    , filtros : Filtros
    }

Fíjate en lo que aún no aparece. No hay mostrandoModal, no hay pestanaActiva, no hay botonDeshabilitado. Esas tres cosas son consecuencias derivables del estado, no estado. La regla es sencilla de enunciar y difícil de sostener bajo presión: si un campo puede calcularse a partir de otros, no es un campo, es una función de la vista. Cada vez que se guarda un derivado en el Model se crea la obligación de mantenerlo sincronizado, y las obligaciones de sincronización son la deuda técnica que más rápido se acumula en una interfaz.

El paso siguiente es el vocabulario. Msg enumera todo lo que puede pasarle a la aplicación, y su calidad se mide por una prueba concreta: leído en voz alta y sin ver el resto del código, debe describir el sistema. Un Msg con constructores llamados ClicEnBoton o ActualizarCampo suspende esa prueba; uno con PidioExportar, LlegoElInforme o CaducoLaSesion la aprueba. El nombre debe decir qué ocurrió en el mundo, no qué elemento del DOM se tocó, porque el elemento del DOM cambiará en el próximo rediseño y el hecho del mundo no.

Rutas, páginas y el reparto del estado

En una aplicación con varias vistas direccionables, el tipo Route se define antes que cualquier vista, y se define como el conjunto exacto de direcciones válidas. El parser convierte una URL en un Route o en nada, y ese nada es el 404, tratado como un caso legítimo del dominio y no como una excepción. Elm te obliga a nombrarlo, y esa obligación es la razón de que las aplicaciones en Elm raramente tengan rutas rotas silenciosas.

type Route
    = Inicio
    | Informes
    | DetalleInforme IdInforme
    | Ajustes
    | NoEncontrada


parser : Parser (Route -> a) a
parser =
    Parser.oneOf
        [ Parser.map Inicio Parser.top
        , Parser.map Informes (Parser.s "informes")
        , Parser.map DetalleInforme (Parser.s "informes" </> idInformeParser)
        , Parser.map Ajustes (Parser.s "ajustes")
        ]

El reparto del estado por página es donde más proyectos se tuercen, porque la tentación de reproducir componentes es enorme y Elm no los tiene. La forma idiomática es que el Model raíz contenga un campo pagina cuyo tipo sea una unión con el estado propio de cada página, y que Msg contenga un constructor por página que envuelva el mensaje hijo. El update raíz hace de conmutador: desenvuelve, delega en el update de la página, vuelve a envolver el modelo resultante y traduce el Cmd con Cmd.map. Es más ceremonia que en un sistema de componentes, y a cambio el flujo de datos es visible entero en una función.

💡
Extrae un submódulo tarde, no pronto

La heurística que mejor funciona es esperar a que duela. Un módulo por página que solo tiene init, update, view y un Model de dos campos es ceremonia sin beneficio; conviene dejar esa página dentro del módulo raíz hasta que su lógica tenga cuerpo propio. La señal de que ha llegado el momento no es el número de líneas sino la aparición de estado que solo esa página entiende y que ninguna otra necesita leer. Cuando eso ocurre, extraer es mecánico. Cuando se extrae antes de que ocurra, se acaba pasando media docena de campos del padre al hijo en cada llamada, que es la forma que tiene Elm de avisarte de que la frontera está mal puesta.

Las fronteras: banderas, decoders y ports

Toda la comunicación con el exterior atraviesa tres puertas y solo tres, y conviene tenerlas dibujadas antes de escribir la primera petición. La primera son las banderas de arranque: lo que el anfitrión inyecta al iniciar el programa, típicamente la configuración de entorno y un token recuperado del almacenamiento local. Aunque las banderas se decodifican automáticamente, la práctica robusta es tiparlas como Json.Decode.Value y decodificarlas a mano, porque así un anfitrión que envía basura produce un estado de error explícito en lugar de un fallo de inicialización que rompe la página antes de que exista nada capaz de contarlo.

main : Program Json.Decode.Value Model Msg
main =
    Browser.application
        { init = init
        , update = update
        , subscriptions = subscriptions
        , view = view
        , onUrlRequest = PidioNavegar
        , onUrlChange = CambioLaUrl
        }


init : Json.Decode.Value -> Url -> Nav.Key -> ( Model, Cmd Msg )
init banderas url clave =
    case Json.Decode.decodeValue decodificarBanderas banderas of
        Ok config ->
            arrancar config url clave

        Err _ ->
            ( arranqueDegradado clave, Cmd.none )

La segunda puerta son los decoders y encoders del API. La disciplina que paga sola es escribir un decoder por recurso y colocarlo junto al tipo que produce, nunca junto a la vista que lo consume. La tercera puerta son los ports, y la regla de oro es que un port debe ser lo más tonto posible: no envía órdenes complejas ni recibe objetos ricos, envía y recibe datos planos que se decodifican al entrar exactamente igual que si vinieran de la red. Un port que recibe un Json.Decode.Value y lo decodifica es un port que no puede tumbar la aplicación; uno que confía en la forma del objeto que le manda JavaScript ha renunciado a la única garantía por la que se eligió Elm.

flowchart LR
A[Banderas del anfitrion] --> D[Decoder de frontera]
B[Respuesta HTTP] --> D
C[Port entrante] --> D
D --> E[Tipos del dominio]
E --> F[Model]
F --> G[update puro]
G --> H[Cmd y Sub]
H --> I[Encoder de salida]
I --> J[Red o port saliente]
style D fill:#f9e2af,color:#11111b
style G fill:#a6e3a1,color:#11111b
style I fill:#f9e2af,color:#11111b

Cerrar el ciclo: pruebas, revisión y despliegue

En Elm la pregunta sobre pruebas no es cuánto cubrir sino qué merece cubrirse, y la respuesta cambia respecto a otros ecosistemas porque el compilador ya cubre gratis todo lo que en otros lenguajes se prueba con tests de humo. No tiene sentido probar que un campo existe ni que una función recibe el tipo correcto. Sí lo tiene, y mucho, probar tres cosas: las transiciones de update que codifican reglas de negocio, los decoders contra respuestas reales capturadas del servidor, y las funciones puras del dominio con pruebas basadas en propiedades, donde elm-test brilla porque la ausencia de efectos hace que generar mil casos aleatorios sea barato y fiable.

suite : Test
suite =
    describe "Informes"
        [ test "exportar solo se habilita con informe listo" <|
            \_ ->
                modeloBase
                    |> conInforme (Lista informeDeEjemplo)
                    |> puedeExportar
                    |> Expect.equal True
        , fuzz listaDeFilas "el total nunca depende del orden" <|
            \filas ->
                Expect.equal
                    (total filas)
                    (total (List.reverse filas))
        , test "el decoder acepta la respuesta real del API" <|
            \_ ->
                Json.Decode.decodeString decodificarInforme respuestaCapturada
                    |> Expect.ok
        ]

A la revisión automatizada le corresponde el resto. elm-review no comprueba tipos, comprueba disciplina: importaciones sin usar, funciones expuestas que nadie llama, Debug.log olvidado, patrones que en tu equipo se han decidido prohibir. Ejecutado en integración continua, sustituye a la mitad de los comentarios de revisión humana y deja la otra mitad para lo que de verdad requiere criterio. El despliegue, por su parte, es de una simplicidad casi anticlimática: elm make con la bandera de optimización produce un JavaScript sin código muerto y sin el runtime de depuración; pasarlo después por un minificador agresivo es seguro precisamente porque Elm garantiza que no hay reflexión ni acceso dinámico a nombres. El resultado es un fichero estático que se sirve desde cualquier CDN, sin servidor, sin proceso, y sin más requisito que redirigir todas las rutas al mismo index.html para que el enrutador del cliente reciba la URL.

El plano importa más que el lenguaje, porque lo que estás construyendo es una frontera y no una pantalla

Cuando lleves varias aplicaciones construidas con este orden verás emerger algo que ningún tutorial dice y que reorganiza la manera de pensar el oficio: el trabajo real de una aplicación de interfaz no es dibujar, es traducir. Entre el mundo exterior y el modelo de tu programa hay un desfiladero, y toda la dificultad genuina se concentra en él. El servidor evoluciona a su ritmo y con sus propias prioridades; el navegador expone un API imperativa y llena de estado global; el usuario introduce texto libre; el almacenamiento local devuelve lo que quedó de una versión anterior de tu propia aplicación. Cada una de esas fuentes habla un idioma que no es el tuyo y que puede cambiar sin avisarte. Un programa robusto es el que sitúa toda esa traducción en un anillo delgado y explícito, y mantiene el interior en un lenguaje que solo contiene conceptos del dominio. Elm hace visible ese anillo porque no te deja saltártelo: no puedes leer un JSON sin decodificarlo, no puedes tocar JavaScript sin cruzar un port, no puedes recibir una URL sin parsearla a un tipo. Lo que en otros ecosistemas es una buena práctica que se erosiona bajo presión de calendario, aquí es una condición de compilación. Y la consecuencia estratégica es que el interior de una aplicación Elm bien construida envejece de un modo distinto: cuando el API cambia, se toca el anillo; cuando el diseño cambia, se toca la vista; cuando la regla de negocio cambia, se toca update. Tres tipos de cambio, tres zonas disjuntas, ningún efecto colateral entre ellas. Esa disjunción no la da el lenguaje por sí solo. La da el orden en que decidiste, y por eso el plano se puede llevar contigo a cualquier sitio.

⚔️ Construye el plano antes de construir la aplicación
  1. Elige una aplicación pequeña pero real y escribe únicamente sus tipos de dominio, sin Model, sin Msg y sin vistas. Cuenta cuántos estados admite y elimina los que no correspondan a nada del mundo.
  2. Deriva el Model de esos tipos y comprueba campo a campo que ninguno sea calculable a partir de los demás. Si alguno lo es, muévelo a una función.
  3. Escribe Msg completo y léelo en voz alta como si fuera una lista de sucesos. Renombra todo constructor que hable del DOM en lugar del mundo.
  4. Define Route y su parser antes de escribir una sola vista, incluyendo el caso de ruta desconocida. Justifica por qué ese caso pertenece al tipo.
  5. Dibuja las tres puertas del programa y anota qué tipo cruza cada una en ambas direcciones. Marca cuál es hoy la más frágil y qué la haría fallar.
  6. Escribe tres pruebas: una de transición de negocio, una de decoder contra una respuesta real capturada y una de propiedad sobre una función pura. Después argumenta qué cuarta prueba serían sobra y por qué.