A Strategy for Scalable Business Logic

Reading Time: 6 minutes

Or: How to kill off the if branch.

Everli has changed a lot in the past years! Not only by growth in raw numbers, but structurally.

  • From just Italy into multiple countries.
  • New kinds of business partnerships and customer relationships.
  • New types of retailers.

(And that’s not even mentioning external changes like new government regulations)

Feature Creep Can’t Be Stopped

So imagine you’ve got some code. It’s readable, it met all the requirements, very elegant!
”Okay code, now meet the real world.”

No matter how good the code was before, requirements change.

  • A new country is added with its own basket of regulations.
  • Or a country brought in a new law – something is different now just for them.
  • Or a retail partner changes their agreement – they need something different just for them.

So you added a small fix, one “little” if branch to handle that special case?

  • Requirements keep changing – until the project is dead.
  • Requirements become more complex over time – usually added, not removed.

You soon end up with a sprinkling, then a heap, then a mountain of if-else branches through your code!

Yes, that is a bad thing.

  • You easily lose sight of the historical reason for each “little” branch, especially across multiple files.
  • Testing every combination of them is a nightmare.
  • Even just reading through them can be a challenge.
    (not naming and shaming any 400+ line methods here, no…)
if, else if, else if, else if, else if, else if…

So, when you write that code?
Expect requirements and business logic to change in the future, in ways you can’t anticipate.
Make change less painful for future developers. (Let’s be honest, probably it will be you)

Putting Decoupling and Cohesion into Practice

These should be familiar terms to any student of program design. In short;

  • Decoupling: Keep different things away from (and independent of) each other,
  • Cohesion: Keep related things together.

Code is easier to change if change happens in just one place.
For scalable business logic; keep it away from the program flow, keep it away from each other.

You gotta keep ’em separated

How can this be done?

1) Generic Program Flow

The program flow (the sequence of steps – doThis(), then doThat(), then doTheOtherThing()) is separated from any of the business-logic-tainted specifics of how those steps are to be performed.

  • It doesn’t even know which implementation should be used.

2) Business Logic

Each specific implementation of business logic (how to doThis() for a particular situation, eg a particular retailer + country + phase_of_moon) lives in its own space.

  • It’s unaware of the wider program flow.
  • It’s unaware of any other implementations.

3) Self-Selection

Each specific implementation of business logic is self-selecting – it decides whether it should be used for a particular situation.

  • Selection and implementation are kept together.

4) Clear Separation

The point where core program flow and specific implementations interact has a clear interface.

  • From a program-flow point of view, it’s clear what the implementation should do.

5) Isolated Changes

A change to the program flow can be made in isolation of any specific implementation.
A change to a specific implementation can be made in isolation of the core logic.

  • Changes happen in just one place.

Show Me Some Code!

Business Logic is a solitary animal, and prefers an enclosure of its own…

and the core program flow becomes:

Okay, you still need one if statement…

So that’s basically it.
Calling back to the points above:

  1. :white_check_mark: Generic Program Flow
    The core-logic code calls resolve(). It doesn’t know anything about which implementation it’s using.
  2. :white_check_mark: Business Logic
    Each implementation is isolated into its own class, and handles just that one set of business logic.
  3. :white_check_mark: Self-Selection
    Each implementation chooses whether to execute some task.
  4. :white_check_mark: Clear Separation
    Interaction between core-logic and implementations is defined by the interface.
    That interface is free to define additional methods, parameters, return types beyond just the accepts() method.
  5. :white_check_mark: Isolated Changes
    Changes are almost completely independent.
    A change to the Blue implementation? Modify only the Blue class.
    A new Purple implementation is added? Create the Purple class, add Purple to the list.

Final Implementation (Laravel)

Many of our projects use the Laravel framework, so let’s talk briefly about our implementation of this pattern.

The Service Container is the right place to keep the list of implementations, using the tagging feature.

  • Laravel has a mechanism for this – use it.
  • The core program flow doesn’t know anything about the implementations.
  • During unit tests the implementations can be completely swapped out for a test mock.
    This gives the ability to test core program flow in complete isolation.

1) Tag the implementations with their interface:

<?php declare(strict_types=1);
namespace App\Providers;
...

final class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot(): void
    {
        ...
        
        // Specific color implementations - use first that accepts
        $this->app->tag([
		    DarkGreen::class,
			Green::class,
			Red::class,
		    Blue::class,
		], ColorInterface::class);
    }
}

2) Find the right implementation for that context:

/**
 * Fetch the correct Color implementation for this context.
 *
 * @param Context $context
 *
 * @return ColorInterface
 */
protected function resolveColor(Context $context): ColorInterface
{
    /** @var ColorInterface $color */
    foreach (app()->tagged(ColorInterface::class) as $color) {
        if ($color->accepts($partner)) {
            return $color;
        }
    }

    throw new LogicException("No color found for context {$context->toString()}");
}

3) Call the right implementation, without needing to know which:

/**
 * Some important program flow.
 */
public function doTheThing()
{
    ...
		
    $color = $this->resolveColor($context);
		
    $color->execute($context);
    // and call other methods, as needed

    ...
}

easy.

Extra: Unit Testing Helper

One limitation of the Laravel container is that it doesn’t allow existing tags to be replaced, only appended.
(Making it difficult to inject a test mock into the container)

For unit testing, a little reflection can overcome this and allow replacement.
Include this trait in unit test classes that need that ability.

ModifiesContainer.php

<?php declare(strict_types=1);

namespace Tests;

use ReflectionClass;

/**
 * Helpers that alter the behaviour of the application container.
 *
 * @see https://laravel.com/docs/6.x/container
 */
trait ModifiesContainer
{
    /**
     * Replace an existing tag with the given mapping.
     *
     * (Provides a missing feature - the container doesn't allow tags to be removed, only added)
     *
     * @see https://laravel.com/docs/6.x/container#tagging
     *
     * @param array<object|string> $classes List of class names or instances
     * @param string $tag Tag to replace
     *
     * @return void
     */
    private function replaceTag(array $classes, string $tag): void
    {
        foreach ($classes as $i => $class) {
            if (is_object($class)) {
                $this->app->instance(get_class($class), $class);
                $classes[$i] = get_class($class);
            }
        }
        
        $appReflection = new ReflectionClass($this->app);
        $tagsReflection = $appReflection->getProperty('tags');
        $tagsReflection->setAccessible(true);
        
        $tags = $tagsReflection->getValue($this->app);
        $tags[$tag] = $classes;
        $tagsReflection->setValue($tags);
    }
}

So that’s it!

Some Finer Points

  1. The order of the classes can be important.
    As shown above, the DarkGreen class must be placed before the Green class.
    (Because it is a specialization of the Green case)
  2. One implementation can still inherit from another.
    DarkGreen does mostly the same thing as Green, so delegates back to the parent for shared behavior.
    (This will add a dependency between those two classes, so the usual warnings apply)
  3. This pattern can be used even when there’s only a single implementation available!
    In some cases we use this pattern, confident that it will be needed in the near future.
    In a different circumstance, the program might want to run all accepting implementations.
    This begins to look like a publish-subscribe pattern, or could be built with a method like resolveAll returning a collection of accepting implementations.

We strive to create software that is not only efficient for our stakeholders and customers, but that solves problems they don’t even know they will have.

If you think you are brave enough, and you have what it takes to solve it, visit our careers site to see our current openings.

Author: @MrTrick

Leave a Reply

Your email address will not be published. Required fields are marked *