woolfred

woolfred

Reducing repetition of strings via macro?!

Hey everyone :wave:,

we (me and my team) are currently struggling with the idea of reducing the repetition of the same strings in several places within our application. I guess in other languages we would rely on enums.

tl;dr

In the end we introduced a macro to include module attributes with the strings we needed to match on. But aren’t fully convinced, if this is the right approach. Therefore we would like to hear what you all think about this approach :slight_smile:

First I will try to give a context and then try to show some examples of the problem, which gave us the feeling that we would want to introduce a “single point of truth”. At the end I show you “the solution” :wink: Here we go…

Context & Problem

Our backend is dealing with payment data it receives from an app and handles the communication with an external payment service.
As some requests take a bit longer and we wanted to deal with bad reception, the basic architecture is asynchronous and relies on Oban.

So basically: When the App makes a payment request we create an %PaymentProcess{status: "initialized", payment_provider: "paypal", ....}, put it in the database, enque an Oban job to deal with the external service interaction and return a 200. The app is then listening to state changes of the payment.
Based on the provider the job handles the interaction with the external service differently and in the end depending on the result it will update the PaymentProcess like this:

  • %PaymentProcess{status: "paid", payment_provider: "paypal", ....} or
  • %PaymentProcess{status: "declined", payment_provider: "paypal", error_code: 123}

After that, it sends the app a notification to fetch the new state of the payment process (the app also pulls on a regular base as a fallback).

When the app now fetches the current PaymentProcess, we fetch it from the DB and return a 200 if everything went smoothly (status: "paid") or 422 when the payment was declined. Part of the error response is also a text the app should display, which is specific to the used payment_provider. In some cases it is also a mix of the used payment_provider, the status and a error_code.
(We decided to let the backend provide the error text, as it is way easier to change texts, add new cases etc. with a new backend deployment, instead of releasing an app update through the app stores)

Examples of repetition

This flow and setup led to code where we need to pattern match on the payment_provider in several places.
E.g. does Request look like this:

defp payment_authorization(%{
    payment_authorization_provider: "paypal",
    payment_authorization_attributes: %{"fetching" => some, "provider" => specific_data}
  }) do
	...
end
 
defp payment_authorization(%{
    payment_authorization_provider: "google_pay",
    payment_authorization_attributes: %{"other" => specifc_attributes}
  }) do
  ... 
end 

And the part where we build a failure (lets call it AppFailure), we do something like this:

defp authorization_failed(payment_provider) do
  case payment_provider do
    "paypal" ->
      {:unprocessable_entity,
       "Please do this and that to fix the problem."}

    "google_pay" ->
      {:unprocessable_entity,
       "Open the Google Pay App to update your card details."}
  end
end

Especially in this module the repetition in the case statements matching over and over again on "paypal" ,"google_pay" etc. got pretty obvious.
The first idea was using module attributes at the top:

@paypal "paypal"
@google_pay "google_pay"
...

This helped to reduce repetition and error-proneness, while providing auto-completion with VSCode.

But we still have the same strings in our Request module and basically wanted a way to share the module attributes. The first idea (we already knew wouldn’t work) looked like this:

defmodule PaymentProviders do
  @paypal "paypal"
  @google_pay "google_pay"
	
	def paypal, do: @paypal
	def google_pay, do: @google_pay
end

But you can’t use functions for pattern matching.

Our solution

Now what we came up with, but maybe shouldn’t do: Macros

defmodule PaymentProvider do
  defmacro __using__(_opts) do
    quote 
      @google_pay "google_pay"
      @paypal "paypal"
    end
  end
end

With this we could improve our AppFailure like this:

defmodule AppFailure do
  use PaymentProvider
	
  ...
	
  defp authorization_failed(payment_provider) do
    case payment_provider do
      @paypal ->
	    {:unprocessable_entity,
	     "Please do this and that to fix the problem."}

      @google_pay ->
        {:unprocessable_entity,
          "Open the Google Pay App to update your card details."}
    end
  end
end

And also use it to pattern match in the function signature:

defmodule Request do
  use PaymentProvider
	
  defp payment_authorization(%{
      payment_authorization_provider: @paypal,
      payment_authorization_attributes: %{"fetching" => some, "provider" => specific_data}
    }) do
        ...
  end
end

The refactoring the code with the macro felt better than the initial version with the same strings throughout the code. But we would really like to hear, what you all think about this approach, as we are really hesitant as soon as we try to achieve something with macros and start wondering where we took a wrong turn.

Looking forward to read your thoughts and thank you for reading anyway!

Most Liked

LostKobrakai

LostKobrakai

Usually one would use atoms in places you’re using the strings. For db level support there’s Ecto.Enum.

bartblast

bartblast

Creator of Hologram

It feels a little too over-engineered in my (subjective) opinion.

eksperimental

eksperimental

Sorry, but why don’t you just refer to them by atoms. I think you are overlyengineering stuff.

One pattern that I commonly use is in your main module, when you manually add your providers.

defmodule YourApp do
  # Edit only this line when a new payment provider is added
  @payment_providers [:google_pay, :paypal]

  defguard is_payment_provider(term) when term in @payment_providers

  def payment_providers(), do: @payment_providers
end

defmodule YourApp.PaymentProviders do
  import YourApp, only [is_payment_provider: 1]

  # Now apply this guard to your function, so you don't have to worry about passing the an invalid provider

  defp payment_authorization(%{provider: provider}) when is_payment_provider(provider),
    do: beautiful_stuff()
end
lud

lud

This is what we use:

defmodule MyApp.Enums.EnumCompiler do
  @moduledoc false

  defmacro defenum(mod_alias, property) do
    providing_code = Macro.to_string(property)

    quote location: :keep, bind_quoted: binding() do
      module = Module.concat(MyApp.Enums, mod_alias)

      values =
        case property do
          %Xema.Schema{enum: enum} -> enum
          %Xema.Schema{const: const} -> [const]
          other -> raise "invalid values: #{inspect(other)}, given as #{providing_code}"
        end

      IO.puts("defining enum #{inspect(module)} as #{inspect(values)}")

      defmodule module do
        Enum.each(values, fn
          val when is_binary(val) and val != "" ->
            :ok

          value ->
            raise "Invalid value for enum #{inspect(__MODULE__)}: #{inspect(value)}, given as #{providing_code}"
        end)

        [first | _] = values
        first_fun = first |> String.downcase() |> String.to_atom()

        @moduledoc """
        Generated enum with the following values:

        #{MyApp.Enums.EnumCompiler.format_values_markdown(values)}

        A function named after the lowercase version of the value is defined for
        each enum value, returning the enum key as a `t:String.t()`.

            IO.inspect #{__MODULE__ |> Module.split() |> List.last()}.#{first_fun}(), label: "value"
            # value: "#{first}"

        Macros are also defined to provide values access through named calls in
        guards and matches. They use the same names as the functions, with a 
        `__` prefix to denote compile-time work. You must require the module in 
        order to be able to use those macros.

            require #{inspect(__MODULE__)}, as: #{__MODULE__ |> Module.split() |> List.last()}
            #{__MODULE__ |> Module.split() |> List.last()}.__#{first_fun}() = "#{first}"
            # "#{first}"
        """

        @doc """
        Returns all values as a list.
        """
        def _values, do: unquote(values)

        @doc """
        A macro that injects the full value list.
        """
        @doc guard: true
        defmacro __values do
          values = unquote(values)

          quote do
            unquote(values)
          end
        end

        values
        |> Enum.each(fn value ->
          fun = value |> String.downcase() |> String.to_atom()

          @doc "Returns the enum value: `#{inspect(value)}`"
          @doc section: :enum_value
          def unquote(fun)(), do: unquote(value)
        end)

        values
        |> Enum.each(fn value ->
          fun = value |> String.downcase()

          @doc "Macro injecting AST for the enum value: `#{inspect(value)}`"
          @doc section: :enum_value_macro
          defmacro unquote(:"__#{fun}")(), do: unquote(value)
        end)
      end
    end
  end

  def format_values_markdown(values) do
    values
    |> Enum.map(&[?`, inspect(&1), ?`])
    |> Enum.intersperse(", ")
    |> to_string
  end
end

Given a value like "HELLO", the generated module will have the hello/0 function and the __hello/0 macro (the macro can be used in pattern matching).

We use Xema. If you do not, you will have to accept a bare list of strings in the following case:

      values =
        case property do
          %Xema.Schema{enum: enum} -> enum
          %Xema.Schema{const: const} -> [const]
          other -> raise "invalid values: #{inspect(other)}, given as #{providing_code}"
        end
IvanR

IvanR

If the idea is to make the IDE to help you to select a provider from the list, then it may be useful to add a scope to attribute like @payment_provider_paypal, @payment_provider_google_pay etc.

Then in VSCode when you start typing @payment you get the scoped autocompletion list of providers :slightly_smiling_face:

Where Next?

Popular in Questions Top

dotdotdotPaul
Okay, I'm having a heck of a time trying to figure out how to best handle the validation of belongs_to associations in Ecto. I'm sure I'...
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
albydarned
Hello all! I am typing this post from my new MacBook Pro with the M1 chip. I’m loving it so far, and will probably use it as my daily dr...
New
quazar
How to set Jason to encode all fields in ecto schema, I don’t care about security and implementing only is taking long list of attributes...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
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
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
mgjohns61585
Could someone help me? I'm making my first elixir program, number guessing game. I can't figure out how to convert the user's guess from ...
New
LegitStack
I’m trying to make a websocket server in Phoenix or raw Elixir. I heard about gun, I think I could use cowboy, but since I’m not that sma...
New
siddhant3030
Hi, I have to write a raw query for one of my project. But till now I have used ecto queries and don’t have much experience writing raw ...
New

Other popular topics Top

JDanielMartinez
Hi! May someone helps me, please! I have two apps into an umbrella project: the first one is Database, which manages queries, and the se...
New
itssasanka
Hi all, Trying to get some more clarity over utc_datetime and naive_datetime for Ecto: https://hexdocs.pm/ecto/Ecto.Schema.html#module-...
New
ycv005
I have followed this StackOverflow post to install the specific version of Erlang. And When I am running mix ecto.setup then getting fol...
New
fireproofsocks
I’m working on defining a simple Ecto schema for a table (in PostGres), but I don’t see where I can define a column as NOT NULL. Conside...
New
script
If I have a string “1000 cfu/ml” . I want to remove the characters and / and space . So the string is like this "1000" What is the ...
New
vonH
When I run the Plug and I recompile I wind up having to use Ctrl C to quit iex and start again. Witht the help of rlwrap I can use the cu...
New
stefanluptak
Hello everybody, usually, I use a 29" ultra-wide monitor for VSCode which can easily accomodate explorer (files panel) + file with code ...
New
WestKeys
Currently suffering from paralysis by [HTTP client] analysis. This is rather unusual in Elixirland as there tends to be consensus on the ...
New
Patoshizzle
After calling mix ecto.create I get this error: 17:00:32.162 [error] GenServer #PID<0.412.0> terminating ** (Postgrex.Error) FATAL...
New
jay1
Why is it that the mnesia database isn’t the most preferred database for use in Elixir/Phoenix?
New

We're in Beta

About us Mission Statement