An Introduction to Functional Design
Functional programming doesn’t need to be complex, confusing, or theoretical.
Instead, functional programming can be simple, natural, and practical, helping you solve problems with more power and more joy than ever before.
A key to unlocking this potential is understanding functional design, a framework for applying functional programming to real world problems that I have been teaching at Ziverge as well as on my Patreon mentorship program.
Functional design helps you put the “practical” in functional programming, distilling the essence of functional programming into a toolkit that you can use anywhere, any time, in any programming language, and with either greenfield or legacy code bases.
Although developing skills in functional design requires work, time, and practice, the concepts are few and simple—perfect content for a blog post, in fact.
So in this post, I’m going to introduce you to functional design!
Elements of Functional Design
In functional design, we create immutable data types that model solutions to problems in some domain of interest, and equip these data types with constructors, which let us build simple solutions, and composable operators, which let us transform and combine solutions in a predictable way, so that we can use a small set of primitive operators and constructors to solve all possible problems in the domain of interest.
That’s quite a mouthful! So let’s break it down, one bite at a time:
- Functional design. Functional design is an alternative to object-oriented design. Both try to solve business problems in ways that reduce defects and lower the cost of maintenance.
- Immutable data types. Functional programming uses immutable data types, which are a better fit for concurrent applications and which invert control (the functions that you call cannot modify the data that you pass them), making code maintenance easier and less risky.
- Model. Most functional code doesn’t solve problems directly. Instead, it creates models of solutions to problems, which are later executed (or interpreted). For example, a route in a REST web server models handling a URL path, which is later executed on each request. Models are always represented using immutable data types.
- Solutions. At every level of an application, there are problems that require solutions. Data needs to be validated, requests need responses, business logic needs to trigger emails, numbers need to be aggregated, and so forth.
- Domain of interest. In most applications, there are dozens to hundreds of different domains of interest, each of which contains related entities and similar problems that we would like to solve in a way that is easy to test and maintain.
- Constructors. Constructors let us build values using our immutable data types that model solutions to simple problems.
- Composable operators. Operators let us transform and combine solutions to subproblems so we can make values that model solutions to larger, much more complex problems.
Functional design works great with legacy code and greenfield code, although you may use slightly different techniques in each of these cases.
A functional domain is a shorthand way to refer to some particular domain of interest, together with its immutable data type that models solutions to problems, and the model’s constructors and operators.
In the next section, we’ll take a look at a few example functional domains.
Functional design is at the heart of all great functional programming libraries.
Below are a few of the examples you may have already seen:
- Parser Combinators. Parser combinator libraries are designed for the domain of parsing text formats into data structures. A parser itself is a model that describes how to parse some piece of data from a character input sequence. Parser constructors let you build simple parsers, while operators let you transform and combine parsers so you can parse more complicated formats. You can execute parsers, which involves feeding them input to produce either an error or a value that captures the structure in the parsed data.
- Functional Effects. Functional effect libraries like ZIO are designed for the domain of concurrent programming, with safe error management and resource handling. An effect is a model that describes how to execute a series of sequential and concurrent operations. Effect constructors let you build simple (non-concurrent) operations, while operators let you transform and combine effects so you can model much more sophisticated concurent tasks. You can execute effects, which performs all the operations they describe.
- Optics. Optics libraries like Monocle are designed for the domain of accessing and transforming immutable data structures. An optic is a model that describes how to zoom into a piece of data, and retrieve or modify it in some fashion. Optic constructors let you build simple accessors and modifiers, while operators let you transform and combine optics so you can model access and modification on gigantic, highlly nested data structures. You can “execute” optics, which involves feeding them data and transformations to get transformed data.
- Streams. Stream libraries like ZIO Stream are designed for the domain of concurrent streaming data. A stream is a model of a concurrently generated sequence of values. Stream constructors let you build simple streams, while operators let you transform and combine streams so you can model sophisticated data pipelines. You can execute streams, which involves reading elements from their source locations, and concurrently transforming and combining them as per the model.
I will take one example functional domain in ZIO and drill down to the level of actual code, to make everything as concrete as possible.
In Depth: ZIO Schedule
ZIO Schedule is an immutable data type that models a solution to the problem of specifying a finite or infinite recurring schedule.
As with every functional domain, there is a model, constructors, and operators.
The model is the data type
zio.Schedule[Env, In, Out], which describes a schedule that runs in
Env, requires inputs of type
In, and produces outputs of type
When schedules are used to retry failed effects, the input type to the schedule is the error type of the effect. When schedules are used to repeat successful effects, the input type to the schedule is the success type of the effect.
Like all models, a
Schedule doesn’t actually do anything except describe a recurring schedule
(models describe, they don’t do). To execute a schedule model, you can call the
repeat methods on any ZIO effect, and feed them the schedule.
The constructors give us schedules that model solutions to very simple problems. For example, if we
wish to describe a recurrence on Thursday, we can use the
Similarly, if we wish to describe a recurrence at 6:00 AM and 12:00 PM, then we can use the
Like all models in functional design, the schedule model has operators that let us transform and combine schedules, so we can solve bigger problems from solutions to much simpler subproblems.
For example, if we want a schedule that recurs on Thursdays at 6:00 AM and 12:00 PM, then we can use the intersection operator, which produces the intersection of two schedules:
Similarly, if we want to modify this schedule, so that it produces a constant output (say, the unit
()), we can use the
map method, which allows us to produce a new schedule with a
Because ZIO Schedule is a functional domain, it has the ability to solve a huge number of scheduling problems using a small set of constructors and operators. Thanks to following the principles of functional design, schedules are fully testable, easy to understand, and easy to modify in the face of changing business requirements.
Now that you’ve seen an example of functional design up close, we’re going to build our own simple functional domain and explore different choices for encoding the model.
Let’s say we’re building an email web application. Users of the application need the ability to construct email filters, so they can trigger actions like forwarding emails, moving emails to certain folders, and deleting emails.
There are a huge number of possible filters that users might want to construct. Moreover, as developers of the application, we want the ability to support these numerous use cases in a way that is highly testable and has a low cost of maintenance.
To achieve these benefits, we will use functional design techniques.
The first thing we need in this new functional domain is a model. This model will be an immutable data type (like all models), and will describe a particular filter that the user has constructed and wishes to apply to incoming emails.
One of the ways we want to execute this model is to apply it to an email and see if the filter matches the email. This consideration can influence the design of our model.
In functional design, there are two ways to encode any model:
- Executable Encoding. In this encoding, we express every constructor and operator for our model in terms of its execution.
- Declarative Encoding. In this encoding, we express every constructor and operator for our model as pure data in a recursive tree structure.
The first encoding is sometimes called final (because the constructors and operators are expressed in terms of its final executed form, which is predetermined in advance), while the latter is sometimes called initial (because given this initial form, the model can later be executed into any form, even those not predetermined in advance).
Let’s explore both encodings of an email filter.
Executable encodings are represented using
case class types or open
trait types that store
a bunch of functions, each of which executes the model in some predetermined way.
In our case, we want to execute an email filter by applying it to an email, and seeing if the filter
matches the email. So to create an executable encoding of an
EmailFilter, we will define a
case class, and store a function that takes an email and returns a boolean value:
To execute this model, we need only call the
matches function that we have stored in the model,
and provide it an email, which will then give us a boolean value that tells us whether or not the
email filter matches the specified email.
We may now proceed to implement constructors, which build solutions to simple problems, such as matching emails that have some subject:
In a realistic example, other constructors would be needed, which would allow filtering on bodies, or filtering on the recipient list, or filtering on the send date (for example).
Constructors only solve simple problems. To solve more complex problems, we need operators that allow transforming and composing models.
We can add these methods directly inside the
These two binary operators and one unary operator are enough to solve quite a few interesting problems in email filtering. For example, the following filter accepts emails whose subject contains the words “discount” or “clearance”, and which do not contain the word “liquidation”:
The executable encoding is nice and straightforward: it’s obvious what an email filter is, even to someone who is not very familiar with functional design, because every email filter is expressed directly in terms of its execution.
Declarative encodings are represented using
sealed trait types or
enum types (Scala 3), which
have as many subtypes (or cases) as the model has primitive constructors and operators. The
purpose of these subtypes is to capture the arguments of every constructor and operator, and store
them as pure data in a recursive data type.
In our case, if we want to replicate the functionality of the previous executable encoding, then we
need four subtypes of a
sealed trait, one for the subject constructor, one for the unary operator,
and two more for the binary operators:
As with all declarative encodings, the subtypes just store the arguments to the constructors and the operators—they don’t actually do anything except “record” the way email filters are constructed, transformed, and composed.
If we take the previous example
filter, then we can see it ends up building a tree:
This tree can be inspected and transformed, like any data tree.
Now because a model using the declarative encoding is pure data, there is no built-in way to execute the model: we have no way to test an email to see if it matches a filter. However, it’s easy to write a standalone function that can execute the model (called an interpreter or executor of the model).
The following executor takes a model, and tests an email to see if it matches the filter:
The declarative encoding has a layer of indirection, which adds additional ceremony. However, it is a nice way to think about models in general, because it is obvious they describe solutions to problems—the execution of the model requires a separate traversal of the data structure.
In the previous examples, we executed the
EmailFilter model by passing an email, and we got back
a boolean value indicating whether or not the filter matched the email.
However, that’s just one of many ways we might want to execute a model. For example, we might also want to execute the model into a string, which tells us how the email filter was constructed, transformed, and composed (for debug purposes or rendering to users).
We can do this for both the executable and the declarative encodings.
For the executable encoding, we just add another function of type
() => String to the
case class. This second executor creates a string representation of the email filter:
In general, if we want
n distinct ways to execute a model, then our executable encoding will have
n functions all stored inside the
case class (or
n methods inside an open
Every time we add a new interpreter to a model expressed with the executable encoding, we have to
update all the code that uses the constructor for the model, which could potentially be a lot of
code. However, the maintenance burden can be minimized by creating derived operators and
constructors, which are expressed in terms of other constructors and operators (and not the primary
constructor for our
For the declarative encoding, because the model is just data, if we want another interpreter, we can just add another standalone function:
The ease with which one can add new ways to execute a model is indeed a strength of the declarative encoding.
Executable and declarative encodings have opposite strengths and weaknesses.
In the executable encoding, new constructors and operators can be added freely, without updating any existing code. However, new interpreters cannot be added without updating all existing constructors and operators.
In the declarative encoding, new interpreters can be added freely, without updating any existing code. However, new (primitive) operators and constructors cannot be added without updating all existing interpreters.
In functional programming, the executable and declarative encoding are considered “duals”: they are mirror images of each other, sitting at opposite ends of the same spectrum.
In both executable and declarative encodings, you can add new operators and constructors; and in both executable and declarative encodings, you can add new interpreters, which execute the model in some way (for example, testing to see if an email filter matches an email).
Now, as pure data, the declarative encoding is amenable to optimizations that are not possible with the executable encoding (at least, not without essentially reinventing data). So for cases where performance is critical, the declarative encoding can sometimes have an edge.
In addition, the declarative encoding is ideal when persistence of the model is required: as pure data, it is straightforward to define a way to save and restore a model value, using a relational database or other persistence scheme.
Meanwhile, the executable encoding tends to be a better fit for legacy code, because it is very
lean and can seamlessly reuse existing (impure) interfaces and classes. For example, if we have a
lot of code using an
InputStream, we can write our own simple functional stream like so:
Such a data type can have many constructors and operators that let us program in a more declarative fashion (without worrying about resource handling or error propagation for intermediate input streams), while still maintaining easy interop with existing code bases.
Where From Here
Functional design is an immensely powerful tool for making functional programming practical. It lets you solve real world problems in a way that makes your code easy to test, cheap to maintain, and fun to work on.
Although this post introduces the basics, there’s much more beyond what I’ve talked about here, including how to make functional domains more type safe (using generics, phantom types, and type- level programming); how to design good constructors and operators using the principles of orthogonality, expressivity, and composability; common patterns of transformation and composition; and identifying functional domains inside business applications.
Those for whom this post was an easy read are encouraged to explore the expression problem, object algebras, and tagless-final style, all of which provide more background on the tradeoffs of different encodings.
That’s all for now!