Refactoring dependencies in F#

When coaching some of our F# customers, one of the common questions we hear is "how do we make our code testable?". I would like to talk briefly about this from a code-focused perspective and give some different ways of refactoring a relatively simple code snippet to aid testability, and some of the pros and cons of each of them.

Testing strategies

The following examples demonstrate some different approaches to testable F# code, and how you might look at refactoring your logic to separate out dependencies and enable better testability - and what the pros and cons of these approaches may be.

1. Untestable code

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
14: 
15: 
16: 
17: 
18: 
19: 
20: 
type Person = { Name : string; Age : int }

/// Database access
module DB =
    let savePerson (p:Person) = Ok()
    let loadPerson cId = { Name = "Foo"; Age = 18 }

module Untestable =
    /// Save person with validation logic
    let trySavePerson p =
        if p.Age < 18 then Error "Too young!"
        else DB.savePerson p

    /// Orchestrator function loads data, does some (omitted) logic and then saves
    let orchestrator customerId =
        let p = DB.loadPerson customerId
        // elided business logic goes here...
        trySavePerson p

Untestable.orchestrator 123

This is some standard F#, with a simple set of functions in a standard call chain. The orchestrator function handles the, well, orchestration of data access and logic for some unknown business purpose. The code calls directly into the DB module in order to load and save data, intermingled with validation.

On the one hand this code is simple to follow. There is no use of higher-order functions, no dependency injection etc.. Of course, the flip side is that functions such as orchestrator and trySavePerson are untestable in the sense that they go straight to a real database.

2. Refactoring with higher order functions

Now, a simplistic way to improve matters (and often this will suffice) is to simply start to use higher order functions - in other words, supply functions as arguments to other functions as a way to compose behaviours together. In our case, this means replacing the calls to the DB module with fake functions that have the same structural signature.

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
module Hof =
    /// Save person with validation logic
    let trySavePerson savePerson p =
        if p.Age < 18 then Error "Too young!"
        else savePerson p

    /// Orchestrator function loads data, does some (omitted) logic and then saves
    let orchestrator loadPerson savePerson customerId =
        let p = loadPerson customerId
        // elided business logic goes here...
        trySavePerson savePerson p

orchestrator DB.loadPerson DB.savePerson 123

This simple refactoring has improved our code in the sense that trySavePerson is now testable insofar as we can replace the call to savePerson with a faked version e.g.

1: 
2: 
3: 
// Result should equal Ok()
let mockSave _ = Ok()
let result = trySavePerson mockSave { Name = "Sophie"; Age = 42 }

Of course, this adds some complexity to our code - two higher order functions (HOFs). HOFs are extremely useful as a way to compose behaviours together or for simple mocking, but in larger systems they can become unweildy if used without care:

Uncontrolled HOFs

Firstly, it's easy to create "unusual" higher order functions that represent bizarre abstractions with difficult to understand type signatures (especially if generics or secondary HOFs interact with it).

Explicit supply of HOFs

Since we don't tend to use dependency injection in the conventional OO sense, we have to manually supply all HOFs to functions. Secondly, you need to pass the HOFs all the way down the call chain from the "top level" which decides which implementation of the HOF to use. In this case, witness how DB.savePerson is supplied at the top level and is "threaded" down through orchestrator into trySavePerson. Now, this simple example only contains a call stack two functions deep, but imagine if it were a larger application with potentially dozens of callsites: it can quickly become difficult to maintain.

HOF overload

Also, consider how orchestrator requires two dependencies to be passed in. For larger functions (or function "roots"), this number of HOFs can quickly grow and become difficult to manage. One alternative to this is to create an interface which contains all (or some) dependencies bundled together, and throw this single object around throughout your system. Perhaps you could also use F#4.6's new anonymous records feature to assist here!

3. Creating a bootstrapper

Another alternative is to use a "bootstrapper" function which is responsible for "wire up" of dependencies.

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
14: 
module Bootstrap =
    let trySavePerson savePerson p =
        if p.Age < 18 then Error "Too young!"
        else savePerson p

    let orchestrator load save customerId =
        let p = load customerId
        save p

    let bootstrapper() =
        let trySavePersonToDb = trySavePerson DB.savePerson
        orchestrator DB.loadPerson trySavePersonToDb 123

bootstrapper()

What's important to see in this example is that we ensure that HOFs are never passed more than one level deep. We achieve this by performing the wireup of the lowest-level functions first, and then working back up the call chain before arriving at the top with a fully "injected" function. In our case, that's the trySavePersonToDb function, which we then supply into the orchestrator. There's now no need to explicitly supply dependencies all the way down the chain within your business logic, because the bootstrapper does all this for you.

I sometimes call this an "inside out" refactoring, because you essentially invert the way in which dependencies are managed - instead of an explcit call chain of (A dep) which internally calls B dep, you end up with a call chain such as A (B dep) in which dep is provided as an argument to B, and the resulting function is itself provided as an argument to A. A no longer has any knowledge about dep.

On the one hand, this means that functions such as orchestrator are more easily testable and simpler to reason about. On the other hand, you now have a growing bootstrapper function to maintain which will be responsible for handling the wireup throughout your application. Depending on how you pass dependencies around, this may be entirely managable, or not.

4. Refactoring with explicit composition

All the examples above don't try to alter the fundamental nature of the functions themselves. An alternative is to look at changing the responsibilities of each function directly and seeing if that lends itself to a better outcome, with easier composition.

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
14: 
module Compose =
    let validate p =
        if p.Age < 18 then Error "Too young!"
        else Ok p

    let orchestrator load save customerId =
        let p = load customerId
        save p

    let bootstrapper() =
        let validateThenSaveToDb = validate >> Result.bind DB.savePerson
        orchestrator DB.loadPerson validateThenSaveToDb 123

bootstrapper()

In this version, we've split out the validation logic from the save-to-database logic completely. Doing this immediately makes our validation code inherently testable, and easier to reason about: Instead of calling the database, we simply return Ok() for the happy path.

Now, the composition of "validation" and "saving to the database" are performed directly in the bootstrapper using the >> operator to compose both functions; this is typical "railway-oriented" programming. We can no longer "test" the orchestrated function that combines validation and save, since the composed function only exists in the bootstrapper, and is directly coupled to the physical database again. However, assuming we have trust in Result.bind and the >> operator, and can ensure that the validate function behaves correctly, this may be completely reasonable.

5. Composition to the max

Using the above approach, in this case it's possible to completely do away with the orchestrator function using the >> (composition) operator. Of course, in a more fully-feature application, you may have another function (or functions!) between loading and validation that performs some business logic.

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
module CompositionToTheMax =
    let validate p =
        if p.Age < 18 then Error "Too young!"
        else Ok p
    
    let bootstrapper() =
        let orchestrator = DB.loadPerson >> validate >> Result.bind DB.savePerson
        orchestrator 123

bootstrapper()

There are some excellent online resources on the "Ports and Adapters" architecture by people such as Mark Seeman that are well worth checking out. One of the goals of this architecture is to push your impure functions to the boundaries of the application, leaving a pure center. In F#, since it's not easily possible to guarantee pure functions, one way of doing this is to simply program against values, and not higher order functions at all.

This is essentially our "compose to the max" example above, but is subtly different from a philosphical standup if nothing else. Here, we view our "pure" application as the validate function (along with any other "pure" functions that work soley on data), whilst the load and save are "ports" to the outside world; we compose the "impure" and "pure" functions together in our (untestable) bootstrapper.

Testing

The github repo also illustrates how your testing might be affected by these different ways of composing your code together.

Conclusion

This is by no means a complete, in-depth analysis of how to refactor dependencies away in F# code, nor am I stating which of the alternatives above is "better" or "worse". Indeed, there are also other ways of solving this problem. For example, I've seen systems that had a global "static" dependencies value which was set once at the start of the application as another way. It's certainly not a "purist" FP solution, but in this case it worked for that team.

The most important thing is to be aware of some of the trade offs for the alternatives, and to try them out yourself. Take one hour out of your week, create a new branch in git and try to refactor a subset of your codebase in a different style. What are the costs and benefits? Were there any unforseen issues? What impact did it have on testability?

Hopefully you enjoyed this - and have fun _ -> Ok()!

Isaac