DRY Elixir Code with Composition and Macros

As a software engineer there is nothing that annoys me more than solving the same problem in the same way.

It's one thing to iterate and improve on a solution, but I'm not here to rehash solved problems. Especially not problems I've already solved. (Side note, that's why I love a healthy, high quality open source ecosystem.)

Repeating yourself for things that are conceptually the same sucks. Like basic CRUD functions. I should never have to write those twice.

So, as someone writing Elixir you have a few options:

1. Copy/paste

Copy and pasting code means you get code drift and conceptual duplication, which is problematic. It also means if you find a bug in that copy pasted code, congratulations, that bug now exists in multiple places. I hope you find them all.

Copy and paste definitely has its place, and should be used, but you have other tools in your belt you should be reaching for as well.

2. Macros

Small, single function macros that you can compose into greater macros, or just use piecemeal in order to reduce the net amount of boilerplate code that you have to write.

Tradeoffs here include less immediately accessible readability, and increased compile time, but used judiciously this can be a worthwhile trade. If you're not generating hex docs for your team's usage, the accessibility drops off even more dramatically.

defmodule Meta.Create do
 @moduledoc """
 Abstracted/generalized create function
 """
 
 defmacro __using__(schema, repo) do
   singular = schema.singular()
 
   quote do
     @doc """
     Creates #{unquote(singular)}.
     ## Examples
         iex> create_#{unquote(singular)}(%{field: value})
         {:ok, %#{unquote(schema)}{}}
         iex> create_#{unquote(singular)}(%{field: bad_value})
         {:error, %Ecto.Changeset{}}
     """
     @spec unquote(:"create_#{singular}")(map()) ::
       {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()}
     def unquote(:"create_#{singular}")(attrs) do
       %unquote(schema){}
       |> unquote(schema).changeset(attrs)
       |> unquote(repo).insert()
     end
 end
end
 
defmodule Invoker do
 use Meta.Create, schema: Invoker.Schema, repo: Invoker.Repo
end

3. Code generators

Code generators as they are commonly used in Elixir don't attempt to update all the instances where you generated code en masse. This means if you want to make an improvement anywhere you're having to find all the places this was used manually, and update it manually. You're basically back to copy/paste, but possibly more convenient.

4. Functional abstraction

A close second, and something I wouldn't fault you for opting for, but increases the amount of boilerplate because you now have to write the specs and docs as well by hand each time.

defmodule Meta.Create do
 @moduledoc """
 Abstracted/generalized create function
 """
 
 @spec call(module(), module(), map())
    :: {:ok, Ecto.Schema.t()} :: {:error, Ecto.Changes.t()}
 def call(schema, repo, params) do
   params
   |> schema.changeset()
   |> repo.insert()
 end
end
 
defmodule Invoker do
 @doc """
   Creates an Invoker.Schema.
   ## Examples
       iex> create_schema(%{field: value})
       {:ok, Invoker.Schema.{}}
       iex> create_schema(%{field: bad_value})
       {:error, %Ecto.Changeset{}}
   """
 @spec create(map()) :: {:ok, Invoker.Schema.t()} :: {:error Ecto.Changeset}
 def create(params) do
   Meta.Create.call(schema, repo, params)
 end
end

So for now I've settled on composable macros as the most approachable for most Elixir teams.

Future Exploration

Future exploration here would include using the AST to compare the generated older code, with potential generated new, using a framework that generates the migrations and updates them appropriately across the entire codebase automatically. This would be a bigger win than both the macros and the code generators.

Hypothetically, a generator framework could either be used to support both macros, or generated code, as described via templating. Win/win. It could run the code version migrations everywhere the old code version matches across the whole codebase, until a generator version is marked deprecated, at which point it is never used.

Let me know what you think about DRYing up Elixir code. Or if you beat me to the implementation of this framework.