UP | HOME

Joschka Tillmanns

Handling Side Effect Driven Architectures in a Sane Manner

I am currently working on a large scale web application that for the most part consists of one large monolith. Working with such an architecture requires hard constrains because you will have a large group of developers working on the same code base everyday. Especially with a language like PHP where you miss out on compiler specific code checks one of those constrains needs to be a high test coverage. Secondly you want to provide a single approach to deal with all kinds of data handling while also not making everyones life a overly complicated through emposing high verbosity on edge cases.

Concept

We want to create a framework that steers every developer into the same direction by requiring a specific codestyle that is simple, maintainable, testable while also provides ways to deal with legacy code that does not persue the same ideology. The approach I am going to introduce in this article is based on a single ideology:

Everything we do in code should be accepting some data and process it into some other form. We get data in and spill data out.

Lets assume some example: I want to classify some customer ticket. Based on that ticket and it's properties I return a category that provides the frontend with the ability to deal with the ticket in a specific manner. This function should be extremely easily testable as for some input I always get the same output.

This assumption holds true as long as we don't have to deal with side effects such as writing to the database or writing an email. Everytime we do that we have to either create compilcated mocks or write integration tests which have it's own set of problems. Also when writing side effect driven code the developer is tempted to not adhere to the basic principal that we want to introduce: Everything we do is take some input and generate some output. As an example I often see code like this:

class Updater {
    private $database;

    public function updateSomeEntity($entity, $property) {
    $entity->setProperty($someProperty) = $property;
    $this->database->write($entity);
    }
}

The problem is that this function is very hard to test correctly. The PHP ecosystem has introduced testing frameworks like PHPUnit which allow for concepts like this:

class UpdaterTest {
    public function testUpdateSomeEntity() {
    $entity = mock(Entity::class);
    $entity->shouldReceive('setProperty')->withArgument('foobar')->once();
    $property = 'foobar';

    $database = mock(Database::class);
    $database->shouldReceive($entity)->withArgument($entity)->once();

    $updater = new Updater();
    $updater->updateSomeEntity($entity, 'foobar');
    }
}

I think this doesn't comply with any premise that exists in terms of unit testing. First of all: you are not first and foremost testing the intended behavior of the function but it's actual procedural implementation. When this class get's more complex your tests will grow more complex as well. However your test should never be dependent on the implementation but on the output. Secondly you are not doing any explicit assertions which is what your unit tests should really be all about. If this function would simply update the entity and return it the test function would be a lot simpler while still testing the required behavior.

One fact however remains: We need to actually include side effects into our code, how else can we update the state the application is in? If we don't do any side effects the world our application lives in is going to get stuck. To address this issue I want to introduce the framework Effects which provides solutions to all previously described problems.

Implementation

As previously described, I want to provide a single solution to all future feature implementations. This approach should always be: take some input and transform it into some output. You will find that the implementation of the framework is fairly simple and doesn't really contain any logic. It is more of a very opinionated set of rules that steers developers into the right (and unified) direction.

Generally I can observe multiple layers of library classes.

  1. Classes that get called directly by the controller
  2. Branching classes that are direct or indirect dependency of controller near classes.

Those branching classes are often not problematic to define as pure side effect free implementations as they are mostly variations of the ticket classification example I gave above. Thus they are not directly dependent on the framework itself.

However classes which are near the controllers often have to deal with updating the world which introduces side effects. To deal with this Effects introduces an imperative inspired solution. Instead of making the side effects in place you return how the world should be updated. Any such description is implementing an Effect

interface Effect
{
    public function getHandle();
}

which simply returns an handle which describes the change and lets the effect handler make the actual side effect.

interface EffectHandler
{
    public function run(Effect $effect);
}

Understanding this you also understood nearly all of the codebase that Effects provides. (yay!)

As an example let's look at some method that has to log some event. Logging usually requires writing to some synchronized global state, such as a database or a file, hence side effects. Following the approach of Effects we won't write the actual log entry ourselfs, but return an Effect that describes how the world needs to be changed. To do this we first implement the Effect and the corresponding EffectHandler.

class LogEntry implements Effect
{
    private $message;

    public function __construct(string $message)
    {
        $this->message = $message;
    }

    public function getHandle(): string
    {
        return $this->message;
    }
}

class LogHandler implements EffectHandler
{
    public function run(Effect $effect)
    {
        $log = new Logger();
        if (!is_string($effect)) {
            return;
        }

        $log->log($effect);
    }
}

Now in the function that needs to actually introduce the log entry we want to return the Effect that describes the world change, which is logging our event.

class MyLibrary {
    public function createUser(): EffectList {

    $messge = 'successfully created new user';
    return new EffectList(new LogEntry($message));
    }
}

Now there is a controller which actually initializes this action.

class Controller {
    private $effectRunner;
    private $myLibrary;

    public function createUserAction() {
    $effectList = $myLibrary->createUser();
    $effectRunner->play($effectList);
    }
}

So what did we create here? The library method initiated writing to some log stream in a maintainable and testable manner. As a user of the MyLibrary API I am also directly able to see what the method does. To complete the example let's examine the appropriate test:

class MyLibraryTest extends TestCase  {
    public function testCreateUser() {
    $effectList = (new MyLibrary())->createUser();

    [$logEntryEffect] = $effectList->getArrayCopy();
    $message = $logEntryEffect->getHandle();

    self::assertEquals('successfully created new user', $message);
    }
}

As you can see we have some actual assertion (yay!) that tests the required behavior of the method.

Providing interfaces to legacy libraries

While this is all fine and dandy I stated at the beginning that I want to deal with a large monolothic application that will not behave in this manner at all. So how to deal with that?

Previously I promised that Effects will also provide an interface to existing legacy libraries that do not assume the same architecture.

Doing so does not reqiure any special workarounds. We can do this very easily by dealing with those libraries as we dealt with the Logger API in the example above. Hence We only need to provide an Effect and the corresponding EffectHandler for each of those libraries. This will allow us to also write code that uses those libraries and still interface with all the code that provides us with the functionality noone has time to reimplement or refactor.

Conclusion

You may have recognized that this idea is heavily based on concepts introduced by functional programming. This is no coincidence as I believe that any large scaled application can best handle problems that arise during growth by adhering to those concepts. In fact the framework is fully based on ideas introduced by the re-frame project which is a clojurescript frontend framework. By providing the developer with a framework that is very similar to the concepts that those guys provide I think any codebase is able to grow and scale exponentially while still being maintainable as well as testable.