Functional domain modeling in Elixir

Paweł Świątkowski - Oct 25 '23 - - Dev Community

In this blog post I want to explore how using techniques from functional modeling can improve the code written in Elixir. We will see if this way we can avoid some common pitfalls often met in Elixir codebases that lead to convoluted code and, especially, to overly complex (and slow) tests.

For the purpose of this, we will be implementing the following piece from a Library (as in book borrowing) domain: borrowing a book. I am fully aware that libraries work differently in different places of the world, so I picked a handful of requirements. To borrow a book, the following conditions must be met:

  • A patron must have an active subscription in the library
  • A patron can have at most 5 books borrowed at any time (so if this is a sixth book, it cannot be borrowed)
  • A patron cannot have any unpaid fines from keeping the books for too long

Many of us will on instinct write a code similar to this:

def borrow_book(patron_id, book_id) do
  with {:ok, _} <- get_active_subscription(patron_id),
      [] <- Finance.get_unpaid_fines(patron_id),
      currently_borrowed <- get_currently_borrowed_books(patron_id) do
    if length(currently_borrowed) <= 5 do
      Borrowing.create_changeset(patron_id, book_id, DateTime.utc_now())
      |> Repo.insert()
    else
      {:error, :too_many_borrowed_books}
    end
  else
    [%Fine{} | _] -> {:error, :unpaid_fines}
    {:error, :subscription_not_found} -> {:error, :no_active_subscription}
  end
end

defp get_active_subscription(patron_id) do
  case Subscriptions.get_active(patron_id) do
    nil -> {:error, :subscription_not_found}
    sub -> {:ok, sub}
  end
end
Enter fullscreen mode Exit fullscreen mode

This code is not inherently bad by any means. It can be followed quite easily to check what requirements have been implemented. It has reasonable separation of concerns (subscription checks in Subscriptions context etc.). However, at least for me, it does not have a great look'n'feel and also has some more objective issues:

  • It creates tight coupling between current module (supposedly Borrowing) and other modules - Subscriptions and Finance. Some changes in those module might cause necessity to make changes in this module as well. This is especially visible in tests: if the test setup required for get_active_subscription changes (because, for example, you check it in an external service now, instead of a local database), you have to adjust the setup for test testing borrow_book; otherwise it won't work.
  • It mixes "technical" concerns of saving into the database with domain logic.
  • It accepts pretty meaningless IDs (numbers, uuids) as input. If you make a mistake and call it in the reverse order borrow_book(some_book_id, some_patron_id), it will be relatively hard to detect what went wrong.

Let's see if applying techniques of functional modeling can help us here.

Functional what?

But first, we need to finally get to the definition of functional modeling. I'm going to use the definition from @jakub_zalas article:

A functional domain model is made of pure functions and immutable types.

Immutable part is really easy in Elixir, because things are immutable by default, until you try really hard to make them mutable. But the other part is interesting and, in my opinion, often overlooked in Elixir, given it's usually advertised as functional language.

Just to recap: a pure function means that for any given input it returns the same predictable output. In other words, it does not have side effects or rely on such - current time, database data, external API calls, I/O etc.

Using pure functions in functional domain modeling allows us to concentrate on the essence of the domain - verifying domain requirements - instead of dabbling with everything around that. We will see what we can do with our little function.

Let's model the domain functionally!

But first, let's reiterate on the requirements:

A patron must have an active subscription in the library. A patron can have at most 5 books borrowed at any time. A patron cannot have any unpaid fines from keeping the books for too long

I made them a little more terse, but also underlined a couple of things. These are the information we need to run our business logic. We don't (for now) care how to obtain it. We just assume it is available to us. There are a few possibilities of how to pass it into our function, but we will use the simplest one, which is: positional arguments.

@spec borrow_book(Subscription.t() | nil, list(Borrowing.t()), list(Fine.t())) :: any()
def borrow_book(active_subscription, currently_borrowed, unpaid_fines)
Enter fullscreen mode Exit fullscreen mode

Or actually we could simplify it even further, if we feel like it, by using just primitive values:

@spec borrow_book(boolean(), integer(), boolean()) :: any()
def borrow_book(has_active_subscription?, number_of_borrowed_books, has_unpaid_fines?)
Enter fullscreen mode Exit fullscreen mode

Whether to choose the first option or second is a choice you need to make. First one is more verbose, makes it more clear what you should pass by just looking at the types. However, in a way, it does create some kind of loose coupling. If you change the name Borrowing to Lending, you will have to change the type signature of the function (although logic would remain untouched).

The first definition is at the same time more open to extension in the future. Maybe if unpaid fines amount to less than $10 you still can borrow? Or perhaps you can if you only have one from last month? Since you have a list of unpaid fines at hand, you might change the check without changing the signature of the function. Using only the primitive values, you'd have to change boolean argument has_unpaid_fines? to some other, like integer sum_of_unpaid_fines_amount.

Leaving this quandary aside, let us note on important thing here: we no longer reference neither the book, nor the patron herself! It turns out that to make a decision whether a book might be borrowed or not, in this case, these are not exactly needed. This is also very important observation: a pure domain function makes a decision whether something should happen or not and returns it. It doesn't necessarily need to execute that decision.

After all this talk, let's now try to actually write the function's body (I picked the first signature, by the way):

def borrow_book(active_subscription, currently_borrowed, unpaid_fines) do
  cond do
    is_nil(active_subscription) -> {:error, :no_active_subscription}
    length(currently_borrowed) >= 5 -> {:error, :too_many_borrowed_books}
    length(unpaid_fines) > 0 -> {:error, :unpaid_fines}
    true -> :ok
  end
end
Enter fullscreen mode Exit fullscreen mode

I used cond here instead of with, because in my opinion it fits a bit better now. This might have something to do with my Ruby background though, where I'd use guard clauses with early return in the beginning of the function to check potential failures and only then execute the happy path. However, you could just as well still use with.

It might seem that this function does not do a lot compared to our original function. That observation is 100% correct. It does much less. In fact it does precisely what we wanted it to do: checks the domain requirement. Nothing more, nothing less. It does not rely on any mutable global state (a.k.a. the database), it does not write to the database - it simply makes a decision whether borrowing can happen or not.

As you can imagine, testing it is a bliss. No setup, no transactions and Ecto sandboxes. You just use ExUnit.Case, you call the function and assert on the result.

There is only one question lingering here...

Okay, but what next?

The domain logic of our application does not exist in vain. Let's be honest here: most web application are glorified wrappers over the database. Almost every request consists of three phases: fetch some data, process this data, persist the result in the database. We are going to build upon this observation. We will take out pure domain function and will make a "functional sandwich" (also sometimes called I/O sandwich or impureim sandwich). This technique bases on isolating "impure" part of the application to the beginning and the end of the request handling, while the middle remains functionally pure. The whole sandwich builder is usually called (at least in DDD-ish circles) a service layer. We need such layer too.

defmodule Library.Borrowing.BorrowingService do
  def borrow_book(patron_id, book_id)
    # something
    # call our pure function
    # something
  end
end
Enter fullscreen mode Exit fullscreen mode

It looks familiar, doesn't it? The implementation might look familiar too:

def borrow_book(patron_id, book_id) do
  subscription = Subscriptions.get_active(patron_id)
  unpaid_fines = Finance.get_unpaid_fines(patron_id)
  currently_borrowed = get_currently_borrowed_books(patron_id)

  case Domain.borrow_book(subscription, currently_borrowed, unpaid_fines) do  # <- !!!
    :ok ->
      Borrowing.create_changeset(patron_id, book_id, DateTime.utc_now())
      |> Repo.insert()
    {:error, error} -> 
      {:error, error}
  end
end
Enter fullscreen mode Exit fullscreen mode

One might say we did not win a lot, but actually we did. We have our core function called in a marked line. Everything before it is preparation, everything after is "post-processing". Our core function is well-tested, so we probably don't need to exhaustively test the service. Only 1 – 2 "plumbing tests" (the name comes from Filip Pacanowski), but this is not a domain logic testing. It just checks if we connected the building blocks correctly.

To retrospect

We split our initial function into two. One of them is a pure function, which means that it's super-easy and super-fast to test. Incidentally, it also handles the most important part of the whole implementation - the actual domain logic. The other function adds "two slices of bread" on both sides of the function: to prepare the required data and to save the result in the database (if needed).

With that we separated the concerns a little but. If the data source or database schema changes, only a service function should be affected. The domain logic stays the same and the function in the Domain module is unaffected by the change. In the opposite direction, if the domain requirements change (for example with allowing to borrow even if there are some unpaid fines), only the domain function will change.

It is, of course, possible that both functions will have to change at the same time: if the change in requirements causes the need to fetch more or different data. In our case, let's imagine that a book of "erotica" genre can be lent only to adult patrons. In that case not only you need to actually fetch a patron and a book, but you also need to pass additional data to the domain function. This might quickly lead to an explosion of the number of the argument. There are some ways to improve this situation, but this is outside of the scope of this article (perhaps I'll write another one).

All in all, I think this kind of the design is not only more functional, but more testable and a bit nicer on the eyes (easier to scan). And I certainly would prefer writing more code like this than like our initial borrow_book version.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .