Domo

repository·master·Indexed 42 days ago

https://github.com/ivanrublev/domo

An Elixir library for validating nested structs against their type specifications (t()) and custom business logic preconditions. It provides automated deep validation, descriptive error reporting, and integration with Ecto, TypedStruct, and TypedEctoSchema to maintain domain model consistency.

Tokens
6.9K
Snippets
24
Records
33
Agent score
53%

What's inside Domo

  1. What is Domo?

    master

    Domo is a library for validating the values of nested Elixir structs against their type specification t() and associated precondition functions.

    It ensures that:

    1. A struct (or a group of nested structs) conforms to their defined t() types.
    2. The struct's data follows business rules defined via precond macros.

    In Domain Driven Design (DDD) terms, Domo uses types and preconditions to define the invariants relating structs to each other.

  2. Use Domo in a module

    master

    By adding use Domo to a module, Domo injects several functions and reflection capabilities into your struct module:

    • new/1 and new!/1: Create a new, validated struct. new/1 returns {:ok, struct} or {:error, errors}, while new!/1 raises on error.
    • ensure_type/1 and ensure_type!/1: Validate an existing struct instance.
    • Reflection functions: required_fields/0, typed_fields/0, and __t__() to inspect the struct's type requirements.
  3. Domo Limitations

    master

    Be aware of the following unsupported type patterns in Domo:

    • Parametrized types: Not supported (e.g., container(integer())). Using these will return {:type_not_found, :key}.
    • Self-referencing primitive types: Not supported (e.g., @type leaf :: leaf | nil).
    • Self-referencing structs: Supported (e.g., @type t :: %__MODULE__{leaf: t | nil}).
  4. Use Domo to validate structs with `new!/1` and `ensure_type!/1/2`

    master

    Domo can be applied to any struct defined with defstruct/1 and a t() type. When a module uses use Domo, it automatically gains several functions for instantiation and validation:

    • new!/1: Creates a new instance of the struct. The bang version (!) will raise an error if validation fails.
    • new/1: The non-bang version, returning {:ok, struct} or {:error, errors}.
    • ensure_type!/1/2: Validates that the provided data matches the expected t() type definition and associated preconditions. The bang version raises an error on failure.
    • ensure_type/1/2: The non-bang version, returning {:ok, struct} or {:error, errors}.

    ensure_type/2 also accepts an option maybe_filter_precond_errors: true to handle precondition errors.

    # Example: Creating and validating a Passenger
    alias ExampleAvialia.Boardings.Passenger
    
    # Create a new passenger
    p = Passenger.new!(flight: "ALA-1215", first_name: "John", last_name: "Smith", seat: "5C")
    
    # Validate type consistency (will raise error if flight is invalid)
    Passenger.ensure_type!(%{p | flight: "invalid"})
    
    # Example: Creating and validating a Shipment
    alias ExampleAvialia.Cargos.Shipment
    
    {:ok, s} = Shipment.new(
      flight: "ALA-1215", 
      kind: {:passenger_baggage, "5C"}, 
      weight: {:kilograms, 29}, 
      documents_count: 0, 
      documents: []
    )
    
    # Validate with precondition error filtering
    Shipment.ensure_type(
      %{s | kind: {:passenger_baggage, "invalid"}}, 
      maybe_filter_precond_errors: true
    )
  5. Install Domo

    master

    To use Domo in your project, follow these steps to configure your mix.exs and .formatter.exs files.

    1. Add Dependencies

    Add domo to your mix.exs dependencies:

    {:domo, "~> 1.5"}

    2. Configure Compilers

    Add the domo_compiler to your mix.exs compilers list:

    compilers: [:domo_compiler] ++ Mix.compilers()

    3. Configure Formatter

    To prevent mix format from adding unnecessary parentheses around precond/1 macro calls, add domo to your import_deps in .formatter.exs:

    [
      import_deps: [:domo]
    ]

    4. (Optional) Exclude TypeEnsurer from Test Coverage

    If you use mix test --coverage and want to ignore the generated TypeEnsurer modules, add this to mix.exs (requires Elixir v1.13+):

    test_coverage: [ignore_modules: [~r/\.TypeEnsurer$/]]
  6. Define types and preconditions with `precond`

    master

    To enforce business rules, use the precond macro. Preconditions can be attached to specific fields or to the struct's type t() as a whole.

    Field-level preconditions

    Attach a precondition to a specific field type to ensure values meet certain criteria (e.g., range checks).

    Type-level (invariant) preconditions

    Attach a precondition to the t type to validate invariants involving multiple fields or nested structs.

    Example:

    defmodule PurchaseOrder do
      use Domo
    
      defstruct id: 1000, approved_limit: 200, items: []
    
      @type id :: non_neg_integer()
      precond(id: &(1000 <= &1 and &1 <= 5000))
    
      @type t :: %__MODULE__{
              id: id(),
              approved_limit: pos_integer(),
              items: [LineItem.t()]
            }
      precond(t: &validate_invariants/1)
    
      defp validate_invariants(po) do
        amounts = po.items |> Enum.map(& &1.amount) |> Enum.sum()
        if amounts <= po.approved_limit do
          :ok
        else
          {:error, "Sum of line item amounts (#{amounts}) should be <= to approved limit (#{po.approved_limit})."}
        end
      end
    end
    defmodule LineItem do
      use Domo
    
      defstruct amount: 0
    
      @type t :: %__MODULE__{amount: non_neg_integer()}
    end
    
    defmodule PurchaseOrder do
      use Domo
    
      defstruct id: 1000, approved_limit: 200, items: []
    
      @type id :: non_neg_integer()
      precond(id: &(1000 <= &1 and &1 <= 5000))
    
      @type t :: %__MODULE__{
              id: id(),
              approved_limit: pos_integer(),
              items: [LineItem.t()]
            }
      precond(t: &validate_invariants/1)
    
      defp validate_invariants(po) do
        amounts = po.items |> Enum.map(& &1.amount) |> Enum.sum()
    
        if amounts <= po.approved_limit do
          :ok
        else
          {:error, "Sum of line item amounts (#{amounts}) should be <= to approved limit (#{po.approved_limit})."}
        end
      end
    end
  7. Configure Phoenix hot-reload with Domo

    master

    To enable support for Phoenix hot-reloading when using Domo, you must update your project configuration to include the necessary compilers.

    1. In your mix.exs file, add :domo_phoenix_hot_reload to the compilers list immediately after :elixir.
    2. In your configuration file (e.g., config.exs), add :domo_phoenix_hot_reload to the reloadable_compilers list.
    # mix.exs
    compilers: [:elixir, :domo_phoenix_hot_reload]
    
    # config.exs
    reloadable_compilers: [:elixir, :domo_phoenix_hot_reload]
  8. Run the ExampleAvialia application

    master

    The ExampleAvialia project demonstrates how to maintain domain model consistency using Domo within a Phoenix application. It features two contexts—board passengers and cargo shipments—backed by separate Ecto repositories to simulate a microservices architecture.

    To run the full application, follow these steps:

    1. Start PostgreSQL: Use Docker to run a local PostgreSQL instance.
    2. Setup Elixir dependencies: Install project dependencies and migrate the database.
    3. Setup Assets: Install Node.js dependencies in the assets directory.
    4. Start Server: Launch the Phoenix server and visit http://localhost:4000.
    # 1. Start PostgreSQL
    docker run --name postgres-domo \
      -e POSTGRES_USER=postgres \
      -e POSTGRES_PASSWORD=postgres \
      -p 5432:5432 \
      -d postgres:latest
    
    # 2. Setup Elixir/Phoenix
    mix deps.get
    mix ecto.setup
    
    # 3. Setup Assets
    cd assets && npm install && cd ..
    
    # 4. Start Server
    mix phx.server
  9. Filter precondition errors for users

    master

    By default, Domo returns verbose, nested error structures intended for debugging. To provide a cleaner, flattened list of error messages to end-users, use the maybe_filter_precond_errors: true option in constructor or validation functions.

    This will only return the string messages provided in your precond functions, stripping away the structural debugging metadata.

    # Example usage with filtering
    PublicLibrary.new(
      %{shelves: [...]}, 
      maybe_filter_precond_errors: true
    )
    # Returns: {:error, [shelves: ["Book title is required.", "..."]]}
    PublicLibrary.new(
      %{shelves: [struct!(Shelf, %{books: [struct!(Book, %{title: "", pages: 1})]})]}, 
      maybe_filter_precond_errors: true
    )
  10. Construct and validate structs

    master

    Use the generated new/1 and ensure_type/1 functions to manage struct lifecycles.

    Construction

    new/1 accepts an Enumerable (like a keyword list or map) to initialize the struct. It automatically validates nested structs.

    # Successful construction
    {:ok, po} = PurchaseOrder.new(%{approved_limit: 250})
    
    # Failed construction with nested errors
    {:error, errors} = PurchaseOrder.new(id: 500, items: [%LineItem{amount: -5}])

    Validation

    If you manually update a struct (e.g., via Map.put/3), use ensure_type/1 to re-validate it.

    po |> Map.put(:items, [LineItem.new!(amount: 150)]) |> PurchaseOrder.ensure_type()
  11. Implement a custom constructor with `gen_constructor_name`

    master

    If you need to generate default values dynamically during construction (e.g., generating a random token), you can instruct Domo to use a different name for its internal constructor. This allows you to define your own new/1 and new!/1 functions while still benefiting from Domo's validation.

    1. Use skip_defaults: true in use Domo.
    2. Use gen_constructor_name: :_new (or any other atom) to name the Domo-generated constructor.
    3. Define your own new/1 and new!/1 which call the generated _new/1 functions.
    defmodule Foo do
      use Domo, skip_defaults: true, gen_constructor_name: :_new
    
      defstruct [:id, :token]
    
      @type id :: non_neg_integer()
      @type token :: String.t()
      precond token: &byte_size(&1) == 8
    
      @type t :: %__MODULE__{id: id(), token: token()}
    
      def new(id) do
        _new(id: id, token: random_string(8))
      end
    
      def new!(id) do
        _new!(id: id, token: random_string(8))
      end
    
      defp random_string(length), do: ... 
    end
    defmodule Foo do
      use Domo, skip_defaults: true, gen_constructor_name: :_new
    
      defstruct [:id, :token]
    
      @type id :: non_neg_integer()
      @type token :: String.t()
      precond token: &byte_size(&1) == 8
    
      @type t :: %__MODULE__{id: id(), token: token()}
    
      def new(id) do
        _new(id: id, token: random_string(8))
      end
    
      def new!(id) do
        _new!(id: id, token: random_string(8))
      end
    
      defp random_string(length),
        do: :crypto.strong_rand_bytes(length) |> Base.encode64() |> binary_part(0, length)
    end
  12. Enable Phoenix hot-reload for Domo

    master

    To enable Phoenix hot-reloading for struct type ensurers built by Domo, you must update both your mix.exs and config.exs files.

    1. Update mix.exs compilers

    compilers: [:domo_compiler] ++ Mix.compilers() ++ [:domo_phoenix_hot_reload]

    2. Update config.exs endpoint configuration

    config :my_app, MyApp.Endpoint,
      reloadable_compilers: [:phoenix, :domo_compiler] ++ Mix.compilers() ++ [:domo_phoenix_hot_reload]