Remove all side-effects from your business logic with pipelines and yield

Mocking is a pain in the ***, even if you use PHPUnit. I present an alternative way to do side-effects and mocking, by combining the pipeline pattern, the command pattern and generators.

Use-case: With a simple Yii 2 console controller, fetch a user from the database, revert its admin status, and print the result.

Code:

public function actionIndex($userId = 1)
{
    $io = new IOFactory();  // TODO: Inject
    return [
        $io->queryOne('SELECT * FROM users WHERE id = ' . $userId),
        new FilterEmpty($io->printline('Found no such user')),
        /**
         * @param array $user TODO: Filter to convert array -> object
         * @return int
         */
        function (array $user) use ($io) {
            yield $io->printline('Yay, found user!');
            $becomeAdmin = $user['is_admin'] ? 0 : 1;
            yield $io->query(
                sprintf(
                    'UPDATE users SET is_admin = %d WHERE id = %d',
                    $becomeAdmin,
                    $user['id']
                )
            );
            return $becomeAdmin;
        },
        /**
         * NB: If previous closure has both yields and return, two arguments will be sent to next closure.
         * @param int $rowsAffected Result from last yield
         * @param int $becomeAdmin Result from previous closure's return
         * @return int
         */
        function ($rowsAffected, $becomeAdmin) use ($io) {
            if ($becomeAdmin === 1) {
                yield $io->printline('User is now admin');
            } else {
                yield $io->printline('User is no longer admin');
            }
            return ExitCode::OK;
        }
    ];
}

Instead of injecting a repository, model or database connection, we inject a side-effect factory class (shortened to $io). This can be replaced with a very simple mock to replace the results of the executed commands. E.g., to mock the first query, we simply replace $io with

    $io = new Mock(
        [
            0 => ['id' => 1, 'is_admin' => 0],
        ]
    );

where 0 is “side-effect number 1”, and the array the result from the query. The other side-effects can be omitted since the code doesn’t really depend on anything else (UPDATE query and stdout).

So, pretty cool. :smiley: Only a small change was needed to the Yii controller to make it return a pipeline.

Example output:

~/pipe/basic$ ./yii hello 1
Yay, found user!
User is now admin
~/pipe/basic$ ./yii hello 1
Yay, found user!
User is no longer admin
~/pipe/basic$ ./yii hello 2
Found no such user
~/pipe/basic$

Full code: https://github.com/olleharstedt/piiype

If you want to move code to a service class, you’d have to make it yield too, and then do yield from $service->doSomething() in the closure to propagate the side-effect commands to the top.

Things that can be done in the future:

  • Add support for rollback at failure
  • Add caching
  • Middleware interaction

Things I didn’t figure out:

  • Multiple database connections
  • A more complex use-case involving HTTP, curl, database and file IO