DEV Community

Cover image for Open/Closed principle like a boss in C#
Nelson Ciofi
Nelson Ciofi

Posted on

Open/Closed principle like a boss in C#

Since I decided to follow my career in IT as a developer, I have come across countless magical acronyms specifically aimed at ruling the way I write my code. And there is something for everyone, from the beginning of the project to the conception of a small routine, things like DDD, DRY, BDD, SOLID, YAGNI, KISS, TDD, GOF, OOP, etc.

OCP

It turns out that, throughout my professional journey, and there have been about 8 good years, one of these letters has always been a challenge for me. I’m talking about the O of SOLID, the Open-Closed principle. SOLID is an acronym that brings together five principles of object-oriented design, which aim to improve the quality, maintainability and extensibility of the code. Some of these principles already exist implemented implicitly in the programming language that I use, C#, while others are less obvious to be noticed.

Recently I had an enlightenment and managed to perceive and apply a use case for OCP. It all starts with the fact that I don’t like to follow rules very much, but only the programming ones, mainly because most of them came from another context, eventually totally different from the one in which my code is contained. So, I prefer to understand these rules, principles or norms, including understanding the motivations that led each one of them to exist and only then apply them in my work. In the end, the tendency is that my code ends up not looking like what a person usually sees in a day-to-day tutorial on YouTube, but beautifully achieves all the expected objectives.

The original definition of OCP, by Bertrand Meyer in 1988, is as follows:

A module will be said to be open if it is still available for extension. For example, it should be possible to add fields to the data structures it contains, or new elements to the set of functions it performs. A module will be said to be closed if [it] is available for use by other modules. This assumes that the module has been given a well-defined, stable description (the interface in the sense of information hiding).

In my current context, with agile methodologies, the speed with which a business rule change occurs and the way system requirements are generated, it is usually much simpler to add a field in a class and apply a specific treatment for it in the moments that are necessary than to create another class with inheritance or composition. So, by definition, we can consider that these classes may even have a well-defined description, but they are very unstable. And you can ask me: but where do these changes come from? Sometimes it’s from the head of some user, sometimes it’s from a group of politicians who decide to mess with a law, there’s everything. And that’s why OCP has always been a challenge for me, it never made much sense, until…

Chaos

Imagine a payment system that contains a huge logic for payments to be processed. But, in the name of simplicity sought in the blog, we will only have the information relevant to OCP. This below is a context class, a bunch of information that may or may not be necessary for our routine.

public class PaymentContext
{
    public BankAccount BankAccount { get; set; }
    public PaymentType PaymentType { get; set; }
    public Bill[] Bills { get; set; }

    public PaymentContext(BankAccount bankAccount,
                          Bill[] bills,
                          PaymentType paymentType)
    {
        BankAccount = bankAccount;
        Bills = bills;
        PaymentType = paymentType;
    }
}
Enter fullscreen mode Exit fullscreen mode

And now our payment processor. Again, for the sake of simplicity, the methods result in only a bool to attest to the success of the operation. But, in real life, each of these returns requires logic orders of magnitude greater than this.


internal class PaymentProcessor : IPaymentProcessor
{
    public bool ProcessPayment(PaymentContext paymentContext)
    {
        switch (paymentContext.BankAccount.Bank)
        {
            case Bank.BankA:
                return ProcessPaymentForBankA(paymentContext);

            case Bank.BankB:
                return ProcessPaymentForBankB(paymentContext);

            default: return false;
        }
    }

    private static bool ProcessPaymentForBankA(PaymentContext paymentContext)
    {
        switch (paymentContext.PaymentType)
        {
            case PaymentType.InstantTransfer:
                return false;
            case PaymentType.SlowTransfer:
                return true;
            case PaymentType.Invoice:
                return true;
            case PaymentType.FastTransfer:
                return false;
            case PaymentType.Card:
                return true;

            default: return false;
        }
    }


    private static bool ProcessPaymentForBankB(PaymentContext paymentContext)
    {
        switch (paymentContext.PaymentType)
        {
            case PaymentType.InstantTransfer:
                return false;
            case PaymentType.SlowTransfer:
                return true;
            case PaymentType.Invoice:
                return true;
            case PaymentType.FastTransfer:
                return false;
            case PaymentType.Card:
                return true;

            default: return false;
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

By looking at this code it is possible to notice some peculiarities. The first one is the immense amount of possibilities of processes, since a bank account can belong to one of many banks at the same time that the payment refers to a specific type. The other peculiarity refers to OCP: it is complicated to “close” this class if at any moment something new can appear, such as PIX appeared in Brazil, without the implementations becoming a chaos in the team. This is one of the cases where it is not enough to just add a parameter and everything will be fine in the end. But the solutions are precisely in the soup of letters of the acronyms mentioned earlier added with some magic of C#.

Let’s go to the conversion from non-OCP to full-OCP.

Responsabilities

Starting from the principle of single responsibility (S of SOLID), we can define that our payment processor is actually a type of director or selector. Its responsibility is to find out which bank and form of payment of the context in vogue and send it to the correct processor. And so we have that a real processor is responsible for processing a payment from a bank with a specific form of payment. The interfaces below reflect this while keeping the original interface untouched in order to avoid breaking any other part that may be consuming it (and yes, I know that in the example here there is no other consumer, but I prefer to keep the garbo and elegance).

internal interface IPaymentProcessSelector
{
    IBankTypePaymentProcessor SelectPaymentProcessor(PaymentContext paymentContext);
}

internal interface IBankTypePaymentProcessor
{
    bool ProcessPayment(PaymentContext paymentContext);
}
Enter fullscreen mode Exit fullscreen mode

With them, we can already “close” our payment processor, because we already have what is necessary for it to fulfill its role.

internal sealed class PaymentProcessor : IPaymentProcessor
{
    private readonly IPaymentProcessSelector paymentProcessSelector;

    public PaymentProcessor(IPaymentProcessSelector paymentProcessSelector)
    {
        this.paymentProcessSelector = paymentProcessSelector;
    }

    public bool ProcessPayment(PaymentContext paymentContext)
    {
        return paymentProcessSelector.SelectPaymentProcessor(paymentContext)
                                     .ProcessPayment(paymentContext);        
    }
}
Enter fullscreen mode Exit fullscreen mode

Did you notice the trick? It is already born dependent on an IPaymentProcessSelector, which will normally be injected through a dependency injection container. Although the example still does not have a DI implementation, this is part of this idea of including OCP and there is already an easy way to build one of these natively in C#. Let’s continue and we’ll get there.

Separating the wheat from the chaff, from the wheat, from the chaff.

And here comes the step where we talk about the protagonist of the story, himself, the IPaymentProcessSelector. Its responsibility is to select the correct payment processor for the context of data that transits through there.
Considering the initial example, where the selection was done by switches, if the implemented logics were extremely complex for each type of processing, it would not be enough to isolate each one in its own IBankTypePaymentProcessor, because the problem would continue in the switches, which would continue to require direct change to include new processes. The simplest solution that comes to my mind would be to find a way to index all IBankTypePaymentProcessors and inject them into the IPaymentProcessSelector implementation in such a way that no intervention in any other class was necessary. To get to this solution, we first create a structure that is capable of identifying an IBankTypePaymentProcessor, as follows:

internal readonly struct BankType
    : IEquatable<BankType>
{
    private readonly Bank bank;
    private readonly PaymentType type;

    public BankType(Bank bank,
                    PaymentType type)
    {
        this.bank = bank;
        this.type = type;
    }

    public override bool Equals(object? obj)
        => obj is BankType choice
           && Equals(choice);

    public bool Equals(BankType other)
    {
        return type == other.type &&
               bank == other.bank;
    }

    //GetHashCode() and other equality operators here
}

internal interface IBankTypePaymentProcessor
{
    BankType PaymentBankType { get; }
    bool ProcessPayment(PaymentContext paymentContext);
}
Enter fullscreen mode Exit fullscreen mode

Now that an IBankTypePaymentProcessor can already be identified, let’s create some implementations, like this:


internal class BankAInstantTransferPaymentProcessor
    : IBankTypePaymentProcessor
{
    public BankType PaymentBankType 
        => new(Bank.BankA, PaymentType.InstantTransfer);

    public bool ProcessPayment(PaymentContext paymentContext)
    {
        return true;
    }
}

internal class BankBSlowTransferPaymentProcessor
    : IBankTypePaymentProcessor
{
    public BankType PaymentBankType
        => new(Bank.BankB, PaymentType.SlowTransfer);

    public bool ProcessPayment(PaymentContext paymentContext)
    {
        return false;
    }
}
Enter fullscreen mode Exit fullscreen mode

The next step will be in charge of the .NET dependency injection system, the one I mentioned that already exists and we would use. About a month ago, I found out that it is possible to register multiple implementations for the same interface and use them injected as IEnumerable.

var services = new ServiceCollection();

services.AddSingleton<IPaymentProcessSelector, PaymentProcessSelector>();   
services.AddSingleton<IPaymentProcessor, PaymentProcessor>();

services.AddSingleton<IBankTypePaymentProcessor, BankAInstantTransferPaymentProcessor>();
services.AddSingleton<IBankTypePaymentProcessor, BankBSlowTransferPaymentProcessor>();

var serviceProvider = services.BuildServiceProvider();
Enter fullscreen mode Exit fullscreen mode
internal sealed class PaymentProcessSelector : IPaymentProcessSelector
{
    private readonly Dictionary<BankType, IBankTypePaymentProcessor> bankTypePaymentProcessors = new();

    public PaymentProcessSelector(IEnumerable<IBankTypePaymentProcessor> bankTypePaymentProcessors)
    {
        foreach (var processor in bankTypePaymentProcessors)
        {
            if (!this.bankTypePaymentProcessors.TryAdd(processor.PaymentBankType, processor))
            {
                throw new ArgumentException($"There are more than one processor for {processor.PaymentBankType}.");
            }         
        }
    }

    public IBankTypePaymentProcessor SelectPaymentProcessor(PaymentContext paymentContext)
    {
        var bankType = new BankType(paymentContext.BankAccount.Bank, paymentContext.PaymentType);

        if (!bankTypePaymentProcessors.TryGetValue(bankType, out var processor))
        {
            throw new ArgumentException($"A payment processor for {bankType} is not available.");
        }

        return processor;
    }
}

Enter fullscreen mode Exit fullscreen mode

Before, I didn’t know that this existed and would work, but if you run a code like this, in the constructor of PaymentProcessSelector you will receive the two IBankTypePaymentProcessor registered in the service collection. This is very fantastic, because we just “closed” one more class since our selector no longer needs modifications and fulfills its mission successfully.

But there is a counterpoint, which for me is insignificant if the system analysis is stable. If you want to inject a specific IBankTypePaymentProcessor, that is not possible, because when the IServiceProvider resolves the dependency, it selects the last one that was registered. In the case below, for example, we would receive in the constructor the BankBSlowTransferPaymentProcessor. But this doesn’t sound very reasonable to me, because if you need a specific processor, use the selector, it’s there for that.

internal class SingleDependencyBankType
{
    private readonly IBankTypePaymentProcessor bankTypePaymentProcessor;

    public SingleDependencyBankType(IBankTypePaymentProcessor bankTypePaymentProcessor)
    {
        this.bankTypePaymentProcessor = bankTypePaymentProcessor;
    }
}
Enter fullscreen mode Exit fullscreen mode

The fine tunning.

If you got this far, you must have realized that there was still an “open point”, that is, there is still a moment in the code where it is not possible to escape the terrible change that violates OCP. The point I’m referring to is the registration of dependencies in the service collection. The way it is implemented, it is still necessary that, after creating a new class of type IBankTypePaymentProcessor, such class be registered manually. So, after all this engineering, everything seems to me to go back to square one and manual intervention is mandatory. It’s not!

Using reflection it is possible to find all implementations of an interface existing in an assembly, and then, using another less common method of IServiceCollection, register them all. Check it out:

var type = typeof(IBankTypePaymentProcessor);
var implementations = Assembly.GetExecutingAssembly().GetTypes()    
    .Where(p => type.IsAssignableFrom(p))
    .Where(p => !p.IsInterface);

foreach (var implementation in implementations)
{ 
    services.Add(new ServiceDescriptor(type, implementation, ServiceLifetime.Singleton));
}
Enter fullscreen mode Exit fullscreen mode

Done! One more routine “closed”. From now on, every new implementation of IBankTypePaymentProcessor will be automatically registered and injected. Maybe I’m lazy, but I love this and I think it’s very practical for the programmer’s day-to-day.

There is a repository with all the code. See it HERE.

Conclusion

In conclusion, I feel that OCP is an important principle that should be followed aiming at the extensibility and maintainability of the code. However, in environments with very volatile business rules, it has always been difficult to apply it efficiently, being simpler to make a small quick change. For these cases, the well-defined use of the single responsibility principle can be a more suitable alternative, because if each rule is isolated, changing it is not a risk, but a necessity. Finally, it is important to stress that each situation is unique and requires careful analysis, do not follow this post as a rule.

PS.: Will I ever be replaced by an AI as a dev? At least my text is cooler than ChatGPT’s.
PS2.: Why did I implement the IServiceProvider manually? I got used to doing this in Windows Forms and Console applications where there is no standard container implemented. In ASP.Net there is, look there.
PS3.: This post was translated from my own native language post with the help of AI. Original is here.

Top comments (1)

Collapse
 
artydev profile image
artydev • Edited

Great and usefull , thank you very much :-)