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
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
Hello, you are not alone to “resonate” with the ideas presented in this great book ![]()
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
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 ![]()
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance








