A demo of Qoda and an explanation of how we use ports

Qoda is a marketplace exchange for loans, currently built on Moonbeam, where you can borrow and lend on your own terms.

The UI is built with Elm and it communicates with the blockchain via ports.

Here’s a demo of the DApp in action:

The main thing I wanted to talk more about was our use of ports.

How we use ports

The blockchain is an external service we access using the ethers.js library. If you need to sign requests, for e.g. when depositing collateral, then we also interact with the MetaMask wallet. Overall, we currently have 127 possible requests that can be sent from Elm to JavaScript and 34 possible responses that can be sent from JavaScript to Elm. A naive use of ports requires 161 total ports. However, by heeding the advice Evan shares in the Elm Guide (the relevant part is quoted below) we actually only use two ports.

Definitely do not try to make a port for every JS function you need. You may really like Elm and want to do everything in Elm no matter the cost, but ports are not designed for that. Instead, focus on questions like “who owns the state?” and use one or two ports to send messages back and forth. If you are in a complex scenario, you can even simulate Msg values by sending JS like { tag: "active-users-changed", list: ... } where you have a tag for all the variants of information you might send across.

Here’s how we do it.

Port.elm

port module Port exposing (sendMessage, receive)

import Json.Encode as JE
import Message exposing (Message)

sendMessage : Message -> Cmd msg
sendMessage =
    Message.encode >> send

port send : JE.Value -> Cmd msg
port receive : (JE.Value -> msg) -> Sub msg

Message.elm

module Message exposing (Message, empty, string, object, encode)

import Json.Encode as JE

type Message
    = Message
        { tag : String
        , value : JE.Value
        }

empty : String -> Message
empty tag =
    Message
        { tag = tag
        , value = JE.null
        }

string : String -> String -> Message
string tag s =
    Message
        { tag = tag
        , value = JE.string s
        }

object : String -> List ( String, JE.Value ) -> Message
object tag fields =
    Message
        { tag = tag
        , value = JE.object fields
        }

encode : Message -> JE.Value
encode (Message { tag, value }) =
    JE.object
        [ ( "tag", JE.string tag )
        , ( "value", value )
        ]

So to deposit collateral at some point we need to create and send the following:

depositCollateralMessage :
    { accountAddress : Address
    , assetAddress : Address
    , amount : Amount
    , wrapInMoonwell : Bool
    }
    -> Message
depositCollateralMessage { accountAddress, assetAddress, amount, wrapInMoonwell } =
    Message.object
        "action.depositCollateral"
        [ ( "accountAddress", Address.encode accountAddress )
        , ( "assetAddress", Address.encode assetAddress )
        , ( "amount", Amount.encode amount )
        , ( "wrapInMoonwell", JE.bool wrapInMoonwell )
        ]

-- ...

Port.sendMessage <| depositCollateralMessage { ... }

Key takeaways

  1. You only need one port module, Port.elm.
  2. No application ever needs more than 2 ports.
  3. Ports work great and there’s no need to think it’s a subpar solution to JavaScript interoperability.
  4. Elm is a capable technology that can be used as it exists today. We haven’t experienced any roadblocks using it.
17 Likes

Nice concrete use case for the 2 ports strategy. I also like to have a single Port.elm to have one clear location to watch for these messages going back and forth. I usually also have a single ports.js file mirroring the elm one.

3 Likes

Thanks, this a really nice write up and this approach makes sense, especially on projects with lots of ports. However, while I know a lot of people recommend this approach (for example, I think @RyanNHG used this approach in the basic template of elm-spa), I have to admit that I generally don’t use it. The reason I don’t is that it requires additional complexity on the JS side of things, which I avoid like the plague, whereas one port to one JS function is easier to reason about for a dullard like me!

Agreed, we’ve never had roadblocks related to this kind of thing either, I always struggle to see why some people seem to dislike ports.

3 Likes

I tend to use this as one per module rather than one per app. A simple module like a basic localstorage might only need one or two direct ports, whereas a complicated interface like Leaflet maps has a two port interface with message encoding. When I have reusable modules, I copy the Elm port file and the matching JS block or file.

1 Like

With the one per app approach you can have one function in JavaScript where dispatch can occur. You discriminate based on the tag and dispatch to handler functions. The handler functions can be implemented in separate modules.

So let’s use your examples of localStorage and Leaflet.

In Elm

module LocalStorage exposing (saveMessage)
module Leaflet exposing (message1, message2, ..., messageN)

In JS

You’re still able to separate your handlers.

// localStorage.js

handleSaveMessage(...) { ... }
// leaflet.js

handleMessage1(...) { ... }
handleMessage2(...) { ... }
...
handleMessageN(...) { ... }

It’s just in here you need to discriminate and do the dispatch.

// dispatcher.js

function dispatch(message) {
  if (message is saveMessage) { handleSaveMessage(...); }
  else if (message is message1) { handleMessage1(...); }
  else if (message is message2) { handleMessage2(...); }
  ...
  else if (message is messageN) { handleMessageN(...); }
}
// or you can install the handlers into a dictionary and look it up here.
// index.js

// Somewhere in this file you'd have to do this.
app.ports.send.subscribe(dispatch);

The main point I wanted to illustrate is that only the dispatch function needs to be changed. You can still easily share (LocalStorage.elm, localStorage.js) and (Leaflet.elm, leaflet.js) between projects.

All that said, obviously you should continue using whatever approach works best for your situation. Thanks for sharing the idea though.

2 Likes

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.