A Look at the Functional Core and Imperative Shell Pattern

Mario Bittencourt
SSENSE-TECH
Published in
6 min readMay 19, 2023
Photo by Shane on Unsplash

If you come from an enterprise background, you have most likely been working with Java or C# and automatically associate the analysis and development activities with capturing your requirements in terms of classes and then objects.

But there is another way to approach development: the functional way!

Functional programming, while not new, has gained popularity in recent years with offers such as F# and Scala attracting more and more users. It promises an easier path for developers where built-in features, such as immutability, help to avoid introducing side effects. This led to exploring how to approach modeling and the development of applications that follow domain-driven design (DDD) in this style.

This article presents my findings on the matter, more specifically how the functional core and imperative shell pattern can be used to get the best of both worlds.

(Brief) Introduction to Functional Programming

One definition of functional programming (FP) is a “programming paradigm where programs are constructed by applying and composing functions”[1]. This means that functions are top-tier elements that can be stored, passed as arguments to other functions, and returned.

What does this mean in practice? Let’s explore some simple examples using typescript as our language.

Nothing out of the ordinary, you define a function and you can execute the code by either referring to the function name or the newly assigned variable.

We are passing a function calculateSquare as a parameter, which allows us to change the result of the map and make it unaware of the specifics as long as it conforms to the type TransformFunction signature. Our first dependency injection in a functional way.

Now we are returning a new function from multiplyBy that is later assigned and used.

While there are many other aspects associated with FP: composing, currying, monads, etc. for the purposes of this article I will focus on immutability and pure functions.

Pure Functions

A function is deemed pure if it consistently produces the same result with the same input and does not cause side effects.

You can think of a pure function being one that, in theory, could have its implementation replaced by a lookup table where the key is the input argument.

Figure 1. Lookup table replacing a functional implementation.

This is quite powerful as it adds predictability to the execution. Something we seek, especially when performing tests.

Consider a simple example: an individual is known by his/her name and credit score. Each individual can be classified according to the credit score (<500 Bad, >=500 and <1,000 Good, >=1,000 Excellent).

We need to determine the classification for any given individual.

The classification is only dependent on the input. Calling it multiple times does not change the result we receive. For reference, here is a similar implementation where the same can’t be said:

Since it depends on the outside state, that may change, we can no longer provide the same guarantees.

Immutability

Pure functional languages prevent you from mutating the state. Instead, you have to create a new state with the updated value.

In the example above we are not modifying the original state but rather returning a new one.

Javascript can’t prevent you from updating the contents of a variable, but with typescript we can make it more difficult to do so.

Figure 2. IDE would alert you of attempting to modify the state.

The World is Impure

Unfortunately, most of our applications need to handle the outside world, which means having dependencies where state changes are part of the equation.

Consequently, developers must leave the comfort of pure functions they’ve recently learned to appreciate. But not all is lost. This is where the pattern of having a bigger functional core where all your pure functions live, and a thin imperative shell where the impure functions will operate is a viable choice.

Figure 3. Imperative shell and functional core.

The shell will contain all the infrastructure-related code and deal with the side effects and non-deterministic aspects. All business logic will be implemented as a set of pure functions residing in the core.

A rule is that the shell can call the core, but the reverse is not allowed.

When we look at this approach, it is not far from drawing a comparison to hexagonal architecture.

Figure 4. Hexagonal architecture.

In both approaches, we aim to have our application core — where the business rules live — isolated from the outside world communication and the implementation details associated with it.

Adding Domain-Driven Design to the Mix

DDD defines several concepts, divided into tactical and strategic. If we focus solely on the tactical ones, we are presented with value objects, entities, and aggregates where a key aspect is the encapsulation of behavior and data. A basic object-oriented premise is expected.

How do we approach this from a functional perspective?

Let’s start with the value object. Out of the gate, there is one thing FP is aligned with: immutability. Once created, it is expected to always be valid and unchangeable.

In our example, we have the definition of the value object as a read-only type and its creation is taken care of by a pure function that enforces the business invariant: the origin must be different than the destination. Together, they both give you the same expected guarantees of its object-oriented counterpart.

For the entity where there is a natural mutability, one approach is to think of the new state of an entity as being a new value altogether.

In our example, we have a fraud case entity whose lifecycle is open and either approved or declined.

The behavior is captured in pure functions and never mutates the original value, but instead creates a new one. In our case, we modeled the lifecycle as a new type with its own specific properties to enhance the developer experience.

Figure 5. IDE hint of incompatible use due to type mismatch.

Because the declineFraudCase expects an open fraud case, we have help to indicate a potential misuse without having to inspect again.

All the above functions reside in the core and have no external dependencies. Our imperative shell will interact with the core and the dependencies.

Although not mandatory, in this example we are even mixing an object-oriented approach to handle the persistence. We see that it is ok as it does not interfere with the core in any way.

No need to have data transfer objects (DTO) as the returned values from the core are read-only.

Conclusion

I have been advocating for the adoption of hexagonal architecture and domain-driven design for any domain that has to deal with complex use cases for a while now.

This approach is usually met with some resistance given the perceived overhead and number of concepts that need to be understood and implemented.

Another obstacle is the notion that object-oriented programming (OO) is a requirement, which is not necessarily the only way to achieve the results that we want.

If functional programming is your expertise and is accepted in your organization, there is no need to forgo it for the sake of having a clean architecture and strong domain language.

I believe that hexagonal architecture and DDD both help with the development and future maintenance of services over time. If you are interested in knowing more about using FP, explore the resources listed in the resources section.

Good luck!

Resources

Editorial reviews by Catherine Heim & Gregory Belhumeur.

Want to work with us? Click here to see all open positions at SSENSE!

--

--