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.
- Classes that get called directly by the controller
- 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.