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?

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:

Where Next?

Popular in Questions Top

chokchit
** (DBConnection.ConnectionError) connection not available and request was dropped from queue after 2733ms. You can configure how long re...
New
aadeshere1
I have a another noob question about loop. Since elixir is immutable, while loop is not directly possible. total = 10 while total != 0 ...
New
gshaw
What is the idiomatic way of matching for not nil in Elixir? E.g., First way: defp halt_if_not_signed_in(conn, signed_in_account) when...
New
JeremM34
Hello, how can I check the Phoenix version ? Thanks !
New
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
fireproofsocks
Forgive me if this is obvious, but how does one delete a database record WITHOUT selecting it first? Ecto.Repo — Ecto v3.14.0 has exampl...
New
jay1
Why is it that the mnesia database isn’t the most preferred database for use in Elixir/Phoenix?
New
freewebwithme
Using vs code and installed ElixirLS: support and debugger. And I got an error popped up on start up says Failed to run ‘elixir’ comma...
New
rms.mrcs
Hi, I need to transform a list of numbers into a map where the keys are the indexes and the values are the original values of the list. ...
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New

Other popular topics Top

danschultzer
None of the current solutions worked well for me, so I went ahead and built a user management system from scratch. This project took far...
548 29377 241
New
shahryarjb
Hello, I have map which I want to convert it to string like this: the map: %{last_name: "tavakkoli", name: "shahryar"} the string I ne...
New
JorisKok
I have a server on AWS, and was running a load test using artillery. When looking at the Phoenix dashboard I see the Ports going to 100% ...
New
dokuzbir
I want to highlight html closing tags when i click a html tag. That works in .html files but doesnt work for html.eex templates. How can...
New
vrod
I am using the Starship cross-shell prompt – it seems pretty nice, but I get some errors: [WARN] - (starship::utils): Executing command ...
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
Qqwy
Original source of discussion: This topic on the Pragmatic Programmers’ Functional Web Development with Elixir, OTP, and Phoenix forum. ...
New
saif
Hello everyone, Long time lurker first time poster here. I’ve recently begun working on Elixir full-time again! :raised_hands: It’s been...
New
KronicDeth
Elixir plugin for JetBrain’s IntelliJ Platform (including Rubymine) This is a plugin that adds support for Elixir to JetBrains IntelliJ...
289 36128 110
New
AstonJ
We’ve put together this wiki for Phoenix LiveView - please feel free to add any info you feel is worth including. What is Phoenix LiveV...
New

We're in Beta

About us Mission Statement