John A De Goes bio photo

John A De Goes

Twitter LinkedIn Github

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.

Example 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 some environment Env, requires inputs of type In, and produces outputs of type Out.

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 retry or 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 Schedule.dayOfWeek constructor:

val onThursday = Schedule.dayOfWeek(DayOfWeek.THURSDAY)

Similarly, if we wish to describe a recurrence at 6:00 AM and 12:00 PM, then we can use the Schedule.hourOfDay constructor:

val sixAndTwelve = Schedule.hourOfDay(6, 12)

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:

val thursdaysSixAndTwelve = 
  onThursday && sixAndTwelve

Similarly, if we want to modify this schedule, so that it produces a constant output (say, the unit value ()), we can use the map method, which allows us to produce a new schedule with a transformed output:

val finalSchedule = thursdaysSixAndTwelve.map(_ => ())

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.

Email Filtering

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 Model

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 Encoding

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:

final case class EmailFilter(matches: Email => Boolean)

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:

def subjectContains(phrase: String): EmailFilter = 
  EmailFilter(_.subject.contains(phrase))

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 EmailFilter class:

final case class EmailFilter(matches: Email => Boolean) { self =>
  def &&(that: EmailFilter): EmailFilter = 
    EmailFilter(email => self.matches(email) && that.matches(email))

  def ||(that: EmailFilter): EmailFilter = 
    EmailFilter(email => self.matches(email) || that.matches(email))

  def unary_! : EmailFilter = 
    EmailFilter(email => !self.matches(email))
}

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”:

val filter = 
  (subjectContains("discount") || subjectContains("clearance")) &&
  !subjectContains("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 Encoding

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:

sealed trait EmailFilter { self =>
  def &&(that: EmailFilter): EmailFilter = And(self, that)

  def ||(that: EmailFilter): EmailFilter = Or(self, that)

  def unary_! : EmailFilter = Not(self)
}
final case class SubjectContains(phrase: String) extends EmailFilter
final case class And(left: EmailFilter, right: EmailFilter) extends EmailFilter 
final case class Or(left: EmailFilter, right: EmailFilter) extends EmailFilter 
final case class Not(value: EmailFilter) extends EmailFilter

def subjectContains(phrase: String): EmailFilter = 
  SubjectContains(phrase)

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:

And(
  Or(SubjectContains("discount"), SubjectContains("clearance")),
  Not(SubjectContains("liquidation"))
)

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:

def matches(filter: EmailFilter. email: Email): Boolean = 
  filter match {
    case And(l, r) => matches(l, email) && matches(r, email)
    case Or(l, r) => matches(l, email) || matches(r, email)
    case Not(v) => !matches(v, email)
    case SubjectContains(phrase) => email.subject.contains(phrase)
  }

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.

New Interpreters

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:

final case class EmailFilter(matches: Email => Boolean, describe: () => String) { self =>
  def &&(that: EmailFilter): EmailFilter = 
    EmailFilter(
      email => self.matches(email) && that.matches(email),
      () => s"(${self.describe()} && ${that.describe})")

  def ||(that: EmailFilter): EmailFilter = 
    EmailFilter(
      email => self.matches(email) || that.matches(email),
      () => s"(${self.describe()} || ${that.describe})"))

  def unary_! : EmailFilter = 
    EmailFilter(email => !self.matches(email),
    () => s"!${self.describe()}"))
}
def subjectContains(phrase: String): EmailFilter = 
  EmailFilter(_.subject.contains(phrase), () => s"(subject contains ${phrase})")

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 trait).

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 case class).

For the declarative encoding, because the model is just data, if we want another interpreter, we can just add another standalone function:

def describe(filter: EmailFilter): String = 
  filter match {
    case And(l, r) => s"(${describe(l)} && ${describe(r)})"
    case Or(l, r) => s"(${describe(l)} || ${describe(r)})"
    case Not(v) => s"!${describe(v)}"
    case SubjectContains(phrase) => s"(subject contains ${phrase})"
  }

The ease with which one can add new ways to execute a model is indeed a strength of the declarative encoding.

Encoding Tradeoffs

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:

final case class Stream(create: () => InputStream)

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.

If you’ve enjoyed this post and would like to learn more, check out my exercises on Github, sign up for my next workshop at Ziverge, or join my Spartan tier on Patreon.

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!