Strategies for extending the functional core - an effect EDSL
Intro
Pre-reqs:
- Functional core, imperative shell architecture
- Purity, referential transparency, side-effects
- EDSL means embedded domain-specific language
- Abstract syntax-tree (AST)
Rational:
- The functional core is more testable and more composable than the imperative shell or effectful code
- It’s to be desired to extend the ratio of pure methods and functions in a code-base
- Sometimes you can easily extend the functional core by lifting out side-effects to calling code
- Sometimes, the side-effects are entangled inside business logic, and it’s not clear if it’s possible to “purify”
- Another motivation is that mocking is often complex (and boring) to write, and reducing the need of mocking in testing will make your test suite simpler
The EDSL
Consider the pipeline schema read-process-write
, where “read” means reading IO, “process” means pure business logic, and “write” means writing to IO.
- Some times you have
read-process
in one function. Then it’s often easy to lift out the read-part and pass it as an argument instead. - Other times you have
process-write
in one function, in which case you can either mock the writing class in testing, or return a small command object representing the write which is executed in the client code - When
read-process-write
is entangled, it’s sometimes possible to use an effect DSL to represent writes. Details below.
If you have function which does process-write-process-write
, you can delay all writes until after the function is done.
Consider the following use-case: A function to create x number of dummy users from a web request object, save them in database and show a result.
Coded in PHP below, but the pattern is language agnostic.
function createDummyUsers(Request $request, St $st): array
{
$times = $request->getParam('times', 5);
$dummyUsers = new SplStack();
for (; $times > 0; $times--) {
$user = new User();
$user->username = 'John Doe';
// Using the EDSL builder to create an AST
// The AST is not evaluated or executed at this point
$st
->if(save($user))
->then(pushToStack($dummyUsers, $user->username));
}
return [
'success' => true,
'dummyUsers' => $dummyUsers
];
}
The interesting part is the $st
object, which is a builder for an AST, in which each node represent an effect like Save
or PushToStack
, or a condition (the if-then-else
node).
The client code looks like this:
$st = new St();
$result = createDummyUsers(new Request($_POST), $st);
(new Evaluator($st))->run();
renderJson($result);
You need an evaluator to run and execute the nodes in the AST. Just as in the event source pattern, we here separate data from behaviour, in which a Save
node can be parsed in different ways (for example, either a database interaction, or a mocked action).
The big benefit of wrapping side-effects in an AST that’s evaluated, is that in your test code, you can use the same dry-run spy-evaluator in all tests. One mock to rule them all.
Unit-testing code:
$st = new St();
$result = createDummyUsers(new Request(), $st);
(new DryRunSpyEvaluator($st))->run();
// ...assert that $result and effects recorded in the spy correlate
// E.g. try to create 5 dummy users, 5 write nodes in the AST
// should correspond to 5 usernames in $result
Full code-listing can be found in this gist.
Related concepts
- Tagless-final
- Builder pattern
- Expression builder
- Fluent interface
- Event sourcing
- Multi-stage programming
Thanks to
Open-source survey tool