instead of passing in mocks of some sort during testing, you put the effectful code in some other function call and have some sort of parent orchestrator that runs the effects and passes the results into your (now pure) business logic code.
I write most of my backend code this way.
The top-level functions are orchestrating between pure and impure. They don’t make side-effects; they call other functions that do. They don’t have business logic, they call other functions that do. They only orchestrate between pure and impure.
Then my pure functions do all the business logic. And my impure functions the side-effects.
The only functions that are a mix of pure/impure are those top-level orchestration functions. Those one I might test by mocking the impure functions, to test the orchestration, or I might just have integ tests only.
If you have some pure logic that depends on impure, it would be encoded in those top-level. Also, they are the only functions that are also allowed to access globals (though you could make them into a Component and inject those if you cared).
(defn do-x
[input]
(let [queried-z (get-z (:something input) (:something-else input))
data-z (commit db queried-z)]
(if (should-y? data-z)
(transform data-z)
data-z)))
do-x
references the globaldb
stateful resource.do-x
orchestrates between pure and impure, but has no logic apart from the branching conditionsdo-x
does not include the clauses, a pure function is used for that, theshould-y?
function is where the rules for when to transform the data are implemented, not indo-x
get-z
is a pure function, it returns a command that allows you to get z, but does not actually commit the side-effectcommit
will use that command to apply the side-effect and actually fetch the z data from the DB