Use Return Value to Defer Decisions

We need to "defer architectural decisions" when designing a piece of software, so that we can switch between different options easily. In another word, deferring architectural decisions brings us design flexibility for our application.

The same idea can be applied to the running application. By deferring decisions of how to respond to an event, our app can become extremely flexible. So how does a running application defer decisions it needs to make?

There are many design patterns for solving this problem, such as Visitor and Strategy. But this post is about a much simpler one: the return value of our functions. As we'll see, Return Value is the simplest, yet the most powerful tool to defer decisions for later.

What is Return Value?

Return value is one of the first concepts I learned when I started programming in Pascal. A Procedure would not return anything. A Function would return a value.

Then I learned Ruby and Elixir. In these two languages, every line of code would return a value. Return value is so fundamental that its concept fades away quickly.

Until recently, I realized that the return value of a function is an important part of its API. If you think about it, return value is the most natural way for us to pass information aground, and only with information, can the app make any decisions. By defining the return value well, we can defer decision-makings in our code until the last possible moment.

Let's see some examples:

Tagged Tuple

The first example is a common pattern in Erlang/Elixir. Tagged Tuples are tuples with an atom :ok or :error. The atom is a tag saying if a function gets executed successfully or not. Take Ecto.Repo.delete/1 for example:

case MyRepo.delete(post) do
  {:ok, struct}       -> # Deleted with success
  {:error, changeset} -> # Something went wrong
end

At first glance, it's too simple to be anything that's useful. But it's actually extremely powerful.

Just think about the alternatives if we don't use a tag to indicate the execution result:

  1. Hard wire the error handling logic

    But that means there would be no flexibility at all. If we want to handle failed deletions differently in different places, it's almost impossible. So this alternative is not acceptable for a library like Ecto.

    And if we want to deal with different failure reasons, we need to update the delete/1 function itself. Then it will break the Open-Close Principle.

  2. Receive callbacks to handle errors

    We can also add callback parameters to add some flexibility:

    def delete(struct, error_callback_fun) do
      if something_goes_wrong do
        error_callback_fun.()
      end
    end
    

    It's an improvement compared to hard-wire the logic, but still, it doesn't scale. If we want to handle multiple failure reasons, we either add more callback parameters (which will make the API unnecessarily large), or use pattern matching in error_callback_fun (which is basically no different than pattern matching the return value).

  3. Raise exceptions when an error happens

    This is one of the most common solutions in other languages/frameworks. (Yes, I'm thinking about Ruby/Rails here.)

    begin
      post.destroy!
    rescue ActiveRecord::RecordNotDestroyed
      # Something went wrong
    end
    

    Due to the lack of a built-in pattern matching mechanism, the best we can do is to return true/false as the indicator. But it doesn't scale when dealing with more than two results (succeeded/failed). And things get nastier if we want to return some data with the result indicator.

    So, I think of the exception rescuing mechanism as a pattern matching replacement. The snippet above can be translated to:

    case post.destroy! do
      true -> # Deleted with success
      ActiveRecord::RecordNotDestroyed -> # Something went wrong
    end
    

    Though the exception way and the pattern matching way are semantically the same, exceptions have a performance overhead, an maintenance overhead and a cognitive overhead.

    Performance overhead
    Raising an exception and catching it needs to go through a separate stack and it will impact the performance more or less.
    Maintenance overhead
    To handle a new case, we need to define a new exception type/class. Categorize it somewhere. I can tell from my experience, maintaining a growing number of exceptions you own can become a nightmare.
    Cognitive overhead

    Compared to the return value version, exception mechanism is a whole new system to learn and grasp.

    And learning all the exceptions defined in your app and your dependencies is almost impossible. (A potential outcome is that you will define a redundant exception.)

To wrap the comparison above, by returning results in a tuple tagged with an atom like :ok/:error, we get the most flexibility in the simplest way. It doesn't have any performance overhead or cognitive overhead. And to handle a new case, we just add a new type of tag in the API definition. So the client know it needs to upgrade. The maintenance cost is also low.

Functional Modeling

You may still think Tagged Tuples are too simple. So let's see how return value helps us to model our application in a functional way.

When explaining why spawning a process is not always a best choice and how to model a Blackjack game functionally, Saša Jurić demonstrated the power of return values.

  • Tagged Tuples

    First, Saša showed us how to model a Deck in a functional way:

    defmodule Blackjack.Deck do
      @cards for suit <- [:spades, :hearts, :diamonds, :clubs],
                 rank <- [2, 3, 4, 5, 6, 7, 8, 9, 10, :jack, :queen, :king, :ace],
                 do: %{suit: suit, rank: rank}
    
      def shuffled(), do: Enum.shuffle(@cards)
    
      def take([card | rest]), do: {:ok, card, rest}
      def take([]), do: {:error, :empty}
    end
    
    deck = Blackjack.Deck.shuffled()
    
    case Blackjack.Deck.take(deck) do
      {:ok, card, transformed_deck} ->
        # do something with the card and the transform deck
    
      {:error, :empty} ->
        # deck is empty -> do something else
    end
    

    This is just the foundation work and it uses Tagged Tuples as usual. No big deal.

  • Return Side-Effects as Data to Defer Decisions

    The mind-blown part is how to model a round with return values in a purely functional way.

    {instructions, round} = Blackjack.Round.start([:player_1, :player_2])
    
    # The instructions can be like this
    [
      {:notify_player, :player_1, {:deal_card, %{rank: 4, suit: :hearts}}},
      {:notify_player, :player_1, {:deal_card, %{rank: 8, suit: :diamonds}}},
      {:notify_player, :player_1, :move}
    ]
    
    {instructions, round} = Blackjack.Round.move(round, :player_2, :stand)
    
    # instructions:
    [
      {:notify_player, :player_1, {:winners, [:player_2]}}
      {:notify_player, :player_2, {:winners, [:player_2]}}
    ]
    

    By modeling side-effects like notifying a player as pure data, we get more flexibility because we can pass this data around, or store it somewhere, and decide later when and how to use this "side-effect" data. With this kind of flexibility, we can:

    1. Separate concerns.
      How to make a side-effect happen
      How to notify a player
      When a side-effect needs to happen
      When we need to notify a player
    2. Drive the code from different client.
      1. Tests
      2. A GenServer wrapper to add concurrency

    P.S. I learned a similar functional modeling technique from Saša Jurić last year. If you are interested, you can find it at My Advent of Code 2018 Recap

  • Event Bus / Event Sourcing

    The struct like {:notify_player, :player_1, :move} reminds me of Event Bus immediately. If we think of the instructions array as an event bus, then every instruction is an event. So you don't need a fancy framework, service to build an event sourcing system, just use dead simple return values!

    And the reason that Event Sourcing is getting more popular is the same as above: it helps us to defer decisions for later.

  • Message Passing / Actor Model

    In addition to Event Bus, we can also understand Message Passing or Actor Model in the same way. If we think of the instructions array as an actor's message box, then every instruction is a message that can be received and handled by that actor. So this kind of functional modeling is a must-have for an Actor Model based system like Erlang/Elixir.

Enumerating a Collection with Reducee

The last example is also the hardest one. With the flexibility return value brings us, we can have different levels of flexibility in our app by designing the return values (API) differently. The best example for this is how Elixir designs its Enumerator API.

In this post, José Valim, the creator of Elixir, compared several different solutions for enumerating a data structure. You can see how the design becomes more flexible, loses some simplicity along the way, and finally reaches a balance between being flexible and simple.

The final solution they chose was like this:

defmodule Reducee do
  @doc """
  Reduces the collection with the given instruction,
  accumulator and function.

  If the instruction is a `{:cont, acc}` tuple, the given
  function will be invoked with the next item and the
  accumulator.

  If the instruction is `{:halt, acc}`, it means there is
  nothing to process and the collection should halt.
  """
  def reduce([h | t], {:cont, acc}, fun) do
    reduce(t, fun.(h, acc), fun)
  end

  def reduce([], {:cont, acc}, _fun) do
    {:done, acc}
  end

  def reduce(_, {:halt, acc}, _fun) do
    {:halted, acc}
  end
end

By extracting side-effects like continuing or halting an enumeration out as data, the Reducee.reduce/3 is easy to understand (compared to other solutions like Iteratee). And more importantly, it's flexible enough to support almost all the functions in the Elixir.Enum module.

I'd highly recommend this post if you want to know more about different ways of data modeling and how they affect the overall API design differently.

Wrap Up: Why Pure Functions Are Good?

The idea of using return values to model side-effects is not new. Gary Bernhardt talked about it in his incredible talk Boundaries. The gist is to keep side effects at the boundaries of our app, so we can have a pure functional core.

A function is pure when it doesn't have any side-effects. How to model that? By returning data. So we can decide what to do with this data later. That's the beauty of functional programming.