Well defined internal APIs for Model fields or when to use Opaque Types?

Hello, building an application, I’ve been having a hard time coming to terms to a defined way to write data modules. I’ve watched pretty much every talk from Evan and Richard Feldman and some other Elm developers and still haven’t decided on the proper way to do this. Let me explain.

For every field in the Model, which mirrors an entity from my application’s domain model, I create a module to represent and manipulate it. Let’s consider I’ve got a Person entity in my domain and my Model is meant to have a field in which a given collection (whatever data structure that may be) of Person. Right now, I’d do it like this:

module Model.Person exposing (Person, Id, Collection, ....)

type Id = Id Int

type Collection = Collection (Dict Int Person)

type alias Metadata = { id : Id, name : String, age : Int }

type Person = Person Metadata

-- Field extraction

getId : Person -> Id
getId ( Person { id } ) =
    id

getName : Person -> String
getName ( Person { name } ) =
    name

getAge : Person -> Int
getAge ( Person { age } ) =
    age

-- Collection operations

emptyCollection : Collection
emptyCollection = 
    Collection Dict.empty

collectionSize : Collection -> Int
collectionSize (Collection collection) =
    Dict.size collection

collectionToList : Collection -> List Person
collectionToList (Collection collection) =
    Dict.values collection

insert : Person -> Collection -> Collection
insert person (Collection collection) =
    Collection <| Dict.insert (getId person) person collection

... other similar operations

-- Other functions

isOfAge : Person -> Bool
isOfAge person =
    (getAge person) >= 18

... other similar functions

-- Decoders
idDecoder : Decode.Decoder Id
idDecoder =
    Decode.int
        |> Decode.andThen
            (\int -> Decode.succeed (Id id))

personDecoder : Decode.Decoder Person
personDecoder =
    Decode.succeed Metadata
        |> Pipeline.required "id" idDecoder
        |> Pipeline.required "name" Decode.string
        |> Pipeline.required "age" Decode.int

-- Private functions

...

and the Model would have something like this:

type alias Model = { person = Person.Collection }

I’ve found this to be a good pattern, which has the following advantages:

  • The APIs are well defined and the user is cannot mess with the data.
  • I like the Collection type quite a lot. It abstracts away which data structure is being used so we don’t need to pass them around function signatures, for example. Also gives good expressiveness since we read Person.Collection in the signatures which sounds nice to me.

However I’ve got a few questions:

  • Can this kind of Opaque Types for Person where we have to access everything through an API be cumbersome for users using this module, given its meant to be an internal API? Meaning it’s not an external library offered to users. Inside the project a developer has to constantly write stuff like (getAge person) instead of person.age.
  • Are the Collection and the “other functions” inside the same module a bad violation of the Single Responsibility Principle?
  • I enjoy the Collection abstraction but the functions like emptyCollection and collectionSize feel weird and poorly designed from an API standpoint. They stick out like something is wrong in the design, but I don’t know how I could make it better.

I think that the accessor functions (e.g. getAge) are useful when you:

  • You are building a library and you don’t want to expose the type
  • You want more type safety, e.g. your ID example
  • You expect the shape of your data to change a lot, so this accessor functions encapsulates the changes to this module

Otherwise, I don’t think you get a lot from having these, as you say using the data becomes a lot more work everywhere else.

2 Likes

I agree with @Sebastian that I feel the accessors are unnecessary. You shouldn’t make a type opaque if the user is expected to be able to read all the fields unless you absolutely have a reason that the user shouldn’t be able to create their own instances of the type (e.g. if different fields have some relationship between them that needs to be checked for consistency first).

I’d also point out that the way you have set things up in your example, you actually allow for impossible states. For example a bug could allow the creation of a Person with a given Id being entered into the Dict (in the Collection) with a different Id as it’s key. I’m not saying the code you present has that bug, just that it doesn’t make it impossible for that bug to be introduced at a later date. If the Id is to be used to identify the Person out of a Collection then I would just have the Id as the Dict key and not repeat it in the Person record so that such a bug would become impossible.

2 Likes

I saw a lot of functions like getId, getName, getAge… are defined for this kind of type opaque. Instead of define a lot of functions, can we define one function of

getPersonMetadata:Person ->Metadata
getPersonMetadata (Person metadata}=
metadata

then in other modules, do something like:

view:Person->Html Msg
view person=
let
personMetadata = getPersonMetadata person
in
div[]
[text personMetadata.id
, text personMetadata.name
…]

what is disadvantage of this way?

1 Like

I believe the only disadvantage would be that having to first fetch the “metadata” for a data object isn’t usually the common practice when accessing data, so maybe that would be a weird pattern to introduce. Otherwise I think your example could work.

in Richard Feldman’s elm-spa-example, each type has only few fields to access, so only some functions are defined, like your example, only getId, getName, get Age are defined. In some cases, a type like product can have more than 10 fields like id, name, description…, I am not sure whether we should define more than 10 functions to access each field, or like my example, define one function only.

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