After completing the “Phoenix and Elm, a real use case” tutorial by Ricardo García Vega, I went back and refactored parts of the codebase in order to help me really understand it, and get everything straight in my head (I wrote about this in Migrating a Phoenix and Elm app from REST to GraphQL, and you can see the results in my repository versus the original).
Architecting in the Dark
I could not seem to find any generally-accepted ways to architect Elm code in the same way as I would architect Elixir/Phoenix or Ruby/Rails code, so I just let my instincts guide the code architecture direction, which currently lean towards small(er) modules and functions.
So, I thought it would be a good idea to break out code from big Elm files into individual smaller files located under conceptual concerns directories. For example, I extracted application code that looked like it was mostly concerned with a Contact
out into a structure like this:
Contact/
Commands.elm
Decoder.elm
Messages.elm
Model.elm
Update.elm
View.elm
Messages to update a Contact
would be wrapped in a top level Msg
type called ContactMsg
, and when that came through the main update
function, handling of the update would be immediately delegated out to Contact.Update
.
assets/elm/src/Update.elm
module Update exposing (update, ...)
import Messages exposing (Msg(ContactMsg, ...))
import Model exposing (Model, ...)
-- ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ContactMsg msg ->
Contact.Update.update msg model
-- ...
The nested message inside a ContactMsg
, is completely opaque to Update
, and it was only Contact.Update
that knew what should happen when, in this case, a FetchContact
message is received:
assets/elm/src/Contact/Update.elm
module Contact.Update exposing (update)
import Contact.Messages exposing (ContactMsg(FetchContact))
import Messages exposing (Msg)
import Model exposing (Model, RemoteData(Failure, Success))
update : ContactMsg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
FetchContact (Ok response) ->
( { model | contact = Success response }, Cmd.none )
FetchContact (Err error) ->
( { model | contact = Failure "Contact not found" }, Cmd.none )
This sort of thing seemed like a good pattern to me in the absence of any others, but there was no way I could really tell if it was actually any good, or if there would be any issues, performance or otherwise, related to coding Elm in this way.
Graphing Dependencies
Until, that is, I was introduced to elm-module-graph, which enables you to visually explore package and module dependencies in an Elm project. It helped me understand that:
- My code was not really properly separated out into the concerns I thought it was.
- I had the Elm equivalent of a God object-in-the-making: a module that too many other modules had knowledge about. These modules, given enough other modules that have it as a dependency, can lead to longer Elm compile times every time a change is made on them.
Create Graph File
Here is how I generated the needed module-graph.json
for the Address Book app (adapt the commands as necessary for your own project, and make sure you have Python installed):
cd assets/elm
wget https://raw.githubusercontent.com/justinmimbs/elm-module-graph/master/elm-module-graph.py
chmod 744 elm-module-graph.py
./elm-module-graph.py src/Main.elm
Display Graph
Next, navigate to https://justinmimbs.github.io/elm-module-graph/ and upload the generated module-graph.json
file, and you will see something like this:
The default graph display includes modules from external libraries, so let’s hide them (by toggling the display of the external packages at the top) so that we can focus on the application code:
Find Long Bars
If a module has a long bar with attached lines in the graph, that means that it is imported by many modules. Here, it is clear that the Messages
module, displayed right in the middle of the graph, has the longest bar, so let’s take a clearer look at its dependencies by clicking on it:
- The modules in red are the modules that
Messages
imports into itself. These are not the relationships we need to worry about. - The modules in blue are the modules that import
Messages
into themselves. Here, we can see that a great many “child” modules likeContact.Update
, have knowledge about “parent”Messages
, butContact.Update
should really only know about the type of message it deals with directly, theContactMsg
, and leave knowledge about (and handling of)Msg
type messages to theMessages
module.
We have some leaking of encapsulation within the concerns, so, let’s see what can be done to fix them, with the initial goal of not having any modules under the Contact
concern import the Messages
module. The starting point for this refactor will be the rest
branch of the Address App, so if you’re following along at home, clone the repo and let’s get refactoring!
If you get stuck while refactoring at any step of the way, have a look at the
rest-refactor
branch of the Address App for guidance.
Initial Preparation
Extract RemoteData into its own Module
Currently, the RemoteData
type is contained in the top-level Model
module. Since Contact
concern modules needs to know about RemoteData
, but not Model
(they should only need to know about Contact.Model
), let’s remove RemoteData
out from Model
and into its own top level module:
assets/elm/src/RemoteData.elm
module RemoteData exposing (RemoteData(..))
type RemoteData e a
= Failure e
| NotRequested
| Requesting
| Success a
The Elm compiler should let you know the modules in which you need to change RemoteData
references to this new one, but most of the edits will consist of changing references like:
import Model
exposing
( Model
, RemoteData(NotRequested, Requesting, Failure, Success)
)
to something like:
import Model exposing (Model)
import RemoteData
exposing
( RemoteData(NotRequested, Requesting, Failure, Success)
)
Create Routing Concern
In Contact.View
, there is the following line:
assets/elm/src/Contact/View.elm
import Messages exposing (Msg(NavigateTo))
NavigateTo
is a message that is sent in onClick
link attributes in HTML inside multiple view files. When this message is handled in Update
, it only runs a command to navigate to a new URL, and does not return a new model:
assets/elm/src/Update.elm
module Update exposing (update, ...)
import Navigation
import Routing exposing (Route(...))
-- ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
-- ...
NavigateTo route ->
( model, Navigation.newUrl (Routing.toPath route) )
-- ...
So, in order to de-couple Contact.View
from Messages
, it looks like a new extraction of a Routing
concern will be in order, so let’s start with defining messages and update handling for Routing
:
assets/elm/src/Routing/Messages.elm
module Routing.Messages exposing (RoutingMsg(..))
import Routing exposing (Route)
type RoutingMsg
= NavigateTo Route
assets/elm/src/Routing/Update.elm
module Routing.Update exposing (update)
import Navigation
import Routing
import Routing.Messages exposing (RoutingMsg(NavigateTo))
update : RoutingMsg -> Cmd msg
update msg =
case msg of
NavigateTo route ->
Navigation.newUrl (Routing.toPath route)
We will also need to allow for a RoutingMsg
to be handled by the top-level Messages
module, so let’s add that (while removing NavigateTo
from Messages
), and fix Update
so it can handle these new RoutingMsg
messages:
assets/elm/src/Messages.elm
module Messages exposing (Msg(..))
import Contact.Messages exposing (ContactMsg)
import ContactList.Messages exposing (ContactListMsg)
import Navigation
import Routing.Messages exposing (RoutingMsg)
type Msg
= ContactMsg ContactMsg
| ContactListMsg ContactListMsg
| RoutingMsg RoutingMsg
| UpdateSearchQuery String
| UrlChange Navigation.Location
assets/elm/src/Update.elm
module Update exposing (update, ...)
import Routing.Update
-- ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
-- ...
RoutingMsg msg ->
( model, Routing.Update.update msg )
-- ...
Use RoutingMsg in Contact Views
Once this is done, we will start focusing on Contact
-related modules. You will find that a number of different view files have been affected by the creation of the RoutingMsg
, so some references and type signatures will need to change.
The Elm compiler should let you know about which modules need to have their NavigateTo
references changed, but most of the edits will consist of changing references like:
import Messages exposing (Msg(NavigateTo))
to something like:
import Messages exposing (Msg(RoutingMsg))
or (depending on the file):
import Routing.Messages exposing (RoutingMsg(NavigateTo))
as well as changing function declarations to return a RoutingMsg
, rather than a Msg
:
assets/elm/src/Contact/View.elm
-- ...
import Routing.Messages exposing (RoutingMsg(NavigateTo))
-- ...
view : Model -> Html RoutingMsg
-- ...
showView : Contact -> ( String, Html RoutingMsg )
-- ...
-- etc etc change Msg to RoutingMsg for all the functions in this file ...
Again, the Elm compiler will guide you on where the references need to be changed.
Next, there will be some messages across multiple concerns that will need to be Html.map
ped into RoutingMsg
messages:
assets/elm/src/ContactList/View.elm
module ContactList.View exposing (view)
import Messages exposing (Msg(RoutingMsg, ...))
-- ...
contactsList : Model -> ContactList -> Html Msg
contactsList model page =
if page.totalEntries > 0 then
page.entries
|> List.map Contact.View.showView
|> Keyed.node "div" [ class "cards-wrapper" ]
|> Html.map RoutingMsg
else
-- ...
assets/elm/src/View.elm
module View exposing (view)
import Messages exposing (Msg(RoutingMsg))
-- ...
page : Model -> Html Msg
page model =
case model.route of
-- ...
ShowContactRoute id ->
model
|> Contact.View.view
|> Html.map RoutingMsg
NotFoundRoute ->
Shared.View.warningMessage
"fa fa-meh-o fa-stack-2x"
"Page not found"
(Html.map RoutingMsg Shared.View.backToHomeLink)
One final minor view-related change is in the signature for Shared.View.warningMessage
:
assets/elm/src/Shared/View.elm
warningMessage : String -> String -> Html msg -> Html msg
warningMessage iconClasses message content =
div [ class "warning" ]
[ span [ class "fa-stack" ]
[ i [ class iconClasses ] [] ]
, h4 []
[ text message ]
, content
]
The change is ever-so-subtle: Html Msg
to Html msg
. This function is used in both Contact
and ContactList
views, and the message wrapped inside the Html
could be a ContactMsg
or a Msg
type. Since the type of message is not consequential for the rendering of the warning message, we make the type signature ambivalent to the type of message provided and then returned back.
And that should take care of View-related code, so on to Contact.Update
!
Hide Model from Contact.Update
Currently, in Update
, we’re passing the whole Model
off to Contact.Update
when we receive a ContactMsg
, and Contact.Update.update
returns back a (Model, Cmd Msg)
.
However, Contact.Update
should only be concerned with returning a new Contact
: it does not need to know about the rest of the Model
. Also, Contact.Update
should only know how to return messages of its own type: ContactMsg
. So, we want:
-
Contact.Update.update
to return back a(Contact, Cmd ContactMsg)
- Have the
Update
module return a model with the newContact
- Have the
Update
module convert theContactMsg
into aMsg
.
assets/elm/src/Update.elm
module Update exposing (update, ...)
import Messages exposing (Msg(ContactMsg, ...))
import Model exposing (Model, ...)
-- ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ContactMsg msg ->
let
( contact, cmd ) =
Contact.Update.update msg
in
( { model | contact = contact }, Cmd.map ContactMsg cmd )
-- ...
Now that Contact.Update.update
doesn’t receive a Model
any more, let’s change it so that it returns the (Contact, Cmd ContactMsg)
that Update
now wants:
assets/elm/src/Contact/Update.elm
module Contact.Update exposing (update)
import Contact.Messages exposing (ContactMsg(FetchContact))
import Contact.Model exposing (Contact)
import RemoteData exposing (RemoteData, RemoteData(Failure, Success))
update : ContactMsg -> ( RemoteData String Contact, Cmd ContactMsg )
update msg =
case msg of
FetchContact (Ok response) ->
( Success response, Cmd.none )
FetchContact (Err error) ->
( Failure "Contact not found", Cmd.none )
Contact.Commands should return ContactMsgs
Currently, the Contact.Commands.fetchContact
function returns a top-level Cmd Msg
type. What we want to do is have it instead return a Cmd ContactMsg
, and have the caller of the function (in this case Update.urlUpdate
) be responsible for Cmd.map
ping the ContactMsg
to a Msg
.
So, this is what we currently have:
assets/elm/src/Contact/Commands.elm
module Contact.Commands exposing (fetchContact)
import Commands exposing (contactsApiUrl)
import Contact.Decoder as Decoder
import Contact.Messages exposing (ContactMsg(FetchContact))
import Http
import Messages exposing (Msg(ContactMsg))
fetchContact : Int -> Cmd Msg
fetchContact id =
let
apiUrl =
contactsApiUrl ++ "/" ++ toString id
in
Decoder.decoder
|> Http.get apiUrl
|> Http.send FetchContact
|> Cmd.map ContactMsg
assets/elm/src/Update.elm
module Update exposing (update, urlUpdate)
-- ...
urlUpdate : Model -> ( Model, Cmd Msg )
urlUpdate model =
case model.route of
-- ...
ShowContactRoute id ->
( { model | contact = Requesting }
, Contact.Commands.fetchContact id
)
-- ...
And this is what we need to change the files to:
assets/elm/src/Contact/Commands.elm
module Contact.Commands exposing (fetchContact)
import Commands exposing (contactsApiUrl)
import Contact.Decoder as Decoder
import Contact.Messages exposing (ContactMsg(FetchContact))
import Http
fetchContact : Int -> Cmd ContactMsg
fetchContact id =
let
apiUrl =
contactsApiUrl ++ "/" ++ toString id
in
Decoder.decoder
|> Http.get apiUrl
|> Http.send FetchContact
assets/elm/src/Update.elm
module Update exposing (update, urlUpdate)
-- ...
urlUpdate : Model -> ( Model, Cmd Msg )
urlUpdate model =
case model.route of
-- ...
ShowContactRoute id ->
( { model | contact = Requesting }
, id
|> Contact.Commands.fetchContact
|> Cmd.map ContactMsg
-- ...
Phew! We’ve made quite a lot of changes across multiple files, so let’s see if it paid off!
Having compilation problems? Compare what you’ve written with code in the
rest-refactor
branch of the Address App and see if they match up.
Re-generate the module-graph.json
file (./elm-module-graph.py src/Main.elm
), and re-upload it to https://justinmimbs.github.io/elm-module-graph/ and let’s see what the graph says:
Awesome! Modules in the Contact
concern now have no direct dependencies with the top level Messages
module!
I have attempted to take this even further by removing ContactList
dependencies in the Messages
module, and you can see the results of that in the rest-refactor
branch of the Address App if you are interested. Suffice to say, the graph now looks like:
Not bad! With further refactoring and re-architecting, maybe I could remove ContactList.View
from this list, but I’m done fighting with types for now
Conclusion
I found that using elm-module-graph was helpful in getting a high level overview of my Elm application, determining where potential compilation bottlenecks could appear, and deciding how to structure modules.
Is the way I architected and then refactored the application presented here a “good” way to do it? At this stage, I do not know. I am very happy to be shown to be wrong about this, but for now, this way of doing things (less massive files; more smaller functions in smaller modules under concern directories) feels right to me, especially since I do not know of any “official” guidance on this.
Regardless, on your next Elm project, give generating a graph for it a try for some easy-to-digest information about its dependencies!
Leave a comment