The Null Struct Pattern

elixir ecto postgresql
Posted on: 2018-01-18

At Mealthy.com, vegetarians get first-class treatment. If you sign up and tell them you're a vegetarian, you won't see any meaty recipes, even if you search for something like "chicken".

To implement that, I borrowed a concept called the Null Object Pattern from OOP and applied it in our functional context. You might call it the "Null Struct Pattern". There's nothing original in this idea, but I liked the fact that the concept transferred so well.

The use case was the stereotypical one: we had conditional code based on the current_ user, but that would be nil if the user hadn't signed in. There are a couple of ways we could handle this.

  1. Sprinkle the knowledge of how to handle an anonymous user everywhere. For example, code that checks the current user's dietary preference would say "do this if they're a vegetarian, do that if they're not, and do this default thing if the user is nil."
  2. Represent an anonymous user with a %User{} struct that contains default values. We can call this a "null" or "guest" user. Eg, %User{id: nil, is_vegetarian: false}. When no user is signed in, set current_user to that anonymous %User{} struct. The rest of the system can treat an anonymous user like any other, checking their preferences and acting accordingly.

Option 2 seemed much better to me. Here's how I used it to provide a first-class vegetarian experience.

Filtering Recipes By Tag

First, I made a way to say "give me only those recipes that are tagged 'no-meat'".

query
|> Recipes.tagged_with("no-meat")

That function looks like:

def tagged_with(query, tag_name) do
  from r in query,
    where: r.id in ids_of_recipes_tagged_with(tag_name)
end

...which depends on this macro:

defmacro ids_of_recipes_tagged_with(tag_name) do
  quote do
    fragment(
     "SELECT recipes_tags.recipe_id
      FROM recipes_tags
      INNER JOIN tags
        ON tags.id = recipes_tags.tag_id
        AND tags.name= ?
      ", ^unquote(tag_name)
    )
  end
end

The nice thing about tagged_with(query, "no-meat") is that it takes a query and returns a modified one, so it's easy to layer on functionality. Eg, "only show me vegetarian recipes matching this search term", or "vegetarian recipes for the pressure cooker", or whatever.

Applying this Preference

With tagged_with/2 in place, it was easy to find recipes matching the current user's preferences.

def matching_user_preferences(query, %User{is_vegetarian: true}) do
  tagged_with(query, "no-meat")
end
def matching_user_preferences(query, _user), do: query

So far, there's no real need for a null user; a nil user would simply see every recipe.

However, for the Mealthy API, it's useful to be able to toggle "vegetarian mode" without registration. That's because the app lets users say "I'm a vegetarian" before they even sign up; the preference is stored on their mobile device, and passed along as a param.

If the param is given, we override the current user's dietary preference for the duration of the request by using a plug:

defmodule Mealthy.Api.SetUserPreferencesFromParams do
  import Plug.Conn

  def init(_opts), do: nil

  def call(conn = %{assigns: %{current_user: current_user}, params: %{"dietary-preference" => "vegetarian"}}, _opts) when is_map(current_user) do
    conn
    |> assign(:current_user, %{current_user | is_vegetarian: true})
  end

  def call(conn, _opts) do
    conn
  end
end

... which we can't do if an anonymous user is represented with current_user: nil.

So for endpoints using the plug above, we can either require a signed in user (eg, for pages like "my favorite recipes"), or we can use our null user.

That's as simple as:

@anonymous_user %User{id: nil, is_vegetarian: false}
# ...
assign(conn, :current_user, @anonymous_user)

The rest of the system treats this anonymous user like any other: we can parse the dietary-preference param, set the guest user's preference, and show only recipes that the "current user" wants to see. The concept of the anonymous user is dealt with in one place and doesn't perturb the rest of the code.

Having spent a lot of time in object-oriented languages, I like seeing examples like this where that knowledge transfers well into a functional design. This technique is really just an application of Don't Repeat Yourself: give each piece of knowledge a single, unambiguous representation in the system.