Implementing Pipeline Design Pattern using C#

Introduction

In this article, we will understand what is Pipeline design pattern and how we can implement it using C#.

Pipeline design pattern using C#

What is a Pipeline design pattern?

A pipeline design pattern is a software design pattern that process or executes a series of steps or stages in a linear sequence. It allows you to break down complex tasks into smaller and modular steps or stages that can be executed in order. Each step is taking input from the previous step, executes its specific functionality, and produces output for the next step.

The pipeline design pattern promotes the separation of concerns and improves maintainability by encapsulating each step’s logic in a separate component or class. It also enables easiness in extensibility and flexibility as new steps can be added, and existing steps can be modified without affecting the overall pipeline.

Key Elements of the Pipeline design pattern

  • Steps: Steps are the individual components or classes which perform a specific task on the input data.
  • Input and Output: Each step is taking input, processes it, and producing output data for the next step.
  • Order: Steps are performed in a predefined order while setting up the pipeline.
  • Context: Context is an optional shared object that can be passed between the steps, containing data or a state relevant to the entire pipeline.

Pipeline Design Pattern in C#

Let’s look at some real-world scenarios. Suppose we are working on a project which has some Image Processing functionality. It has several features like Loading, Filtering, and Saving an Image. These three features may have some complex and detailed implementation like:

  • Loading Image has different strategies to load images like loading from local computer, from google drive, from the Azure blob, etc.
  • Filtering Images have strategies like Blur filtration, Sepia filtration and Greyscale filtration.
  • Saving Images have different logic for storing images, like to store with some image format, convert it into pdf, and store them. And storing it on local machine or on cloud.

These are the features that our Image Processor. These three features are completely different and can be implemented as separate modules.

First, we have an Image class.

// Image class
public class Image
{
    // Properties and methods of the image
}

Then we have an IPipelineContext interface which may have some common methods and properties.

// Pipeline context interface
public interface IPipelineContext
{
    // Common context properties and methods
}

We have an IPipelineStep interface which has a method Execute, and every step of the pipeline will be implementing this interface.

// Pipeline step interface
public interface IPipelineStep
{
    void Execute(IPipelineContext context);
}

Now we are implementing our first step, the ImageLoading step. And it is implementing the IPipelineStep interface.

// Image loading step
public class ImageLoadingStep : IPipelineStep
{
    public void Execute(IPipelineContext context)
    {
        // Load the image from a file or any source
    }
}

The next step is the ImageFiltration step, which is also inherited from the IPipeline step.

// Image filtering step
public class ImageFilteringStep : IPipelineStep
{
    public void Execute(IPipelineContext context)
    {
        Image image = context as Image;
        if (image != null)
        {
            // Apply the filtering strategy to the image
        }
    }
}

The final step is ImageSavingStep, which is also implementing IPipelineStep.

// Image saving step
public class ImageSavingStep : IPipelineStep
{
    public void Execute(IPipelineContext context)
    {
        Image image = context as Image;
        if (image != null)
        {
            // Save the image to a file or any destination
        }
    }
}

We have implemented the steps; next thing is to create the pipeline called ImageProcessingPipeline. This pipeline has a method to AddStep and another method ExecutePipeline.

// Image processing pipeline
public class ImageProcessingPipeline
{
    private readonly List<IPipelineStep> _steps;

    public ImageProcessingPipeline()
    {
        _steps = new List<IPipelineStep>();
    }

    public void AddStep(IPipelineStep step)
    {
        _steps.Add(step);
    }

    public void ExecutePipeline(IPipelineContext context)
    {
        foreach (var step in _steps)
        {
            step.Execute(context);
        }
    }
}

We have created the pipeline and steps. Now we are going to use this pipeline like how we can use it in our code.

//creating the Image object
Image image = new Image();
        
//creating and initializing pipeline
ImageProcessingPipeline pipeline = new ImageProcessingPipeline();
        
//creating ImageLoadingStep and adding in our pipeline
pipeline.AddStep(new ImageLoadingStep());

//creating ImageFilteringStep and adding in our pipeline
pipeline.AddStep(new ImageFilteringStep());

//creating ImagesAVINGStep and adding in our pipeline
pipeline.AddStep(new ImageSavingStep());

//when all steps are created, calling executepipeline function which will execute all steps
pipeline.ExecutePipeline(image);

In the above code, we are creating an instance of the Image class. Then we are creating and initialize the pipeline object and add steps. After adding all steps that we need, we are calling the ExecutePipeline function, which will execute the methods of all steps.

It is not necessary to add all steps in the pipeline. We can add more steps, and we can ignore some steps as well. But some steps are not ignorable. For example, ImageLoadingStep is loading an image from some source, if we don’t add and execute these steps, then the Image will not be loaded, and none of the other steps will be executed. 

I hope this example helps you understand pipeline design patterns in the context of Image Processing context.