Functional Core, Imperative Shell in Nushell

Gary Bernhardt gave a famous talk called Boundaries. In this talk he introduces a pattern he calls the Functional Core, Imperative Shell.

The Imperative Shell is a sequence of steps that sits at the edges of your code. The Functional Core contains the business logic in the form of pure, total functions. I will demonstrate what this means in the form of a recent shell script I wrote in Nushell.

The Shell Script

I was recently writing a script to check for new emails from a web-based Winlink client called Pat. The script would download my inbox with Pat's API. If there are new messages, it would send a notification to my phone using ntfy.

Winlink is an email-like system that is used by amateur radio operators to exchange messages over the air. It is used often during disaster situations when Internet service is unavailable. Most recently it was used during the aftermath of Hurricane Helene.

Total, Partial, and Pure Functions

Before we look at the code, I need to explain a couple of terms.

A total function is one that produces an output for every input. A non-total function is referred to as a partial function.

A classic example of a partial function is division: 10 / 2 is defined, but 10 / 0 has no answer.

A pure function is one whose output is solely determined by its input. The result does not depend on the world outside of its input, nor does it affect the outside world.

In Nushell, the expression [a b c] | length is pure; it always returns 3. In contrast, date now is not pure. If you run it twice, you will get different answers because the result depends on a clock. The clock is an external dependency.

The Imperative Shell

While I was writing this script, I was testing it with Nu's std/assert module. It dawned on me that I accidentally applied the "Imperative Shell, Functional Core" pattern.

My main function was a simple sequence of steps:

  1. Load the last known messages from the cache
  2. Fetch the current messages from Pat
  3. Update the cache with the latest message
  4. Return the new messages

This is the Imperative Shell. It keeps all the fallible code at the edges where it can be handled more easily, and pushes the business logic into the new-messages function.

Looking at the steps, each one is a partial function because it can fail. The only exception is new-messages, which holds the business logic. It is a total function.

For me the rule of thumb for writing the Imperative Shell is this:

  1. Gather the facts you need to make a decision
  2. Pass those facts to pure, total functions to produce data to be acted on
  3. Act on the resulting data

Now that we've looked at the Imperative Shell, we can move on to the Functional Core.

The Functional Core

The logic lives in a pure, total function, new-messages. It is simply a filter of its input.

The result of this function only depends on its inputs, $in and $existing. This makes it a pure function. It is also total because there is no set of inputs that results in an undefined result.

Testing

The benefit of using pure, total functions is that they are very simple to test. The more your application is made up of these kinds of functions, the easier it will be to test.

For instance, new-messages is a total function, albeit a poorly optimized one.

Testing this is trivial. I feed it some inputs and assert the outputs match, using a table of test cases covering the variations I expect.

If I wanted to go further, I could optimize new-messages with a hash map, or use Property Based Testing to fuzz my inputs.

Conclusion

I've found applying this pattern in production code to be very helpful. It pushes the error-producing code to the edges, where handling it is a matter of logging or notifying the user, and pushes the business logic into pure, easy-to-test functions.

If you structure the edges of your code like this, you'll find it much easier to test, and you'll find you need mocks much less often. Gary's Boundaries talk goes into these benefits in more detail. I highly recommend the talk if you haven't watched it.