Why?
You:
Why?
Me:
Because code is better...
You:
Code is better? How?
Me:
Because is it easier to:
- read
- extend
- debug
You:
Prove it!
Prove it!
Me:
Ok let's look at the following problem:
In practice
We have to write an endpoint which returns an offer by its id:
GET /offer-by-id/:offer-id
Possible results:
- 200 {"offer-id":2, "offer-data":""}
- 400 {"errors":["The id you provided is invalid"}]}
- 404 {"errors":["The id you provided cannot be found"}]}
IMPORTANT: if an id is valid but not found the deposit must be notified!
GET /offer-by-id/:offer-id
Possible results:
- 200 {"offer-id":2, "offer-data":""}
- 400 {"errors":["The id you provided is invalid"}]}
- 404 {"errors":["The id you provided cannot be found"}]}
IMPORTANT: if an id is valid but not found the deposit must be notified!
Solution 1: IFs
The problem could be solved by:
Or in code:
Whenever we have ifs in code, it becomes dificult to read so maybe we could simplify it, making it more like:
step 1
then step 2
then step 3
Or more like
validate
then find offer
then jsonify
Hmm, can we?
Solution 2: Exceptions
The solution above is very present in Java, even if the way the errors are caught might not be this explicit.
Because it's a fairly simple example we could also use the strategy pattern. Basically we use ifs to choose a strategy, then we execute it to give us the result.
Solution 3: strategy pattern
Functional programming gives us sever possibilities using pipes.
What is a pipe?
A pipe makes sure that steps get executed in a certain order and that the result of a step is passed to the next step. Like:
In our case we'd like something like:
validate(requestId)
.then(findOfferById)
.then(jsonify)
Solution 4 Functional either or railway oriented programming
This is a very common solution in typed functional languages such as F# or Haskel, but it's becoming very common in Java as well.
Having a single pipeline is very beautiful but how do we handle errors? By using two parallel pipes. A pipe for the happy path and a path for the errors. Basically all our functions can return either a SuccesfullResponse or a ErrorResponse. This will get passed on to the next function that will process it and return again either a SuccesfullResponse or a ErrorResponse.
In F# we'd have something like:
findOrderById: IResponse -> SuccesfullResponse | ErrorResponse
we might describe it like:
SuccesfullResponse | ErrorResponse aFunction(SuccesfullResponse | ErrorResponse response)
For the happy path we'd have:
And if we get a validation error in the first step we'll have:
And the code, will have a class for Success and a class for Error. Each will inherit an IResponse and will implement two functions receiving a function (lambda) then and fail. In Succes we'll return the result of applying the function on what we have on then and the data we have on fail, and on Error we'll do exactly the opposite.
Imaging you'd replace then with map and fail with orElseGet, doesn't that sound like an optional?
In dynamic languages, there are other options. But first let's describe functional composition.
Functional composition is when you combine 2 (or more) functions into one, then apply it. It's pretty much like pipe but you may do the composition at runtime.
Now we can look at the two options. First is:
Solution 5: pipeline with flag
We'll pass through the pipe an object that contains a flag which tells you whether there were errors before. Basically using a value in your data instead of using the type of the data (has response property instead of type: Successful or Error).
In our case, the flag is whether a response has been set already:
if(state.response) return state;
Solution 6: pipeline + overflow pipeline
The second option is to have a pipeline and an overflow pipeline.Unlike the previous two options when an exception happend it will jump straight to the end, bypassing the next steps directly.
The code: https://gitlab.com/danbunea/improving-control-flow-in-code-using-functional-pipelines/tree/stage-1
Readability
step 1
then step 2
then step 3
While also handling the errors:
step 1
then step 2
then step 3
fail on-error
or
safe(
step 1
then step 2
then step 3
)
Extensibility
We now have to modify our endpoint
GET /offer-by-id/:offer-id
Possible results:
- 200 {"offer-id":2, "offer-data":"", "active":true, "requests":1}
- 400 {"errors":["The id you provided is invalid"}]}
- 400 {"errors":["The offer expired"}]}
- 404 {"errors":["The id you provided cannot be found"}]}
IMPORTANT: if an id is valid but the offer expired the deposit must be notified!
We changed the tests:
Then we change the code and we can look at how
For solution 1:
The way we solved it was to add more ifs inside an existing if thus increasing the cyclomatic complexity of the solution making it even harder to read. And real life code tends to be more complex than this.
For solution 2:
We did:
For solution 4:
What did we do:
For solution 5:
What did we do:
For solution 6:
What did we do:
The code: https://gitlab.com/danbunea/improving-control-flow-in-code-using-functional-pipelines/tree/stage-2
The merge request in gitlab: https://gitlab.com/danbunea/improving-control-flow-in-code-using-functional-pipelines/merge_requests/3/diffs#faff669626dfa73714964353a02a5101dce1b3a7
- to check if the offers are still active. If the aren't we need to return an error
- to update the number of times the offer has been accessed
GET /offer-by-id/:offer-id
Possible results:
- 200 {"offer-id":2, "offer-data":"", "active":true, "requests":1}
- 400 {"errors":["The id you provided is invalid"}]}
- 400 {"errors":["The offer expired"}]}
- 404 {"errors":["The id you provided cannot be found"}]}
We changed the tests:
Then we change the code and we can look at how
For solution 1:
For solution 2:
We did:
- added a new exception
- changed the code inside the try catch block
- changed the code in the catch
For solution 4:
What did we do:
- added two new functions, completely independent
- modified an existing one
- added steps to the pipeline
For solution 5:
- added a new function
- heavily modified an existing one
- added a step to the pipeline
For solution 6:
What did we do:
- added a two new functions
- added them as steps to the pipeline
The code: https://gitlab.com/danbunea/improving-control-flow-in-code-using-functional-pipelines/tree/stage-2
The merge request in gitlab: https://gitlab.com/danbunea/improving-control-flow-in-code-using-functional-pipelines/merge_requests/3/diffs#faff669626dfa73714964353a02a5101dce1b3a7
Debugging
Let's say we need to start logging what is going on. For ifs we may end up with:
Not exactly easy to know where to insert the logging.
But for a pipeline, we just need to insert some tracing function between the steps:
giving us:
or
And the log is like:
Not exactly easy to know where to insert the logging.
But for a pipeline, we just need to insert some tracing function between the steps:
giving us:
or
And the log is like:
Last, let's see where pipelines could be used.
Frontend/Javascript with Promises:
or front end Clojurescript:
or backend Python:
or Java pipelines
The example comes from the book "Functional Style" by our colleague Richard Wild https://functional.works-hub.com/learn/the-functional-style-part-5-higher-order-functions-i-function-composition-and-the-monad-pattern-bc74a?utm_source=blog&utm_medium=blog&utm_campaign=j.kaplan
Frontend/Javascript with Promises:
or front end Clojurescript:
or backend Python:
or Java pipelines
or
The example comes from the book "Functional Style" by our colleague Richard Wild https://functional.works-hub.com/learn/the-functional-style-part-5-higher-order-functions-i-function-composition-and-the-monad-pattern-bc74a?utm_source=blog&utm_medium=blog&utm_campaign=j.kaplan
Conclusion
Typed languages (including Java) you should use Either.
Dynamic languages the best would be pipeline with exceptions.