"I think I understand functional programming at the micro level, and I have written toy programs, but how do I actually go about writing a complete application, with real data, real error handling, and so on?"
This is a very common question, so I thought that in this series of posts I'd describe a recipe for doing exactly this, covering design, validation, error handling, persistence, dependency management, code organization, and so on.
Some comments and caveats first:
Here's an overview of what I plan to cover in this series:
Let's pick a very simple use case, namely updating some customer information via a web service.
So here are the basic requirements:
This is a typical data centric use case. There is some sort of request that triggers the use case, and then the request data "flows" through the system, being processed by each step in turn. This kind of scenario is common in enterprise software, which is why I am using it as an example.
Here's a diagram of the various components:
But this describes the "happy path" only. Reality is never so simple! What happens if the userid is not found in the database, or the email address is not valid, or the database has an error?
Let's update the diagram to show all the things that could go wrong.
At each step in the use case, various things could cause errors, as shown. Explaining how to handle these errors in an elegant way will be one of the goals of this series.
So now that we understand the steps in the use case, how do we design a solution using a functional approach?
First of all, we have to address a mismatch between the original use case and functional thinking.
In the use case, we typically think of a request/response model. The request is sent, and the response comes back. If something goes wrong, the flow is short-circuited and response is returned "early".
Here's a diagram showing what I mean, based on a simplified version of the use case:
But in the functional model, a function is a black box with an input and an output, like this:
How can we adapt the use case to fit this model?
First, you must recognize that functional data flow is forward only. You cannot do a U-turn or return early.
In our case, that means that all the errors must be sent forward to the end, as an alternative path to the happy path.
Once we have done that, we can convert the whole flow into a single "black box" function like this:
But of course, if you look inside the big function, it is made up of ("composed from" in functional-speak) smaller functions, one for each step, joined in a pipeline.
In that last diagram, there is one success output and three error outputs. This is a problem, as functions can have only one output, not four!
How can we handle this?
The answer is to use a union type, with one case to represent each of the different possible outputs. And then the function as a whole would indeed only have a single output.
Here's an example of a possible type definition for the output:
type UseCaseResult = | Success | ValidationError | UpdateError | SmtpError
And here's the diagram reworked to show a single output with four different cases embedded in it:
This does solve the problem, but having one error case for each step in the flow is brittle and not very reusable. Can we do better?
Yes! All we really need is two cases. One for the happy path, and one for all other error paths, like this:
type UseCaseResult = | Success | Failure
This type is very generic and will work with any workflow! In fact, you'll soon see that we can create a nice library of useful functions that will work with this type, and which can be reused for all sorts of scenarios.
One more thing though -- as it stands there is no data in the result at all, just a success/failure status. We need to tweak it a bit so that it can contain an actual success or failure object. We will specify the success type and failure type using generics (a.k.a. type parameters).
Here's the final, completely generic and reusable version:
type Result<'TSuccess,'TFailure> = | Success of 'TSuccess | Failure of 'TFailure
In fact, there is already a type almost exactly like this defined in the F# library. It's called Choice. For clarity though, I will continue to use the
Result type defined above for this and the next post. When we come to some more serious coding, we'll revisit this.
So, now, showing the individual steps again, we can see that we will have to combine the errors from each step onto to a single "failure" path.
How to do this will be the topic of the next post.
So far then, we have the following guidelines for the recipe: