tomekowal

tomekowal

Modeling domain with types in Elixir

I am reading “Domain modelling made functional” and many ideas resonate with me. The examples are in F♯ but some ideas are general and transferrable to all languages.

One idea is that we should write types like UnverifiedEmail and VerifiedEmail and then

  @type unvalidated_email() :: String.t()
  @type validated_email() :: String.t()

  @spec validate_email(unvalidated_email()) :: validated_email()
  def validate_email(u), do: u

  @spec an_email() :: unvalidated_email()
  def an_email(), do: "example@gmail.com"

  @spec send_message(validated_email()) :: :ok
  def send_message(_e), do: :ok

  @spec run() :: :ok
  def run() do
    send_message(an_email())
    :ok
  end

In F♯ code like that would fail to compile because send_message tries to use unverified email. Dialyzer success typing passes so I can’t enforce it with Dialyzer.

Another approach would be to use an %UnverifiedEmail{} struct and %VerifiedEmail{} structs all over the code.

That would require creating a lot of small (one field) structs. My questions are: have anyone tried modelling the domain with multiple structs? How much using a lot of structs affected compilation time?

First Post!

peerreynders

peerreynders

Probably too naive an approach for your purpose - but here it is.

Most Liked

zkessin

zkessin

The problem is that both types are defined as String.t(), what you want to do is define the types in some way that they can not mix. such as {:validated_email, String.t()} and {:unvalidated_email, String.t()} then dialyzer (and your pattern matching) can validate things.

@type validated_email() :: {:validated_email, String.t()}
@type unvalidated_email() :: {:unvalidated_email, String.t()}
@spec validate_email(unvalidated_email()) :: validated_email()
def validate_email({:unvalidated_email, u}), do: {:validated_email, u}

In general, in terms of types, a String is a String. so using an atom in a tuple or other data structure allows dialyzer to catch this.

This video goes over this in more details

Moxide

Moxide

Hello, you are not alone to “resonate” with the ideas presented in this great book :wink:
When I tried to reproduce in Elixir some on the examples given, I used the “all is struct” approach and the typed_struct library.
Then played with VSCode and dialyzer (using the dialyzer underspecs and overspecs options).

Here is the code (just a POC ! ;-)) :

defmodule OrderTakingTypes do
  defmodule OrderLines do
    use TypedStruct

    typedstruct do
      field(:lines, list(OrderLine.t()), default: [])
    end
  end

  defmodule OrderLine do
    use TypedStruct

    typedstruct do
      field(:product_code, String.t(), enforce: true)
      field(:quantity, number(), default: 0)
      field(:price, number(), default: 0)
    end
  end

  defmodule ShippingAddress do
    use TypedStruct

    typedstruct enforce: true do
      field(:town, String.t())
      field(:zip_code, String.t())
    end
  end

  defmodule Order do
    use TypedStruct

    typedstruct enforce: true do
      field(:shipping_address, ShippingAddress.t())
      field(:order_lines, list(OrderLine.t()))
    end
  end

  defmodule ValidatedOrder do
    use TypedStruct

    typedstruct enforce: true do
      field(:order, Order.t())
      field(:validation_date, DateTime.t())
    end
  end
end
defmodule TypeDemo do
  alias OrderTakingTypes.{OrderLines, OrderLine, ShippingAddress, Order, ValidatedOrder}

  def send() do
    %OrderLines{
      lines: [
        %OrderLine{price: 10, product_code: "SKU-0001", quantity: 5}
      ]
    }
    |> send_to(%ShippingAddress{zip_code: "69000", town: "Lyon"})
  end

  @spec send_to(OrderLines.t(), ShippingAddress.t()) :: :ok
  def send_to(%OrderLines{lines: order_lines}, %ShippingAddress{} = address) do
    %Order{
      shipping_address: address,
      order_lines: order_lines
    }
    |> validate_order()
    |> process_order()
  end

  @spec validate_order(Order.t()) :: ValidatedOrder.t() | ErrorResponse.t()
  def validate_order(%Order{order_lines: order_lines} = order) when length(order_lines) > 0 do
    %ValidatedOrder{
      order: order,
      validation_date: DateTime.utc_now()
    }
  end
  def validate_order(%Order{order_lines: order_lines}) when length(order_lines) == 0,
    do: %ErrorResponse{code: 500, message: "OrderLines cannot be empty"}
  def validate_order(_), do: %ErrorResponse{code: 500, message: "Unknown error"}

  @spec process_order(ValidatedOrder.t() | ErrorResponse.t()) :: :ok
  def process_order(%ValidatedOrder{
        order: %Order{shipping_address: %ShippingAddress{town: town, zip_code: zip_code}}
      }),
      do: IO.puts("Processed shipping to #{zip_code}, #{town}")
  def process_order(%ErrorResponse{code: code, message: message}),
    do: IO.puts("Error occured with code '#{code}' and message '#{message}'")
  def process_order(_), do: IO.puts("Weird error")
end

Depending of the type of error you introduce (missing return type in @spec for instance) and on the Dialyzer options, it will be too verbose or too silent.

tomekowal

tomekowal

Yes! I’ve learned that Dialyzer has @opaque directive.

If you mark a type as @opaque, you can use it inside the module it is defined. Outside the module, you can pass the opaque type around, but you can’t access its fields. This directive renders @imetallica solution great! E.g.

defmodule Email do
  defstcut :email
  @opaque t() :: %__MODULE__{email: string()}

  @spec validate(String.t()) :: t()
  def validate(string_email) do
    #... validations
    # if this is the only place you return that struct, dialyzer will make sure nobody else creates or modifies the struct
    %__MODULE__{email: string_email)
  end

  @spec send(t()) :: :ok | :error
  def send(%__MODULE__{email: string_email}) do
    #access to the field is OK because you can use @opaque type fields in the same module
    :ok
  end
end

def OtherModule do
  def test() do
    valid_email = Email.validate("email@example.com")
    %Email{email | email: "something_broken"} #dialyzer will complain because the type is opaque
  end
end

Combined with other approaches from this talk: https://www.youtube.com/watch?v=XGeK9q6yjsg
it gives me a pretty nice domain-driven design :slight_smile:

Last Post!

IvanR

IvanR

Hey folks, I was inspired by Scott Wlaschin’s domain model examples from his book Domain Modeling Made Functional. That was for F#, and I made a library that allows us to express the same examples in Elixir.

Please, have a look at Domo User defined tags and type-safe structs for domain modelling (Domo library)

Where Next?

Popular in Questions Top

minhajuddin
I have seen a lot of code which picks the first element from a list using Enum.at(0) instead of List.first. Is there a reason why people ...
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod – where is this set? Thanks.
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
stefanluptak
Hello everybody, usually, I use a 29" ultra-wide monitor for VSCode which can easily accomodate explorer (files panel) + file with code ...
New
WestKeys
Currently suffering from paralysis by [HTTP client] analysis. This is rather unusual in Elixirland as there tends to be consensus on the ...
New
dblack
I’ve got an issue with an app and I’ve no idea of how to troubleshoot it. I’m hoping someone here might have seen something similar. I p...
New

Other popular topics Top

hariharasudhan94
Lets say I have map like this fetching from my database %{"_id" => #BSON.ObjectId<58eb1a7a9ad169198c3dXXXX>, "email" => ...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
Patoshizzle
After calling mix ecto.create I get this error: 17:00:32.162 [error] GenServer #PID<0.412.0> terminating ** (Postgrex.Error) FATAL...
New
sergio
Kind of like when jquery came out, it was super necessary. Existing drag and drop libraries have a bunch of baggage to support old browse...
New
AstonJ
Posting this to see if we can make things easier for people to get into Neovim. If you use Neovim and have a favourite distro please let ...
New

We're in Beta

About us Mission Statement