Writing Quality Code Using the Chain of Responsibility Pattern
Moad Fethallah6 min read
At the beginning of my software engineering internship, I had little experience with writing clean code. The features were functional but there was hardly any underlying organization. I was lacking guidelines on what classes to create and how they should interact with each other. That’s when my mentor introduced me to design patterns. These are solutions for creating flexible and maintainable modules.
In the upcoming sections, I will showcase the use of the Chain of Responsibility pattern and how it helped us write scalable and easily testable code on a production-level API.
A design problem
On a project for a renewable-energy provider, I was working on a consumption monitoring app. One goal was to retrieve the user’s activities over a time period. An activity
is a carbon footprint calculated based on energy consumption. To compute the activities, we had to fetch and process the consumption in the following priority order:
- The real consumption (meter reading), retrieved from an external API
- An estimate of the household’s annual consumption, retrieved from an external API
- An estimate calculated from the user’s behavioral data (thereafter labeled macro estimate).
The returned activities are the result of the first of these methods that returns a non-empty array.
A trivial solution
While designing a solution for the presented situation, we have to take into account the maintainability of the code, the simplicity of adding and removing data sources as well as testing our service. An initial solution may consist of cramming everything in one class.
namespace Component\Activity\Provider;
class ActivityProvider {
public function retrieveActivities(\DateTime $startDate, \DateTime $endDate): array
{
if (count(retrieveMeterActivities($startDate, $endDate)) > 0)
return retrieveMeterActivities($startDate, $endDate);
if (count(retrieveEstimatedActivities($startDate, $endDate)) > 0)
return retrieveEstimatedActivities($startDate, $endDate);
if (count(retrieveMacroActivities($startDate, $endDate)) > 0)
return retrieveMacroActivities($startDate, $endDate);
return [];
}
private function retrieveMeterActivities(\DateTime $startDate, \DateTime $endDate)
{
$data = $this->fetchMeterData($startDate, $endDate);
return $this->processMeterData($data);
}
private function fetchMeterData(\DateTime $startDate, \DateTime $endDate)
{
/* Fetch data */
}
private function processMeterData(array $data)
{
/* Process raw meter data */
}
private function retrieveEstimatedActivities(\DateTime $startDate, \DateTime $endDate)
{
$data = $this->fetchHouseholdEstimatedData($startDate, $endDate);
return $this->processHouseholdEstimatedData($data);
}
private function fetchHouseholdEstimatedData(\DateTime $startDate, \DateTime $endDate)
{
/* Fetch data */
}
private function processHouseholdEstimatedData(array $data)
{
/* Process raw estimated data */
}
private function retrieveMacroActivities(\DateTime $startDate, \DateTime $endDate)
{
$data = $this->fetchMacroEstimatedData($startDate, $endDate);
return $this->processMacroEstimatedData($data);
}
private function fetchMacroEstimatedData(\DateTime $startDate, \DateTime $endDate)
{
/* Fetch data */
}
private function processMacroEstimatedData(array $data)
{
/* Process raw macro estimated data */
}
}
The outcome is a service that is hard to follow and maintain. It is also more complex to test the different ways of retrieving the activities.
The Chain of Responsibility approach
The Chain of Responsibility pattern consists of having multiple services (called handlers) and running through them in a determined order to handle a request. Each service decides in runtime to either handle the request or pass it to the next handler. The CoR pattern can be used for instance in multi-stage validation or in managing multiple data providers.
In our case, each data provider (meter reading, household consumption estimate, macro estimate) represents a “handler”. A request is handled if the service returns an array of activities.
To promote loose coupling, the 3 data providers will implement an interface that exposes a retrieveActivities
function.
namespace Component\Activity\Provider;
interface ChainedActivityProviderInterface {
public function retrieveActivities(\DateTime $startDate, \DateTime $endDate);
}
Each of the three services encapsulates the fetching and processing logic that match their respective data sources.
namespace Component\Activity\Provider;
class MeterReadingActivityProvider implements ChainedActivityProviderInterface {
public function retrieveActivities(\DateTime $startDate, \DateTime $endDate): array
{
$data = $this->fetchData($startDate, $endDate);
return $this->processData($data);
}
private function fetchData(\DateTime $startDate, \DateTime $endDate)
{
/* Process Data */
}
private function processData(array $data)
{
/* Process Data */
}
}
namespace Component\Activity\Provider;
class HouseholdEstimateActivityProvider implements ChainedActivityProviderInterface {
// Same structure as MeterReadingActivityProvider
}
namespace Component\Activity\Provider;
class MacroEstimatedElectricityActivityProvider implements ChainedActivityProviderInterface {
// Same structure as MeterReadingActivityProvider
}
Lastly, to link the services, we can use a “main” activity provider in which they are injected and ran through in the injected order. As soon as one of the handlers returns a valid response (a non-empty array in this case), we return its value. Note that the main activity provider handles the case where none of the chained providers returns a valid response.
namespace Component\Activity\Provider;
class ActivityProvider {
/**
* @var ChainedActivityProviderInterface[]
*/
private array $chainedActivityProviders;
/**
* @param ChainedActivityProviderInterface[] $chainedActivityProviders
*/
public function __construct(array $chainedActivityProviders)
{
$this->chainedActivityProviders = $chainedActivityProviders;
}
/**
* @return Activity[]
*/
public function retrieveActivities(\DateTime $startDate, \DateTime $endDate): array
{
foreach ($this->chainedActivityProviders as $chainedActivityProvider) {
$activities = $chainedActivityProvider->retrieveActivities($startDate, $endDate);
if (!empty($activities)) {
return $activities;
}
}
return [];
}
}
Finally, we need to inject the chained activity providers. In Symfony, one neat way of doing it is using the serices configuration file. This makes reordering as well as adding and removing the services an easy task.
# config/services.yaml
services:
# ...
Component\Activity\Provider\ActivityProvider:
arguments:
$chainedActivityProviders: [
'@Component\Activity\Provider\MeterReadingActivityProvider',
'@Component\Activity\Provider\HouseholdEstimateActivityProvider',
'@Component\Activity\Provider\MacroEstimatedElectricityActivityProvider'
]
With this setup in place, we call the retrieveActivities
function of Component\Activity\Provider\ActivityProvider
to retrieve a user’s activities.
The trade-offs of the CoR pattern
Implementing the Chain of Responsibility pattern offered many advantages:
- Testing the main activity provider: We only need to test the behavior of the chain. Given a mocked chain of activity providers, the function should return the first valid result.
- Implementing the open/closed principle: the principle states that the behavior of classes should be changed through extension and not modification. This comes into play when we add, remove, or reorder the activity providers. We can easily change the behavior of the main activity provider by changing the configuration file.
However, the usage of the CoR pattern comes with a few drawbacks, namely:
- Being unaware that the CoR pattern is implemented makes the code harder to understand and debug
- Depending on the used implementation, it is possible to have no handler to fall back to (if none of the defined handlers can handle the request). Always check for the fallback strategy.
Conclusion
In the long run, the Chain of Responsibility pattern helps organize and write modular code. However, it also comes with an initial cost higher than the trivial solution. They become interesting when the implemented system is prone to expansion. Indeed, setting up the chain is the most expensive part, while adding new chained elements keeps a constant cost over time.
The implementation using injections is one of many ways to utilize this pattern. For implementations in other languages, refer to refactoring-guru.
Generally, design patterns should be perceived as blueprints that offer ready-to-use solutions to common problems. Since the human brain is built to work with patterns, understanding these solutions makes the code base more intelligible and easier to work with. However, one should be aware of the layer of complexity they can add due to their initial cost.