Value Object and Primitive Obsession code-smell

What is your take about the “primitive obsession” code smell in Elixir?

Do you prefer to create structs that wrap the native values or wraps other values like Decimal.t() that are a bit more complex?

Or do you try to use the primitive values as much as you can?

I am particularly interested in those situations where you only have one value in your struct, since


defmodule Something do
  // value is either a native type or some other struct
  @enforce_keys [:value]
  defstruct [:value]
end

// another example

defmodule Amount do
  @enforce_keys [:value]
  defstruct [:value] 

  def new(value) do
    %__MODULE__{value: Decimal.new(value)}
  end
end

Recently coming back to revisit my code, I find myself refactoring a bit and introducing more value objects, that brings a bit more clarity to my code compared to old code.

Some of the value objects (structs) are there to lift up the meaning of the native type or nested structs. While others check some invariants behind the factory function to make sure I don’t have garbage values.

What are your general thoughts about the topic?

You will take a performance, and a readability hit. IMO the only reason to do this if you are have some sort of common strategy that traps the module associated with the struct, e.g. a protocol, or something where you are doing:

def some_function_that_takes_data_interface(value = %module{}) do
  module.operation(value)
end

presumably somewhere, you have some structs that get sent to above function that have more than one map field, in addition to the one-field structs.

1 Like

by readability hit, I mean both in terms of your code, and in terms of when you’re debugging and inspecting stuff, not to mention if somehow your error logs happen to have erlang formatting instead of elixir formatting… Structs are not pretty in erlang formatting. I guess I feel a better strategy to lift up the meaning of primitive data types is to use good variable names. I once had a project where my linter would reject my code i If I had generic variable names like “value” “result”, “x”, etc. I would like to have a static linter some day that can infer types and will require that certain variables have suffixes, e.g. _id must be a uuid and vice versa.

1 Like

In the general case I am completely with you here.

Just definitely don’t obsess. There are many cases where just passing integers and Strings is completely fine.

But I find myself using value objects for another reason as well: libraries like domo / typed_struct can help you with better enforcing contracts at runtime plus give you better error messages in case of a violation.

1 Like

Can you post an example of code that uses this approach? In isolation, it doesn’t seem like this is anything more than a Decimal with extra steps.

They are two main cases thou,

  1. Clarity of the code:
defmodule Amount do
  @moduledoc """
  Value object representing a money amount in the context of fintech.
  """

  @type t :: %__MODULE__{value: Decimal.t()}

  @enforce_keys [:value]
  defstruct [:value]

  @doc """
  Creates a new `t:t/0` object.

  ## Examples

      iex> amount = Amount.new(20_000)
      ...> Amount.value(amount)
      20_000
  """
  @spec new(Decimal.decimal()) :: t()
  def new(value) do
    %__MODULE__{value: Decimal.new(value)}
  end

  @doc """
  Returns the value of the `t:t/0`.

  ## Examples

      iex> amount = Amount.new(20_000)
      ...> Amount.value(amount)
      20_000
  """
  @spec value(t()) :: integer()
  def value(amount) do
    Decimal.to_integer(amount.value)
  end

  @doc """
  Check if the given amount is negative.

  ## Examples

      iex> amount = Amount.new(-20_000)
      ...> Amount.negative?(amount)
      true

      iex> amount = Amount.new(20_000)
      ...> Amount.negative?(amount)
      false
  """
  @spec negative?(t()) :: boolean()
  def negative?(amount) do
    Decimal.negative?(amount.value)
  end
end

In this case, it doesn’t seem to have much value, but I would argue that you shouldn’t care if I am dealing with the value a Decimal.t() or a string.

  1. Hide invariants:
defmodule PositiveAmount do
  @moduledoc """
  Value object representing a `Amount` that must be positive.
  This object is useful when you want to deal signed values to unsigned values
  with safely.
  """

  alias Amount

  @type t :: %__MODULE__{value: Amount.t()}

  @enforce_keys [:value]
  defstruct [:value]

  @doc """
  Creates a new `t:t/0` object.

  ## Examples

      iex> {:ok, amount} = PositiveAmount.new(20_000)
      ...> PositiveAmount.value(amount)
      20_000

  With some an negative amount:

      iex> PositiveAmount.new(-20_000)
      {:error, :negative_amount}
  """
  @spec new(amount :: Amount.t() | Decimal.decimal()) :: {:ok, t()} | {:error, :negative_amount}
  def new(%Amount{} = amount) do
    if Amount.negative?(amount) do
      {:error, :negative_amount}
    else
      {:ok, %__MODULE__{value: amount}}
    end
  end

  def new(value) do
    value
    |> Amount.new()
    |> new()
  end

  @doc """
  Returns the value of the `t:t/0`.

  ## Examples

      iex> {:ok, amount} = PositiveAmount.new(20_000)
      ...> PositiveAmount.value(amount)
      20_000
  """
  @spec value(t()) :: integer()
  def value(amount) do
    Amount.value(amount.value)
  end
end

In this case, the value object doesn’t exist simply to lift up the meaning and hide the underline type, but also to make sure that the invariants check are correct, therefore, people don’t have to figure out what it means to be “PositiveAmount”.

I hope that helps.

2 Likes