Skip to main content

Introducing the Specialized Workers pattern

Introduction

Some years ago, in my blog post about using enums in C# I mentioned that I like using what I called at that time specialized builders to refactor the switch statement into a series of classes that implement a common interface and are specialized for a specific case of the switch statement.

For the past few years I have been showing to my colleagues a slightly improved version of this approach (the topic of this post) and they all seem to agree that this is indeed a cleaner, and more elegant approach which, unlike the infamous switch statement does abide by the Open/Closed principle.

As such, I am excited to introduce to the world the Specialized Workers pattern.

Specialized Workers Pattern

Description and usage

The Specialized Workers pattern aims to distribute the logic of a business operation that requires choosing between one or multiple tasks to be executed, into a collection of specialized classes, while keeping the knowledge about the task(s) to be executed into a single place.

Under these circumstances each worker knows about the data just enough to know whether it can accomplish the task, and how to accomplish it.

Usually (at least from my experience), the decision to delegate a certain sub task was made by the unit that is in charge of delegating. In an abstract representation the code would look something like this:

if(SomeCondition())
{
    DoWorkForConditionAbove();
}

where the method that contains the snippet above delegates the processing to the DoWorkForConditionAbove() method.

However, when the decision whether to delegate to a specific unit or not depends on the data that is to be processed, taking that decision couples both the delegator, and the worker unit to the structure of the data.

It is normal for the worker to be coupled to the structure of the data because it has to know the structure in order to be able to process it but the delegator, which now becomes a coordinator, doesn't have to know the structure of the data.

Consider the example of an unit that has to parse an HTML element and has to make the following decision:

  • if the current node is a div then ParseDivElement() should be called,
  • if the current node is a table then ParseTableElement() should be called, and
  • if the current element is none of the above, it should just ignore it.

Put differently than at the beginning of this section, the purpose of Specialized Workers pattern is to:

  • delegate the work to a single place, and
  • enclose in a single place the knowledge about being able to perform a specific task and the knowledge required to perform that task.

Implementation

Now that we established what Specialized Workers pattern is, let's see how it can be implemented.

It goes without saying that when implementing this pattern, the amount of work directly depends on how many workers the business case requires (i.e. how many switch cases are there) but, in broad lines, to implement the Specialized Workers pattern you'll need to:

  • Define a interface for the worker exposing two methods — CanProcess(), and Process():
public interface ISpecializedWorker
{
    bool CanProcess(Payload payload);

    Result Process(Payload payload);
}
  • Add a class for each use-case:
internal class MondayPayloadWorker: ISpecializedWorker
{
    public bool CanProcess(Payload payload)
    {
        return payload.DayOfWeek = DayOfWeek.Monday;
    }

    public Result Process(Payload payload)
    {
        return ProcessInternal(payload);
    }
}
  • Inject the workers into the calling class (the Employer), and delegate:
internal class Employer
{
    private readonly IEnumerable<ISpecializedWorker> _workers;

    public Employer(IEnumerable<ISpecializedWorker> workers)
    {
        _workers = workers;
    }

    public Result Process(Payload payload)
    {
        var worker = _workers.SingleOrDefault(
            w => w.CanProcess(payload));

        // This is the equivalent of the default case
        // in the switch statement.
        if (worker == null)
        {
            throw new ArgumentException(
                "Cannot process the provided payload.");
        }

        return worker.Process(payload);
    }
}

And that's all there is to it. Now, the Employer class is agnostic of how the payload is processed; it just delegates the processing to the worker that can handle it. If no workers that are able to process the payload are found, the Employer class can choose to signal this by either raising an exception, returning a default value or any other mechanism that is suited to the other patterns used in the code-base. More on this in section Adaptations. Furthermore, the Employer class doesn't have to know how many workers are there; as such, the workers can be added or removed without any change to the Employer class, which means that the class is decoupled from the workers.

On the other hand, each worker class is, as the name of the pattern suggests it, specialized to do one thing — work on the specific use-case it knows all about: whether it can process it, and if yes, then it also knows how to process it.

Discussion

How is it different from the Strategy pattern?

At this moment you may be wondering how the Specialized Workers is different from the Strategy pattern? After all, each worker implements and applies a different strategy in the processing of the data.

To put it simply, the Specialized Workers pattern is not different from the Strategy design pattern; it evolves from it, with the added behavior that the caller (coordinator — the Employee class from above) doesn't have to know explicitly which worker to employ. The worker to employ is selected based on its knowledge of the payload (i.e. based on the workers' "expertise") which, as mentioned before, makes the coordinator agnostic of the payload. As such, all the knowledge that is related to how to do a specific processing is kept in the same class where the processing happens.

There is however, a difference in the nomenclature: Strategy is a design pattern whilst Specialized Workers is an implementation pattern. The difference between a design pattern and an implementation pattern deserves a dedicated post but to put it shortly, an implementation pattern tells you how you should write your code while a design pattern specifies how the application should be structured.

Why not use a Factory method?

Okay, you might say, then why not use a Factory method to build directly the worker that knows how to handle the specific use-case?

Well, because the answer to this question is actually one of the benefits that come with implementing the Specialized Workers pattern, namely that it keeps the specialized logic within the same class.

When applying the Factory method, the decision on which instance to build is separated from the actual processing that needs to take place. From the point of view of the separation of concerns this is OK; however one might argue that, in order to decide which worker to build, the factory has to either apply some business knowledge, or be coupled to the data (by being aware of its structure). When applying the Specialized Workers pattern, all the business logic that is coupled to the data is in a single place, i. e. the class of the specialized worker.

Furthermore, when implementing the Specialized Workers pattern, you don't need to create instances of workers by hand as you do with the Factory method; the creation of the workers can be delegated to the Dependency Injection frameworks.

However, if the instantiation of the specialized workers depends o some parameters that cannot be easily built using the Dependency Injection framework, you'll need to use Factory method. In this case you can combine these two approaches: use the Factory method to build the specialized workers, and then pass them to the coordinator class that needs them. Keep in mind that this approach of combining the two patterns works if building each worker is an inexpensive operation; otherwise you'll end-up spending resources to create instances that may not be used.

Drawbacks

As we all know, there are no perfect solutions, especially in software development. This is also the case for the Specialized Workers pattern, and as such, it has a few drawbacks listed below.

No guarantee of the same parameters

The first drawback of the Specialized Workers pattern stems from the fact that the methods CanProcess(), and Process() are not constrained in any way to be called in the specific order they are meant to be called. Furthermore, there is also no guarantee whatsoever that these methods are called with the same parameter. The lack of constraints on the order of the calls, and on the parameters means that the caller may choose to ignore the results of the CanProcess() method, or not call it at all, and then invoke the Process() method.

There are (at least) two ways to work around this misuse: to combine the two methods into a single one, as presented in sub-section Using a single method, and to simply guard against invalid arguments using Debug.Assert() or any of its equivalents:

public Result Process(Payload payload) //
{
    Debug.Assert(CanProcess(payload));

    // Do the work
}

Sensitivity to collections

While using the Specialized Workers pattern, you should be cautions when calling CanProcess() on collections. Ideally, the method CanProcess() should take the decision without iterating any collection of items. There are two intertwined reasons for that: performance, and lazy loading.

If you have a heterogeneous collection, you can iterate through it in the delegator and call CanProcess() on each item in the collection. At the end, the delegator aggregates the results.

class Employer
{
    private readonly IEnumerable<ISpecializedWorker> _workers;

    public Employer(IEnumerable<ISpecializedWorker> workers)
    {
        _workers = workers;
    }

    public Result ProcessCollection(IEnumerable<Item> collection)
    {
        var partialResults = new List<Result>();
        foreach(var item in collection)
        {
            var itemResults = _workers
                .Where(w => w.CanProcess(item))
                .Select(w => w.Process(item));
            partialResults.AddRange(itemResults);
        }

        return partialResults.Aggregate(/*...*/);
    }
}

This ensures that the collection is iterated only once thus avoiding any odd results due to lazy evaluation.

Adaptations

Despite its drawbacks, the Specialized Workers pattern is quite flexible in its implementation. As such, it can be adapted for some specific scenarios discussed below. Of course, it goes without saying that the list is not exclusive.

Using a single method

For the cases when the processing is lightweight, you can combine the two methods into a single one that returns a tuple like this:

public (bool canProcess, Result result) Process(Payload payload)
{
    if (!CanProcess(payload)) {
        return (false, default(Result));
    }

    Result result = ProcessInternal(payload);
    return (true, result);
}

private bool CanProcess(Payload payload)
{
    // take decision here
}

As mentioned in the sub-section No guarantee of the same parameters, combining the two methods into a single one guards against calling the CanProcess() and Process() methods in the opposite order or with different parameters.

Non-exclusive workers

You can have multiple workers capable of processing the same payload; in this case, the caller (the delegator) is responsible for aggregating the results:

var results = _workers.Where(w => w.CanProcess(payload))
    .Select(w => w.Process(payload))
    .ToArray();

Ending thoughts

As you can see from this quite lengthy post, the Specialized Workers pattern, provides both a way to cluster business logic into specialized classes, and a good degree of flexibility in order to adapt the implementation to different situations. Despite its flexibility however, it is my subjective opinion that the original form of the pattern (the one shown in the Implementation section) is the most elegant and eloquent, which is why I use that version most of the time. But, especially as a software developer, I am aware that each person has its own preferences in regards to how something should be done or implemented. As such, I hope you find this pattern useful, and if so, feel free to apply it in whichever way suits you best.

Comments

Comments powered by Disqus