1

I'm looking for feedback for a design pattern that aims to remove all side-effects from business logic. I'm using PHP but the pattern can be applied to any OOP language. The point is to enforce pure business logic from the framework, by not injecting any dependencies that have side-effects, e.g. database connections, curl, file, output buffer, etc, and replace them by command objects (see command design pattern) in a pipeline.

The motivation behind removing side-effects is to make testing easier by removing the need for mocking. In general, you can mock pure methods, but you don't have to mock them the same why you have to mock, say, a database connection.

Here's an example of a controller action that reverts the admin status of a user:

function updateUser(int $userId, SideEffectFactoryInterface $make)
{
    return [
        $make->query('SELECT * FROM user WHERE id = ' . $userId),
        function ($user) use ($make) {
            $reverted = $user->is_admin ? 0 : 1;
            return [
                null,
                $make->query(
                    sprintf(
                        'UPDATE user SET is_admin = %d WHERE id = %d',
                        $reverted,
                        $user->id
                    )
                )
            ];
        },
        $make->output('Updated user')
    ];
}

As you can see, all side-effects are created as command objects from the SideEffectFactory.

When running the list of callables, there will be a dependency resolver so that query objects get their database connection, file readers their file IO methods, etc.

Of course the question is if mocks will be easier, and if it's worth it since readability might suffer.

8
  • 2
    sprintf('UPDATE user SET is_admin = %d WHERE id = %d', $reverted, $user->id). Please, please, please, don't do that. Jul 21, 2020 at 16:55
  • 1
    You might be interested in the way Haskell uses monads to do this. Jul 21, 2020 at 17:07
  • Or more generally, the idea of encapsulating side-effects in values is one of the core building blocks of functional programming. Jul 21, 2020 at 17:08
  • 1
    @PhilipKendall No injection possible, %d casts to digits. %s would be injection. Jul 21, 2020 at 17:50
  • 1
    @OlleHärstedt Just don't do it anyway. You may have thought carefully enough about the use cases here that it's safe, but if you habitually use string interpolation into SQL queries you will screw it up one day. Just do the right thing every time, it's not hard. Jul 21, 2020 at 18:44

2 Answers 2

2

Your aim to avoid having to mock databases and similar external services is good. But the way you approached it is, in my opinion, flawed.

While you have hidden the technicalities of accessing the database behind the SideEffectFactoryInterface, your updateUser function still contains the knowledge that a User object is stored in a database and how it is stored there. This mixes low-level knowledge about storage with high-level knowledge that updating a User involves inverting its admin status.

A more common way to make the business logic testable without mocking is to make the business logic work only with objects in memory and to make it completely unaware of how those objects got there or what happens to them after the business logic has done its thing.

Translating your code to that format would yield a set of functions like this:

function updateUserController(int $userId)
{
  // This function integrates the business logic with the supporting
  // modules for interfacing with external systems.

  // $userRepository knows about databases and how User objects are stored there
  $user = $userRepository->get($userId); 

  updateUser($user);

  $userRepository->save($user);
}

function updateUser(User $user)
{
  // This function contains the business logic
  $user->is_admin = $user->is_admin ? 0 : 1;
}

The updateUser function can be tested completely without mocks. The updateUserController function could be tested with a mock for the $userRepository, but the function should be straightforward enough that that is not needed and that the function is only tested when integrating with a real database.

3
  • So far so good, but what about other side-effects, like curl, read/write to the file system (e.g. unzipping a file), logging, etc? Jul 22, 2020 at 14:17
  • @OlleHärstedt, except for logging, all of those can be done before and/or after calling the business logic function, providing the business logic with an in-memory representation. Or at worst a stream of data. For logging, I don't really have a solution to take it out of the business logic, but that is often ignored in testing anyway. Jul 22, 2020 at 14:24
  • 1
    OK, so two points: 1) Is it possible to enforce this on a framework level? 2) If you have multiple writes, you might end up with something like read-compute-write-compute-write. You can collect all writes in one package by using the command pattern, but that's getting close to what I've written above. Remember, to enforce removing side-effects from business logic, you must restrain from injecting anything in the business logic except a command factory (what I call SideEffectFactory). Jul 22, 2020 at 16:24
0

if it's worth it

Not in the way you're writing it, IMO.

You're essentially writing a compiler. A compiler converts high level business logic into low level instructions to be run by an interpreter. A compiler can be written as a pure function, but interpreter is necessarily where all the side effect actually happens.

All is good, but the way you currently structure your compiler have two flaws:

  1. You allowed the compiled code to return a function object. This means that you cannot easily test the return value of the compilation process as that function object is a blackbox. If your compiled code only contains only plain old data, then you can write tests that asserts that updateUser(5) == $expectedCompiledCode, but because you are returning a callable, you can't really do that.

  2. Your compiler takes a side effect factory. I don't know what this class does, but this looks like an injection that is probably not necessary as the compilation process itself should be free of side effect.

Also, you can't really remove side effect. At some point, you'll need to have an interpreter actually performs the intended side effect. You're really just moving side effect around, now you have the option between running those side effect in a virtual machine that you design and developed, or why not just rely on the well tested PHP interpreter and just write straight PHP code and accept that side effect need to happen.

Another lighterweight approach you could've taken to avoid needing to developing a full blown interpreter is to just have your compiler generate PL/SQL code (or whatever equivalent for your database system).

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.