Domo (Domo v1.2.8) View Source
Domo is a library to model a business domain with type-safe structs and composable tagged tuples.
The library aims for two goals:
to allow only valid states for domain entities modelled with structs by ensuring that their field values conforms the type definition
to separate struct type definition and field type constraints that can be reused among related contexts or applications f.e. in a micro-service setup
It's a library to declare what kind of values struct fields should have and to generate run-time struct construction and verification functions.
The new/1
constructor function generated by Domo ensures that the struct
field value matches Elixir data type or another struct according to the given
type definition. Then it ensures that the field's value is in the valid range
by calling the user-defined precondition function for the value's type.
The library is a practical tool for the compile-time check. It automatically
validates that the default values of the struct conform to its type.
And ensure that instances constructed with new/1
function to be
a macro argument match their types.
Rationale
One of the ways to validate that input data is of the model struct's type is to do it within a constructor function like the following:
defmodule User do
@type divisible_5_integer :: integer()
@type t :: %__MODULE__{
id: divisible_5_integer(),
name: String.t(),
post_address: :not_given | String.t()
}
@enforce_keys [:id, :name]
defstruct [:id, :name, post_address: :not_given]
def new(id: id, name: name, post_address: post_address)
when is_integer(id) and is_binary(name) and
(post_address == :not_given or is_binary(post_address)) do
if rem(id, 5) == 0 do
struct!(__MODULE__, id: id, name: name)
else
raise "User id expected to be divisible by 5"
end
end
end
The code of the constructor function new/1
written above ensures that
the structure instance would have the expected data. At the same time,
the equally looking code can repeat for almost every entity in the application,
especially when some of the field types are shared across various entities.
It'd be good to generate such constructor function new/1
automatically
in a declarative way reducing the structure definition length to a minimum.
One of the possible ways to do so is with Domo library as the following:
defmodule User do
use Domo
@type divisible_5_integer :: integer()
precond divisible_5_integer: &(rem(&1, 5) == 0)
@type t :: %__MODULE__{
id: divisible_5_integer(),
name: String.t(),
post_address: :not_given | String.t()
}
@enforce_keys [:id, :name]
defstruct [:id, :name, post_address: :not_given]
end
The struct and type definitions are already in the module.
What Domo adds on top are the constructor function new/1
,
the ensure_type!/1
function, and their new_ok/1
and ensure_type_ok/1
versions returning ok-error tuple.
These functions ensure that the given enumerable has keys matching the struct
fields and has the values of the appropriate field types. Additionally,
for the value of the divisible_5_integer
type, these functions check
if the precondition function defined via the precond
macro returns true.
If these conditions are not fulfilled, the Domo added functions are raising
the ArgumentError
exception or returning {:error, message}
respectively.
The construction with automatic type ensurance of the User
struct can
be as immediate as that:
User.new(id: 55, name: "John")
%User{id: 55, name: "John", post_address: :not_given}
User.new(id: 55, name: nil, post_address: 3)
** (ArgumentError) the following values should have types defined for fields of the User struct:
* Invalid value 3 for field :post_address of %User{}. Expected the value matching the :not_given | <<_::_*8>> type.
* Invalid value nil for field :name of %User{}. Expected the value matching the <<_::_*8>> type.
User.new(id: 54, name: "John")
** (ArgumentError) the following values should have types defined for fields of the User struct:
* Invalid value 54 for field :id of %User{}. Expected the value matching the integer() type. And a true value
from the precondition function "&(rem(&1, 5) == 0)" defined for User.divisible_5_integer() type.
After the modification of the existing struct its type can be ensured like the following:
user
|> struct!(name: "John Bongiovi")
|> User.ensure_type!()
%User{id: 55, name: "John Bongiovi", post_address: :not_given}
user
|> struct!(name: :john_bongiovi)
|> User.ensure_type!()
** (ArgumentError) the following values should have types defined for fields of the User struct:
* Invalid value :john_bongiovi for field :name of %User{}. Expected the value matching the <<_::_*8>> type.
Given validations can be especially effective if it is possible to reuse user-defined types and preconditions for one struct in related structs like a foreign key in database tables.
Domo enables this fully. That how it works for the value of
divisible_5_integer
type defined in the User
module mentioned above
and referenced from the Account
struct:
defmodule Account do
use Domo
@type t :: %__MODULE__{
id: integer(),
user_id: User.divisible_5_integer(),
ballance: integer()
}
@enforce_keys [:id, :user_id, :ballance]
defstruct @enforce_keys
end
Account.new(id: 1574, user_id: 55, ballance: 20087)
%Account{ballance: 20087, id: 1574, user_id: 55}
Account.new(id: 1574, user_id: 504, ballance: 20087)
** (ArgumentError) the following values should have types defined for fields of the Account struct:
* Invalid value 504 for field :user_id of %Account{}. Expected the value
matching the integer() type. And a true value from the precondition function
"&(rem(&1, 5) == 0)" defined for User.divisible_5_integer() type.
Furthermore, it's possible to extract the common type and its precondition to a module that can be shared across various applications using Domo, keeping the integrity of the types appropriately.
The code of User
and Account
modules mentioned above can be refactored
for that like the following:
defmodule Identifiers do
import Domo
@type user_id :: integer()
precond user_id: &(rem(&1, 5) == 0)
end
defmodule User do
use Domo
@type t :: %__MODULE__{
id: Identifiers.user_id(),
name: String.t(),
post_address: :not_given | String.t()
}
@enforce_keys [:id, :name]
defstruct [:id, :name, post_address: :not_given]
end
defmodule Account do
use Domo
@type t :: %__MODULE__{
id: integer(),
user_id: Identifiers.user_id(),
ballance: integer()
}
@enforce_keys [:id, :user_id, :ballance]
defstruct @enforce_keys
end
User.new(id: 54, name: "John")
** (ArgumentError) the following values should have types defined for fields of the User struct:
* Invalid value 54 for field :id of %User{}. Expected the value matching the integer() type. And a true value
from the precondition function "&(rem(&1, 5) == 0)" defined for Identifiers.user_id() type.
Account.new(id: 1574, user_id: 504, ballance: 20087)
** (ArgumentError) the following values should have types defined for fields of the Account struct:
* Invalid value 504 for field :user_id of %Account{}. Expected the value
matching the integer() type. And a true value from the precondition function
"&(rem(&1, 5) == 0)" defined for Identifiers.user_id() type.
Domo library plays nicely together with TypedStruct
on the module level. That's how the User
struct can be shortened even more,
having new/1
, ensure_type!/1
, new_ok/1
, and ensure_type_ok/1
functions
added automatically:
defmodule User do
use Domo
use TypedStruct
typedstruct do
field :id, Identifiers.user_id(), enforce: true
field :name, String.t(), enforce: true
field :post_address, :not_given | String.t(), default: :not_given
end
end
User.new_ok(id: 55, name: "John")
{:ok, %User{id: 55, name: "John", post_address: :not_given}}
User.new_ok(id: 54, name: "John")
{:error, [id: "Invalid value 54 for field :id of %User{}. \
Expected the value matching the integer() type. And a true value \
from the precondition function \"&(rem(&1, 5) == 0)\" defined \
for User.divisible_5_integer() type."]}
Domo library plays nicely with Echo.Changeset.
It exports validate_type/*
functions in Domo.Changeset
module
to automatically validate field changes to conform t()
type of the schema.
defmodule User do
use Ecto.Schema
use Domo
import Ecto.Changeset
import Domo.Changeset
schema "users" do
field :name
end
@type t :: %__MODULE__{
name :: String.t() | nil
}
def changeset(user, params \ %{}) do
user
|> cast(params, [:name])
|> validate_type()
end
end
How it works
For MyModule
struct using Domo, the library generates a MyModule.TypeEnsurer
module at the compile time. The latter verifies that the given fields matches the
type of MyModule
and is used by new/1
constructor and the other Domo
generated functions.
If the field is of the struct type that uses Domo as well, then the ensurance
of the field's value delegates to the TypeEnsurer
of that struct.
Suppose the user defines the precondition function with the precond/1
macro
for the type referenced in the struct using Domo. In that case,
the TypeEnsurer
module calls the user-defined function as the last
verification step.
Domo library uses :domo_compiler
to generate TypeEnsurer
modules code. See
the Setup section for the compilers configuration.
The generated code can be found in the _build/ENV/domo_generated_code
directory. That code is compiled automatically and is there only for
the reader's confidence. Domo cleans the directory on the following compilation,
keeping generated type ensurers code only for recently created
or changed structs.
Depending types tracking
Suppose the given structure field's type depends on a type defined in
another module. When the latter type or its precondition changes,
Domo recompiles the former module automatically to update its
TypeEnsurer
to keep type validation correct.
That works in the same way for any number of intermediate modules that can be between module defining struct and module defining type.
Setup
General setup
To use Domo in your project, add this to your mix.exs
dependencies:
{:domo, "~> 1.2.8"}
And the following line to the compilers:
compilers: Mix.compilers() ++ [:domo_compiler],
To avoid mix format
putting extra parentheses around macro calls,
you can add the following import to your .formatter.exs
:
[
import_deps: [:domo]
]
Setup for Phoenix hot reload
If you intend to call generated functions of structs using Domo from a Phoenix controller, add the following line to the endpoint's configuration in config.exs
file:
config :my_app, MyApp.Endpoint,
reloadable_compilers: [:phoenix] ++ Mix.compilers() ++ [:domo_compiler],
Otherwise type changes wouldn't be hot-reloaded by Phoenix.
Usage
Define a structure
To describe a structure with field value contracts, use Domo
, then define
your struct and its type.
defmodule Wonder do
use Domo
@typedoc "A world wonder. Ancient or contemporary."
@enforce_keys [:id]
defstruct [:id, :name]
@type t :: %__MODULE__{id: integer, name: nil | String.t()}
end
The generated structure has new/1
, ensure_type!/1
functions
and their non raising new_ok/1
and ensure_type_ok/1
versions
defined automatically.
Use these functions to create a new struct's instance and update an existing one.
%{id: 123556}
|> Wonder.new()
|> struct!(name: "Eiffel tower")
|> Wonder.ensure_type!()
%Wonder{id: 123556, name: "Eiffel tower"}
At the run-time, each function checks the values passed in against
the fields types defined within the t()
type. In case of mismatch, the functions
raise an ArgumentError
or return {:error, _}
tuple appropriately.
Define preconditions for the structure fields values
To automatically verify ranges of values for the whole struct or a concrete
field's type, define a precondition function with the precond/1
macro
for that type.
defmodule Invoice do
use Domo
@enforce_keys [:id, :subtotal, :tax, :total]
defstruct @enforce_keys
@type id :: String.t()
precond id: &match?(<<"INV", _, _ :: binary>>, &1)
@type t :: %__MODULE__{
id: id(),
subtotal: integer(),
tax: integer(),
total: integer()
}
precond t: &(&1.subtotal + &1.tax == &1.total)
end
A precondition for a field's type generates code that runs faster. In
the example above defining the precondition for the id
type can be more
performant then making the same check with a precondition for the whole t
type.
The new/1
, ensure_type!/1
, and other Domo generated functions call
precondition function to consider if the value is correct after ensuring
its type. They return an error message if the precondition function
returns false for the given value.
You can reuse user-defined type and its precondition by extracting them to a shared kernel module like the following:
defmodule SharedKernel do
import Domo
@type invoice_id :: String.t()
precond invoice_id: &match?(<<"INV", _, _ :: binary>>, &1)
end
defmodule Invoice do
use Domo
@enforce_keys [:id, :subtotal, :tax, :total]
defstruct @enforce_keys
@type t :: %__MODULE__{
id: SharedKernel.invoice_id(),
subtotal: integer(),
tax: integer(),
total: integer()
}
precond t: &(&1.subtotal + &1.tax == &1.total)
end
Now it's possible to reuse the SharedKernel
model across related applications
for value types consistency.
Define a tag to enrich the field's type
Tags can be used in sum types of your domain model to handle use-cases of the business logic effectively.
To define a tag define a module and its type t()
as a tagged tuple.
That is a tuple of the module name and the associated value.
import Domo.TaggedTuple
defmodule Height do
defmodule Meters, do: @type t :: {__MODULE__, float()}
defmodule Foots, do: @type t :: {__MODULE__, float()}
@type t :: {__MODULE__, Meters.t() | Foots.t()}
end
It's possible add a tag or a tag chain to the associated value. The tag chain attached to the value is a series of nested tagged tuples where the value is in the core:
alias Height.{Meters, Foots}
m = {Height, {Meters, 324.0}}
f = {Height, {Foots, 1062.992}}
To extract the value use pattern matching.
{Height, {Meters, 324.0}} == m
def to_string({Height, {Meters, val}}), do: to_string(val) <> " m"
def to_string({Height, {Foots, val}}), do: to_string(val) <> " ft"
Combine struct, tags, and ---/2
operator
The tag's type can be used to define the summary type for the field:
defmodule Wonder do
use Domo
@typedoc "A world wonder. Ancient or contemporary."
@enforce_keys [:id, :height]
defstruct [:id, name: "", :height]
@type t :: %__MODULE__{id: integer, name: String.t(), height: Height.t()}
end
To remove extra brackets from the tag chain definition or within the pattern matching
it's possible to use the ---/2
operator like the following:
import Domo.TaggedTuple
alias Height.Meters
wonder = Wonder.new(id: 145, name: "Eiffel tower", height: Height --- Meters --- 324.0)
%Wonder{height: {Height, {Height.Meters, 324.0}}, id: 145, name: "Eiffel tower"}
Height --- Meters --- height_m = wonder.height
{Height, {Meters, 324.0}}
Map syntax
It's still possible to modify a struct directly with %{... | s }
map syntax
and other standard functions like put_in/3
skipping the verification.
Please, use the ensure_type/1
struct's function to validate the struct's
data after such modifications.
Pipeland
To add a tag or a tag chain to a value in a pipe use tag/2
macro
and to remove use untag!/2
macro appropriately.
For instance:
use Domo.TaggedTuple
alias Order.Id
identifier
|> untag!(Id)
|> String.graphemes()
|> Enum.intersperse("_")
|> Enum.join()
|> tag(Id)
Usage with Ecto
Ecto schema can have t()
type defined and Domo library can validate
field changes conform to the defined type with validate_type/*
functions
for the given changeset.
See Domo.Changeset
module documentation for example.
Options
The following options can be passed with use Domo, [...]
unexpected_type_error_as_warning
- if set to true, prints warning instead of raising an exception for field type mismatch in autogenerated functionsnew/1
andensure_type!/1
. Default is false.name_of_new_function
- the name of the autogenerated constructor function added to the module using Domo. The ok function name is generated automatically from the given one by omitting trailing!
if any, and appending_ok
. Defaults arenew
andnew_ok
appropriately.remote_types_as_any
- keyword list of types by modules that should be treated as any(). Value exampleExternalModule: [:t, :name], OtherModule: :t
Default is nil.
To set option globally add lines into the config.exs
file like the following:
config :domo, :unexpected_type_error_as_warning, true
config :domo, :name_of_new_function, :new!
Limitations
The recursive types like @type t :: :end | {integer, t()}
are not supported.
Because of that Macro.t()
is not supported.
Parametrized types are not supported. Library returns {:type_not_found, :key}
error for @type dict(key, value) :: [{key, value}]
type definition.
MapSet.t(value)
just checks that the struct is of MapSet
. Precondition
can be used to verify set values.
Only 4096 combinations of | type are supported. If an ExternalStruct
has
the t()
type giving more then 4096 combinations, you can use
remote_types_as_any: [ExternalStruct: :t]
option to treat it as any()
,
wrap the type to @type user_type :: ExternalStruct.t()
and use precond user_type: ...
macro to verify the type's value.
Domo doesn't check struct fields default value explicitly; instead, it fails when one creates a struct with wrong defaults.
Generated submodule with TypedStruct's :module
option is not supported.
Migration
To complete the migration to a new version of Domo, please, clean and recompile
the project with mix clean --deps && mix compile
command.
Adoption
It's possible to adopt Domo library in the project having user-defined constructor functions as the following:
- Add
:domo
dependency to the project, configure compilers as described in the setup section - Set the name of the Domo generated constructor function by adding
config :domo, :name_of_new_function, :constructor_name
option into theconfix.exs
file, to prevent conflict with original constructor function names if any - Add
use Domo
to existing struct - Change the calls to build the struct for Domo generated constructor function with name set on step 3 and remove original constructor function
- Repeat for each struct in the project
Link to this section Summary
Functions
Macro to define a range precondition function for the type. The function is called to ensure that the struct field's value range is valid after ensuring its type just before returning the struct's instance.
Link to this section Functions
Macro to define a range precondition function for the type. The function is called to ensure that the struct field's value range is valid after ensuring its type just before returning the struct's instance.
Precondition function should return one of the following values:
true | false | :ok | {:error, any()}
. For false return value library
will generate the error message automatically. And for {:error, message}
tuple
the message
will be passed through.
To get the message
value in the form of {:error, field: message}
use the
new_ok/1
or ensure_type/1
.
When using as:
precond identifier: &match?(<<"AXX-", _, _ :: binary>>, &1)
the macro adds the following function to the module:
def __precond__(:"identifier", value) do
apply(&match?(<<"AXX-", _, _ :: binary>>, &1), [value])
end
Another variant can be:
precond identifier: &(if match?(<<"AXX-", _, _ :: binary>>, &1), do: :ok, else: {:error, "identifier should be like AXX-yyyyyyy"})