Of course, ideally, it’s better not to write extra code at all . And if you write, then, as you know, you need to think well bones system system architecture and implement meat system system logic. In this article we present recipes for convenient implementation of the latter.
We will provide examples for the Clojure language, but the principle itself can be applied in other functional programming languages (for example, we use exactly the same idea in Erlang).
Idea
The idea itself is simple and is based on the following statements:
- any logic always consists of elementary steps;
- for each step, certain data is needed, to which it applies its logic and produces either a successful or unsuccessful result.
At the pseudo-code level, this can be represented as:
do-something-elementary(context) -> [:ok updated_context] | [:error reason]
Where:
do-something-elementary
- the name of the function;context
is a function argument, a data structure with an initial context from which the function takes all the necessary data;updated_context
- data structure with updated context, with success, where the function adds the result of its execution;reason
- the data structure, the reason for failure, in case of failure.
That's the whole idea. And then - the matter of technology. With 100,500 million parts.
Example: user buying
Let's write the details on a specific simple example, which is available on GitHub here .
Suppose that we have users with money and lots that cost money and which users can buy. We want to write the code that will conduct the purchase of the lot:
buy-lot(user_id, lot_id) -> [:ok updated_user] | [:error reason]
For simplicity, we will keep the amount of money and user lots in the user structure itself.
For implementation, we need several auxiliary functions.
Function until-first-error
In the overwhelming number of cases, business logic can be represented as a sequence of steps that need to be done before an error has occurred. For this we will create a function:
until-first-error(fs, init_context) -> [:ok updated_context] | [:error reason]
Where:
fs
is the sequence of functions (elementary actions);init_context
is the initial context.
You can see the implementation of this function on GitHub here .
with-result-or-error
function
Very often, the elementary action is that you just need to perform some function and, if it succeeds, add its result to the context. To do this, let's get the function:
with-result-or-error(f, key, context) -> [:ok updated_context] | [:error reason]
In general, the sole purpose of this function is to reduce the size of the code.
And finally, our "beauty" ...
The function that implements the purchase
1. (defn buy-lot [user_id lot_id] 2. (let [with-lot-fn (partial 3. util/with-result-or-error 4. #(lot-db/find-by-id lot_id) 5. :lot) 6. 7. buy-lot-fn (fn [{:keys [lot] :as ctx}] 8. (util/with-result-or-error 9. #(user-db/update-by-id! 10. user_id 11. (fn [user] 12. (let [wallet_v (get-in user [:wallet :value]) 13. price_v (get-in lot [:price :value])] 14. (if (>= wallet_v price_v) 15. (let [updated_user (-> user 16. (update-in [:wallet :value] 17. - 18. price_v) 19. (update-in [:lots] 20. conj 21. {:lot_id lot_id 22. :price price_v}))] 23. [:ok updated_user]) 24. [:error {:type :invalid_wallet_value 25. :details {:code :not_enough 26. :provided wallet_v 27. :required price_v}}])))) 28. :user 29. ctx)) 30. 31. fs [with-lot-fn 32. buy-lot-fn]] 33. 34. (match (util/until-first-error fs {}) 35. 36. [:ok {:user updated_user}] 37. [:ok updated_user] 38. 39. [:error reason] 40. [:error reason])))
Go through the code:
- p. 34:
match
is a macro for matching values with a pattern from the clojure.core.match
library; - pp. 34-40: we apply the promised
until-first-error
function to the elementary steps of fs
, take the data we need from the context and return it, or throw an error up; - pp. 2-5: we build the first elementary action (to which only the current context will apply), which simply adds the data on the key
:lot
to the current context; - pp. 7-29: here we use the familiar
with-result-or-error
function, but the action that it wraps turned out to be a little trickier: in one transaction, we check that the user has enough money and in case of success we make a purchase (for , by default our application is multithreaded (and who somewhere saw the single-threaded application for the last time?) and we should be ready for this).
And a few words about the other functions that we used:
lot-db/find-by-id(id)
- returns a lot, by id
;user-db/update-by-id!(user_id, update-user-fn)
- applies the update-user-fn
function to the user user_id
(in an imaginary database).
And to test? ...
Let's test this sample application from clojure REPL. We start the REPL from the console from the project root:
lein repl
What we have users with finances:
context-aware-app.core=> (context-aware-app.user.db/enumerate) [:ok ({:id "1", :name "Vasya", :wallet {:value 100}, :lots []} {:id "2", :name "Petya", :wallet {:value 100}, :lots []})]
What we have lots (goods):
context-aware-app.core=> (context-aware-app.lot.db/enumerate) [:ok ({:id "1", :name "Apple", :price {:value 10}} {:id "2", :name "Banana", :price {:value 20}} {:id "3", :name "Nuts", :price {:value 80}})]
"Vasya" buys an "apple":
context-aware-app.core=>(context-aware-app.processing/buy-lot "1" "1") [:ok {:id "1", :name "Vasya", :wallet {:value 90}, :lots [{:lot_id "1", :price 10}]}]
And "banana:
context-aware-app.core=> (context-aware-app.processing/buy-lot "1" "2") [:ok {:id "1", :name "Vasya", :wallet {:value 70}, :lots [{:lot_id "1", :price 10} {:lot_id "2", :price 20}]}]
And "Nuts":
context-aware-app.core=> (context-aware-app.processing/buy-lot "1" "3") [:error {:type :invalid_wallet_value, :details {:code :not_enough, :provided 70, :required 80}}]
On the "nuts" did not have enough money.
Total
As a result, using contextual programming, there will no longer be huge pieces of code (not fit into one screen), as well as “long methods”, “large classes” and “long lists of parameters”. And it gives:
- saving time on reading and understanding the code;
- simplified code testing;
- the ability to reuse the code (including using copy-paste + file finishing);
- simplified code refactoring.
Those. all that we love and practice.