A way to write extremely testable code with side effects.
It has always been a challenge to write testable back-end code. Deterministic code is stupidly testable and is the ultimate goal. But it seems most business workflows that execute on the back-end are dependent on the result of side effects. There has been many a framework and practice created to try to accomplish testability: dependency injection containers, mocking libraries, turning code inside-out with interfaces, etc. None of these have ever felt right to me because they require so much unrelated overhead or framework-dependency. And they are not idiomatic to functional programming.
A few years ago, I stumbled upon the Model-View-Update pattern. It is a UI-specific pattern, but it quite powerfully separates decision from side effect. It made a drastic difference in our UIs. They became low overhead/risk to refactor and test -- something I had never experienced with any UI framework. Eventually I realized that its power is because it is a functional version of the Interpreter pattern. While my decision code has all the power and expressiveness of a full programming language, it can also run deterministically, and return decisions and side effects as simple data values. Those values then get interpreted and the results fed back into the decision code again.
On the back-end
So I began to think about how to apply this pattern to the server-side to reap the same benefits. Of course MVU is tailored for UI, so some modifications were required.
Leaving out View
UI programs display a visual to the user. For an API request there is no visual, so the View part of MVU can be dropped.
Changing runtime behavior
UI programs are also designed to run indefinitely to accept spontaneous user input. On the back-end, there is no spontaneous user input while processing a request... it was all given up front in the request. Nor is it desirable to run the API request indefinitely. Instead, the back-end workflow should probably get a response back to the caller as soon as it can.
Front-end MVU typically runs as 3 independent agents for processing messages, effects, and view changes. But the run-time behavior on the back-end should be more like a single task that runs to completion.
Adding Effects
One part of the MVU pattern that seems to be conspicuously absent is side effects, which is a large part of the point to the back end. My original exposure to MVU was in Elm, a language that never allows you to implement your own side effects. If the provided effects were not enough, you had to write your own in Javascript and make a cumbersome interop call. That was not particularly great on the front-end, but it really is not going to cut it for the back-end.
F# permits side effects in normal code, so I wanted to take advantage of that, but also designate a place where these should be performed. So I added a new type called Effect
for declaring side effects, then a corresponding function called perform
which executes the Effect. Side effects are isolated into the perform function. The update function now deterministically returns Model and Effect list, making it testable. Put another way, you can test not only that the correct state change takes place but also that the correct side effects are invoked! No frameworks, no containers. Just provide values in and assert that output values equals expected values.
I also use the perform / Effect pattern in my F#-based MVU front-ends. It only requires a couple of tiny adapter functions to integrate into the Elmish library.
UMP
With these changes it might be more appropriate to call this pattern UMP: Update Model Perform.
The Pattern
The pattern looks and feels a lot like MVU. So if you use MVU on the front-end, it will feel natural to use on the back-end as well. Here is a basic example of decrementing a counter. First let's define the module and the types.
Types
I usually name the module for the workflow it implements. Defining the types is where I spend some time thinking through the workflow. And looking at the types will often give you an idea of exactly what happens.
module DecrementCounter
open System
type Effect =
| LoadState of counterId: Guid
| SaveState of counterId: Guid * count: int
type Msg =
| StateLoaded of Result<int option, string>
| StateSaved of Result<unit, string>
type Model =
{
// request type defined elsewhere as
// { CounterId: Guid; Amount: int }
Request: DecrementCounterRequest
}
Nothing earth shattering here. The effects are stating that we are going to load and save state. The messages are stating that we will get the result of loading and saving, that those operations can fail (hence Result
), and that load may not find the counter (hence int option
). The failure cases will simply contain a string error.
Deterministic functions
Next we define the two deterministic functions, init
and update
.
init
Init really just gets things prepped and started for update. It converts an initial argument into the model that will be used by update. Usually I will do basic request validation in init
if it is not done by some other part of the infrastructure.
let init request =
Ok { Request = request }
, [LoadState request.CounterId]
update
Update typically makes the big decisions.
let update msg modelResult =
match modelResult with
| Error _ ->
modelResult, [] // do nothing
| Ok model ->
match msg with
| StateLoaded (Error s) ->
Error ("Load failed: " + s), []
| StateLoaded (Ok None) ->
Error "Counter not found", []
| StateLoaded (Ok (Some oldCount)) ->
let count = oldCount - model.Request.Amount
if count < 0 then
Error "Counter would go negative", []
else
Ok { model with Count = count }
, [SaveState (model.Request.CounterId, count)]
| StateSaved (Error s) ->
Error ("Save failed: " + s), []
| StateSaved (Ok ()) ->
Ok model, []
The first 4 lines of update are so common in workflow scenarios that I create a helper function and factor them out. So update would actually look like this.
let update msg model =
match msg with
| StateLoaded (Error s) ->
Error ("Load failed: " + s), []
| StateLoaded (Ok None) ->
Error "Counter not found", []
| StateLoaded (Ok (Some oldCount)) ->
let count = oldCount - model.Request.Amount
if count < 0 then
Error "Counter would go negative", []
else
Ok model
, [SaveState (model.Request.CounterId, count)]
| StateSaved (Error s) ->
Error ("Save failed: " + s), []
| StateSaved (Ok ()) ->
Ok model, []
In this basic example, the model just keeps the original request. But in other scenarios, steps may return a changed model. Those changes are then used by subsequent steps.
perform
Here begins the side-effect area of the code. I usually put open
s here which are needed for side effects instead of placing them at the top. This makes it less likely to "accidentally" include side effects in init/update. I commonly create a Config type here that has configuration or resources which are needed by side effects. perform
is where I do logging as well.
// example open used only by side effects
open Microsoft.Extensions.Logging
type EffectConfig =
{
ExampleConfig: string
// other items such as:
// connection strings
// endpoint URLs
// loggers
}
let perform config effect =
match effect with
| LoadState counterId ->
// simulate db call
async {
let rand = new Random()
do! Async.Sleep 30
let count = rand.Next(0, 100)
return StateLoaded (Ok (Some count))
}
| SaveState (counterId, count) ->
async {
do! Async.Sleep 30
return StateSaved (Ok ())
}
A couple of notes. This simple implementation will not error. But actual code might have try/catch, log exceptions, etc. Anything you might normally need to do when you call a side effect.
I usually find that my API side effects are pretty common between all my workflows. So I will define a common Effects module and call its effect implementation, instead of implementing the effect inside the perform function. I also try to keep effects very focused to doing one single thing, with all the config and data needed as parameters, so that they can have the possibility of being reused. In the end, perform
ends up looking more like this.
let perform fxConfig effect =
match effect with
| LoadState counterId ->
let query = Query.counterState counterId
Fx.Sql.readFirst<int> fxConfig query StateLoaded
| SaveState (counterId, counter) ->
let stmt = Stmt.saveCounter counterId counter
Fx.Sql.write fxConfig stmt StateSaved
Please forgive the cutesy abbreviation of Effects to Fx. The pragmatism of shortening the name won out.
In the case where I centralize the effect implementations under an Fx namespace, I also use a common config. So there is no need to define a workflow-specific config. The Fx modules also know that the output will need to be tagged with a Msg case. So it accepts that as a parameter.
Final pieces
Sometimes you will want the final return value of the workflow to be different from the Model that you used during the workflow steps. So there is an output
function that will convert the model into the desired output value. The last steps are to create an output
function. And then wrap everything up into a runnable UMP program.
// Return Ok () or Error string
let output result =
match result with
| Ok model -> Ok ()
| Error s -> Error s
// more concisely:
// let output = Result.map ignore
let toUmp config =
{
Init = init
Update = Result.bindUpdate update
Perform = perform config
Output = output
}
If you want to return the Model as-is once the workflow completes, you can simply set Output = id
. The F# built-in id
function returns the same thing it is given.
Result.bindUpdate
is an extension function that I define somewhere in my project. It is that helper I mentioned that simplifies the update statement to remove those first 4 lines.
module Result =
let bindUpdate updatef msg result =
match result with
| Ok model -> updatef msg model
| Error err -> Error err, []
Running it
You will want to provide the necessary config for effects, but aside from that you just run it with the initial argument.
let program =
DecrementCounter.toUmp { ExampleConfig = "foo" }
...
async {
let request : DecrementCounterRequest = ...
let! result = Ump.run program request
...
}
}
Sometimes it is convenient to hide the UMP details. So I turn it into a normal async-returning function like this:
// Request -> Async<Result<unit, string>>
let decrementCounter request =
let prog = DecrementCounter.toUmp { ExampleConfig = "foo" }
Ump.run prog request
async {
// this code knows nothing of UMP
// it is just executing a side effect
let! result = decrementCounter request
}
Testing it
Probably the most common way you want to test is providing the initial argument and all the Msgs (the results of side effects) that have occurred. Then you would assert that the return value matches what you expected.
// test data
let counterId = new Guid("9E6F6552-DEA9-4D56-AEAB-08EE5EBD54D3")
let request =
{
CounterId = counterId
Amount = 12
}
let model = { Request = request }
[<TestMethod>]
member __.``DecrementCounter - loads state`` () =
let initArg, msgs = request, []
let expected = Ok model, [LoadState counterId]
expected |> equals (test initArg msgs)
It is not hard to see how you can run a lot of different tests at once by parameterizing the input and expected output.
let tests =
[ // name, initArg, msgs, expected model, expected effects
( "loads state"
, request
, []
, Ok model
, [LoadState counterId]
)
( "saves state"
, request
, [StateLoaded (Ok (Some 13))]
, Ok model
, [SaveState (counterId, 1)]
)
]
[<TestMethod>]
member __.``DecrementCounter tests`` () =
for (name, initArg, msgs, model, effects) in tests do
printfn "%s" name
let expected = model, effects
expected |> equals (test initArg msgs)
You can also manually test one specific step by calling the update function directly.
[<TestMethod>]
member __.``DecrementCounter - prevent negative`` () =
let msg = StateLoaded (Ok (Some 0))
let expected = Error "Counter would go negative", []
expected |> equals (DecrementCounter.update msg model)
When (not) to use
UMP is only valuable when you need to mix decisions and side effects. Further, it should be used for important business code that needs its decisions validated for correctness. If your code is does not require this, you pay the overhead cost of creating all the pattern's types and functions for no advantage. Below I have documented some indications of inappropriate usage that I found the hard way.
Pass-through Effect
When you find yourself making an Effect that passes a value through directly to a Msg without performing any side effect, then it is likely that have tried to use an Effect to represent a logical (non-side-effect) decision step.
This pattern is not designed to divide logical decision steps. It is designed to divide into steps around side effects. You can use normal let
statements or pipelining with |>
to compose multiple logical steps. It is perfectly valid for one of the update cases to be more lines of code than other steps. If you do want to keep your update cases small for clarity, you can place your logic functions into a separate module and call them from init
or update
.
module Logic =
let validate data =
...
let init request =
Logic.validate request
...
Never changing the model
When you find that your update
code never updates the model, or it only updates the model to store values needed by later effects, this might indicate that the code is purely effectful. If it is not business critical, it is probably better to write a normal function that invokes side effects. Below is an example that could be written as UMP, but it is not solving a business problem only a technical one. So the overhead outweighs the benefit.
module Sql =
...
let read<'T> (config: SqlConfig) (op: SqlOperation) =
async {
try
let cmd = SqlOperation.toCommandDefinition config op
use conn = new NpgsqlConnection(config.ConnectString)
let! resultSeq = conn.QueryAsync<'T>(cmd) |> Async.AwaitTask
return Ok (List.ofSeq resultSeq)
with ex ->
return Error ex
}
Implementation
Here is the full implementation of Ump functions with comments. It includes a function to create a test and the Result.bindUpdate
helper.
namespace Ump
type Ump<'initArg, 'Model, 'Effect, 'Msg, 'Output> =
{
Init : 'initArg -> 'Model * 'Effect list
Update : 'Msg -> 'Model -> 'Model * 'Effect list
Perform : 'Effect -> Async<'Msg>
Output : 'Model -> 'Output
}
module Result =
let bindUpdate updatef msg result =
match result with
| Ok model ->
updatef msg model
| Error err ->
Error err, []
module Ump =
[<AutoOpen>]
module Internal =
[<Struct>]
// struct - per iteration: 1 stack allocation + 1 frame copy
type ProgramState<'Model, 'Effect, 'Msg> =
{
Model : 'Model
Effects : 'Effect list
Msgs : 'Msg list
}
// Msgs are processed before Effects.
// Msgs are run sequentially.
// Effects are run in parallel.
// In practice, program.Update will return
// one Effect at a time when it needs sequential effects.
let rec runLoop program state =
match state.Effects, state.Msgs with
| [], [] ->
async.Return (program.Output state.Model)
| _, msg :: nMsgs ->
let (nModel, effects) = program.Update msg state.Model
let nState =
{
Model = nModel
Effects = List.append state.Effects effects
Msgs = nMsgs
}
runLoop program nState
| _, [] ->
async {
let effectsAsync = List.map program.Perform state.Effects
let! nMsgsArr = Async.Parallel effectsAsync
let nState =
{
Model = state.Model
Effects = []
Msgs = List.ofArray nMsgsArr
}
return! runLoop program nState
}
/// Runs a program using the provided initial argument.
/// The returned Model is the final state of the Model when the program exited.
/// Infinite loops are possible when Update generates Effects on every iteration.
/// This allows the program to support interactive applications, for example.
let run (program: Ump<'initArg, 'Model, 'Effect, 'Msg, 'Output>) (initArg: 'initArg) =
let (model, effects) = program.Init initArg
let state =
{
Model = model
Msgs = []
Effects = effects
}
runLoop program state
/// Creates a test function from the init and update functions.
/// The returned function takes initArg and msgs and returns
/// a model and effect list that you can test against expected values.
let createTest init update =
let test initArg msgs =
let init = init initArg
// ignore previous effects
let update (model, _) msg =
update msg model
msgs
|> List.fold update init
test
Here is a repo with this DecrementCounter example and tests. There is also a more advanced example of a rate-limited Emailer.
XeSoft / ump-example
Testable back-end programming pattern
Perspective
This pattern is not meant to be used everywhere. It shines with effectful code that you want rigorously test. In other words, side-effect-infused workflows that are important to your business.
And let's be honest, this pattern has boilerplate in how it is organized: Msg, Effect, Model, init, update, perform. It will not suit everyone's taste. But that same organization pattern also provides a testable structure. It happens to be quite nice if your team already uses MVU on the front-end -- it is much easier for the team to be cross-functional. Even if not, I think with some practice you will find that it makes just about any workflow quite testable.
/∞
Images courtesy of Undraw.
Top comments (4)
Have you looked into algebraic effects at all? It seems similar to what you're describing, or at least aims to solve a similar problem, but is functionally pure.
Yes. This pattern (and MVU like it) is an unrolled version of algebraic effects. With the distinct advantage that it does not take a dependency on category theory abstractions (at least not in code) or special-case language syntax. Just regular code, organized a certain way. This pattern is just as functionally pure as algebraic effects in a functionally pure language -- see Elm. But I appreciate the pragmatism of F# in being impure and letting me code my effects. Hence, the perform function.
Interesting. You might want to take a look at the Eff library for implementing pure algebraic effects in F#. The effect handlers are a bit hairy to write, but the effectful code itself is quite elegant. Example:
Thanks for the suggestion!
I have seen various F# Computation Expressions out there for side effects. Practically speaking, workflows in Eff are harder to test than what I present here. (This will be true in general for monadic control flow.) To test decision logic in an Eff CE, you would have to implement mock effects. Which will lead you to write some non-trivial testing instrumentation. With what I demo above, you can test the logic directly without mocking effect implementations. Just data in and asserting that data out matches expected values. Also the effect implementations are comparatively easier to write, and this matters for maintainability too.
Side note: I've always felt that CEs and do-notation-type things are missing the point a bit. They are emulating an imperative style within a functional language. This can be very convenient at times, like with list expressions and conditional yields. But in most cases I had rather write code in a functional style.
Apologies, as I am a compulsive editor. This comment has changed drastically since first submission.