SOLID Principles in modern C++

When talking about patterns, one acronym seems to be the most popular, the SOLID principles. Yes, actually SOLID is an acronym that stands for:

  • Single Responsibility,
  • Open-closed,
  • Liskov substitution,
  • Interface Segregation and
  • Dependency Inversion

Too many strange terms, a little bit dry. I can think of two things that will help us go through. The first is to watch the Arjan Codes video that explains ridiculously smoothly the concept, and the second is to experiment by trying an example using modern C++.

Spoiler Alert: This is not a detailed technical text. It’s just an example that tries using layman’s terms to explain a dry topic. If you are very serious about that, please try a relevant book first.

Photo by Jonas Von Werne on Unsplash

Okay, let’s start. What is the case? Our example is a sales system, and we will gradually build on that for each individual principle. The sales system consists of the Order class. In this class, we can handle objects of the Item class. In general, we can add items in the order, print an Order object and finally proceed with the payment.

The non-optimised code looks like that:

struct Item {
  Item(std::string_view item, int quantity, float price)
      : item_{item}
      , quantity_{quantity}
      , price_{price} {}

  std::string_view item_;
  int quantity_;
  float price_;
};

This is the Item class. An order consists of Item objects. The Order class looks like this:

class Order {
 public:
  void AddItem(const Item& new_item);
  [[nodiscard]] float TotalPrice() const;
  void Pay(std::string_view payment_type, std::string_view security_code);
  void PrintOrder() const;

 private:
  std::vector<Item> items_;
  std::string status_{"open"};
};

and use case could be:

int main() {
  Item item1{"Keyboard", 1, 50.0};
  Item item2{"SSD", 1, 150.0};
  Item item3{"USB cable", 2, 5.0};
  Order an_order{};

  an_order.AddItem(item1);
  an_order.AddItem(item2);
  an_order.AddItem(item3);

  an_order.PrintOrder();

  spdlog::info("The total price is {}", an_order.TotalPrice());
  try {
    an_order.Pay("debit", "09878");
  } catch (const Trouble& t) {
    spdlog::error(t.what());
  }

  try {
    an_order.Pay("credit", "96553");
  } catch (const Trouble& t) {
    spdlog::error(t.what());
  }

  return 0;
}

Nothing interesting but too many code smells. Do you agree? No? Let me explain to you why.

Single Responsibility Principle

This principle is fairly straightforward. It means that classes and/or methods should do one thing and do it in the most efficient way. In the coder’s jargon, this is translated that classes should have high cohesion. It also helps in the reusability of the entities. In our non-optimised example, the Order class has a problem. The Pay method doesn’t belong there. It should be a different entity that gets as input an Order object and proceeds to the payment adhering to certain security compliances. How can we remedy the situation? First, let’s create a new order class and call it NewOrder (I like the music band too, no surprise). In this class, there is no Pay method anymore. The Item class does not need to change. We also added a new member variable to represent a unique code for the order.

struct NewOrder {

  void AddItem(const Item& new_item);
  [[nodiscard]] float TotalPrice() const;
  void SetStatus(Status status);
  void PrintOrder() const;
  [[nodiscard]] const uuids::uuid &GetId() const;

 private:
  uuids::uuid id {uuids::uuid_system_generator{}()};
  std::vector<Item> items_;
  Status status_{Status::Open};
};

and

void NewOrder::AddItem(const Item& new_item){
  items_.push_back(new_item);
}

void NewOrder::PrintOrder() const {
  spdlog::info("The id of the order is {}, with items:", uuids::to_string(id));
  for (auto& item : items_) {
    spdlog::info(item.item_);
  }
  spdlog::info("and the status is {0}", StatusToString(status_));
}

float NewOrder::TotalPrice() const {
  float total {0.0};
  for (auto& item : items_) {
    total += static_cast<float>(item.quantity_)*item.price_;
  }
  return total;
}

void NewOrder::SetStatus(Status status) {
  status_ = status;
}

const uuids::uuid &NewOrder::GetId() const {
  return id;
}

For the payment, we will add a new class called Payment Processor. The responsibility of this class is to do one thing, pay the bills! It gets an Order object and handles the payment, nothing else.

struct PaymentProcessor {
  explicit PaymentProcessor(NewOrder &new_order)
      : new_order_{new_order} {}

  void DisplayInfo() const;
  void PayDebit(std::string_view security_code) const;
  void PayCredit(std::string_view security_code) const;
  void PayPaypal(std::string_view security_code) const;

 private:
  NewOrder &new_order_;
};

and

void PaymentProcessor::PayDebit(std::string_view security_code) const {
  spdlog::info("Processing debit payment type");
  spdlog::info("Verifying security code: {0}", security_code);
  new_order_.SetStatus(Status::Paid);
}

void PaymentProcessor::PayCredit(std::string_view security_code) const {
  spdlog::info("Processing credit payment type");
  spdlog::info("Verifying security code: {0}", security_code);
  new_order_.SetStatus(Status::Paid);
}

void PaymentProcessor::PayPaypal(std::string_view security_code) const {
  spdlog::info("Processing paypal payment type");
  spdlog::info("Verifying security code: {0}", security_code);
  new_order_.SetStatus(Status::Paid);
}

void PaymentProcessor::DisplayInfo() const {
  spdlog::info("Payment processor for order {0}", to_string(new_order_.GetId()));
}

now the use case example looks like this:

int main() {
  Item item1{"Keyboard", 1, 50.0};
  Item item2{"SSD", 1, 150.0};
  Item item3{"USB cable", 2, 5.0};

  NewOrder an_order{};

  an_order.AddItem(item1);
  an_order.AddItem(item2);
  an_order.AddItem(item3);

  an_order.PrintOrder();

  PaymentProcessor payment_processor1{an_order};
  payment_processor1.DisplayInfo();
  payment_processor1.PayCredit("65379");

  PaymentProcessor payment_processor2{an_order};
  payment_processor2.DisplayInfo();
  payment_processor2.PayDebit("98664");

  PaymentProcessor payment_processor3{an_order};
  payment_processor3.DisplayInfo();
  payment_processor3.PayPaypal("12245");

  return 0;
}

Hmm, that makes sense. We created an Order object and we can do the payment by selecting one of the three different payment methods, using the corresponding Payment Processor object. Straightforward, I think we go in the correct direction.

Open-closed Principle

This seems strange. The meaning of open is that the structure of code (call me architecture) should be open for extensions (by adding new features) but closed for modifications. The latter means that we do not need to modify the existing codebase in order to add new features, we just build on top, or better extend it. In our case, the sales system suffer from that. If we want to add a new payment method (e.g. Paypal), we need to modify the Payment Processor class. Simply unacceptable. The solution comes from creating classes and subclasses connected with inheritance. Sounds familiar, eh?

In this case, the Item and NewOrder classes remain the same. We need to create an abstract class for the Payment Processor.

struct PaymentProcessorAbstractOC {
  virtual void Pay(std::string_view security_code) const = 0;
  virtual void DisplayInfo() const = 0;
  virtual ~PaymentProcessorAbstractOC() = default;
};

Now the subclasses inherit from the parent and look like that, one for every individual payment method.

struct PaymentProcessorCreditOC final : public PaymentProcessorAbstractOC {
  explicit PaymentProcessorCreditOC(NewOrder &new_order)
      : new_order_{new_order} {}

  void Pay(std::string_view security_code) const override {
    spdlog::info("Processing credit payment type");
    spdlog::info("Verifying security code: {0}", security_code);
    new_order_.SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Credit payment processor for order {0}", to_string(new_order_.GetId()));
  }

 private:
  NewOrder &new_order_;
};
struct PaymentProcessorDebitOC final : public PaymentProcessorAbstractOC {
  explicit PaymentProcessorDebitOC(NewOrder &new_order)
      : new_order_{new_order} {}

  void Pay(std::string_view security_code) const override {
    spdlog::info("Processing debit payment type");
    spdlog::info("Verifying security code: {0}", security_code);
    new_order_.SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Debit payment processor for order {0}", to_string(new_order_.GetId()));
  }

 private:
  NewOrder &new_order_;
};

and let’s add a new payment method that utilises Paypal

struct PaymentProcessorPaypalOC final : public PaymentProcessorAbstractOC {
  explicit PaymentProcessorPaypalOC(NewOrder &new_order)
      : new_order_{new_order} {}

  void Pay(std::string_view security_code) const override {
    spdlog::info("Processing paypal payment type");
    spdlog::info("Verifying security code: {0}", security_code);
    new_order_.SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Paypal payment processor for order {0}", to_string(new_order_.GetId()));
  }

 private:
  NewOrder &new_order_;
};

So easy. After that, the use case will look like this:

int main() {
  Item item1{"Keyboard", 1, 50.0};
  Item item2{"SSD", 1, 150.0};
  Item item3{"USB cable", 2, 5.0};
  NewOrder an_order{};

  an_order.AddItem(item1);
  an_order.AddItem(item2);
  an_order.AddItem(item3);

  an_order.PrintOrder();

  PaymentProcessorDebitOC processor1{an_order};
  processor1.DisplayInfo();
  processor1.Pay("65379");

  PaymentProcessorCreditOC processor2{an_order};
  processor2.DisplayInfo();
  processor2.Pay("65379");

  PaymentProcessorPaypalOC processor3{an_order};
  processor3.DisplayInfo();
  processor3.Pay("65379");

  return 0;
}

No major differences. In this case, we just need to create different kinds of Payment Processor objects depending on the scenario.

Liskov Substitution Principle

Now what? I cannot infer anything from the name. I feel you. This principle is a little drier. In essence, it means that the subclasses should be able to substitute each other without altering the correctness of the program, or better, the concept.  To explain that, we need to go back to our sales system. In the Payment Processor classes and more specifically in the authorisation part there is a problem. In Paypal, there is no security code but email address verification. We use it in the precious case pretending that the security code is the email but this is not correct. We fake it. This means that it abuses the Liskov substitution principle. We need to think about something that will give us this flexibility. The solution might be simple as removing the security code from the arguments of the Pay method and adding that as a member variable of the Payment Processors subclasses. In that case the different Payment Processors will look like that:

struct PaymentProcessorCreditLiskov final : public PaymentProcessorAbstractLiskov {
  explicit PaymentProcessorCreditLiskov(NewOrder &new_order, std::string_view security_code)
      : new_order_{new_order}
      , security_code_{security_code} {}

  void Pay() const override {
    spdlog::info("Processing credit payment type");
    spdlog::info("Verifying security code: {0}", security_code_);
    new_order_.SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Credit payment processor for order {0}", to_string(new_order_.GetId()));
  }

 private:
  NewOrder &new_order_;
  std::string_view security_code_;
};
struct PaymentProcessorDebitLiskov final : public PaymentProcessorAbstractLiskov {
  explicit PaymentProcessorDebitLiskov(NewOrder &new_order, std::string_view security_code)
      : new_order_{new_order}
      , security_code_{security_code} {}

  void Pay() const override {
    spdlog::info("Processing debit payment type");
    spdlog::info("Verifying security code: {0}", security_code_);
    new_order_.SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Debit payment processor for order {0}", to_string(new_order_.GetId()));
  }

 private:
  NewOrder &new_order_;
  std::string_view security_code_;
};

and eventually the Paypal payment processor becomes:

struct PaymentProcessorPaypalLiskov final : public PaymentProcessorAbstractLiskov {
  explicit PaymentProcessorPaypalLiskov(NewOrder &new_order, std::string_view email_address)
      : new_order_{new_order}
      , email_address_{email_address} {}

  void Pay() const override {
    spdlog::info("Processing paypal payment type");
    spdlog::info("Verifying security code: {0}", email_address_);
    new_order_.SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Paypal payment processor for order {0}", to_string(new_order_.GetId()));
  }

 private:
  NewOrder &new_order_;
  std::string_view email_address_;
  bool verified_{false};
};

Then the use case then becomes clear:

int main() {
  Item item1{"Keyboard", 1, 50.0};
  Item item2{"SSD", 1, 150.0};
  Item item3{"USB cable", 2, 5.0};
  NewOrder an_order{};

  an_order.AddItem(item1);
  an_order.AddItem(item2);
  an_order.AddItem(item3);

  an_order.PrintOrder();

  PaymentProcessorDebitLiskov processor1{an_order, "65379"};
  try {
    processor1.DisplayInfo();
    processor1.Pay();
  } catch (const std::exception &e) {
    spdlog::error(e.what());
  }

  PaymentProcessorCreditLiskov processor2{an_order, "65379"};
  try {
    processor2.DisplayInfo();
    processor2.Pay();
  } catch (const std::exception &e) {
    spdlog::error(e.what());
  }

  PaymentProcessorPaypalLiskov processor3{an_order, "angelos@in.gr"};
  try {
    processor3.DisplayInfo();
    processor3.Pay();
  } catch (const std::exception &e) {
    spdlog::error(e.what());
  }

  return 0;
}

This proves that there is no violation of the usage of the subclasses. Everything is utilised as designed and expected. No cheating.

Interface Segregation Principle (using inheritance and composition)

This is the fourth principle which means again, do not go too general. It is better to have several specific interfaces instead of having one generic. Translation, please? Well, to explain that we added one more thing to the Payment Processor. This is the SMS authorisation (Two-factor authentication). The correct place to add that is in the abstract class first to enforce that each payment method needs to satisfy that requirement.

struct PaymentProcessorAbstractLiskov {
  virtual void AuthSMS(std::string_view sms_code) = 0;
  virtual void Pay() const = 0;
  virtual void DisplayInfo() const = 0;
  virtual ~PaymentProcessorAbstractLiskov() = default;
};

and then it is implemented from each processor like that:

struct PaymentProcessorDebitLiskov final : public PaymentProcessorAbstractLiskov {
  explicit PaymentProcessorDebitLiskov(NewOrder &new_order, std::string_view security_code)
      : new_order_{new_order}
      , security_code_{security_code} {}

  void AuthSMS(std::string_view sms_code) override {
    spdlog::info("Verifying SMS code {0}", sms_code);
    verified_ = true;
  }

  void Pay() const override {
    if (!verified_) {
      throw Trouble{"Not authorised"};
    }
    spdlog::info("Processing debit payment type");
    spdlog::info("Verifying security code: {0}", security_code_);
    new_order_.SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Debit payment processor for order {0}", to_string(new_order_.GetId()));
  }

 private:
  NewOrder &new_order_;
  std::string_view security_code_;
  bool verified_{false};
};
struct PaymentProcessorDebitLiskov final : public PaymentProcessorAbstractLiskov {
  explicit PaymentProcessorDebitLiskov(NewOrder &new_order, std::string_view security_code)
      : new_order_{new_order}
      , security_code_{security_code} {}

  void AuthSMS(std::string_view sms_code) override {
    spdlog::info("Verifying SMS code {0}", sms_code);
    verified_ = true;
  }

  void Pay() const override {
    if (!verified_) {
      throw Trouble{"Not authorised"};
    }
    spdlog::info("Processing debit payment type");
    spdlog::info("Verifying security code: {0}", security_code_);
    new_order_.SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Debit payment processor for order {0}", to_string(new_order_.GetId()));
  }

 private:
  NewOrder &new_order_;
  std::string_view security_code_;
  bool verified_{false};
};

or in the case of credit payment, which is not supported, it will look like that:

struct PaymentProcessorCreditLiskov final : public PaymentProcessorAbstractLiskov {
  explicit PaymentProcessorCreditLiskov(NewOrder &new_order, std::string_view security_code)
      : new_order_{new_order}
      , security_code_{security_code} {}

  void AuthSMS(std::string_view sms_code) override {
    throw Trouble("Credit card payments don't support SMS code authorization.");
  }

  void Pay() const override {
    spdlog::info("Processing credit payment type");
    spdlog::info("Verifying security code: {0}", security_code_);
    new_order_.SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Credit payment processor for order {0}", to_string(new_order_.GetId()));
  }

 private:
  NewOrder &new_order_;
  std::string_view security_code_;
};

This is a problem though. It seems that our abstract Payment Processor class is too generic. It cannot handle different situations elegantly. And you know what? It even violates Liskov segregation, because in the credit case we actually raise an exception to avoid that. The solution is to go in finer defined interfaces where the support of higher resolution cases is possible. The first approach is to use inheritance. No surprises. We need to create a subinterface of the abstract Payment Processor class that will support SMS authorisation. In return, the abstract Payment Processor class goes in its previous state where no two-factor authorisation was defined. Let me show you. This is the abstract Payment Processor class

struct PaymentProcessorAbstractIS {
  virtual void Pay() const = 0;
  virtual void DisplayInfo() const = 0;
  virtual ~PaymentProcessorAbstractIS() = default;
};

The more dedicated interface that supports authorisation looks like that:

struct PaymentProcessorAbstractSMS : public PaymentProcessorAbstractIS {
  virtual void AuthSMS(std::string_view sms_code) = 0;
};

and the individual Payment Processor classes will use whatever is appropriate for them (pick and choose). Please, have a look:

struct PaymentProcessorCreditISInh final : public PaymentProcessorAbstractIS {
  explicit PaymentProcessorCreditISInh(const NewOrder &new_order, std::string_view security_code)
      : new_order_{std::make_shared<NewOrder>(new_order)}
      , security_code_{security_code} {}

  void Pay() const override {
    spdlog::info("Processing credit payment type");
    spdlog::info("Verifying security code: {0}", security_code_);
    new_order_->SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Credit payment processor for order {0}", to_string(new_order_->GetId()));
  }

 private:
  std::shared_ptr<NewOrder> new_order_;
  std::string_view security_code_;
};

the credit approach is clean. No monkey business. It inherits from the initial interface without messing around with the authorisation functionality. The other approaches will subclass the finer interface which supports the two-factor authentication. Decent, eh?

struct PaymentProcessorDebitISInh final : public PaymentProcessorAbstractSMS {
  explicit PaymentProcessorDebitISInh(const NewOrder &new_order, std::string_view security_code)
      : new_order_{std::make_shared<NewOrder>(new_order)}
      , security_code_{security_code} {}

  void AuthSMS(std::string_view sms_code) override {
    spdlog::info("Verifying SMS code {0}", sms_code);
    verified_ = true;
  }

  void Pay() const override {
    if (!verified_) {
      throw Trouble{"Not authorised"};
    }
    spdlog::info("Processing debit payment type");
    spdlog::info("Verifying security code: {0}", security_code_);
    new_order_->SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Debit payment processor for order {0}", to_string(new_order_->GetId()));
  }

 private:
  std::shared_ptr<NewOrder> new_order_;
  std::string_view security_code_;
  bool verified_{false};
};
struct PaymentProcessorPaypalISComp final : public PaymentProcessorAbstractIS {
  explicit PaymentProcessorPaypalISComp(const NewOrder &new_order, std::string_view email_address, std::shared_ptr<SMSAuthorizer> sms_authorizer)
      : new_order_{std::make_shared<NewOrder>(new_order)}
      , email_address_{email_address}
      , sms_authorizer_{std::move(sms_authorizer)} {}

  void Pay() const override {
    if (!sms_authorizer_->IsAuthorized()) {
      throw Trouble{"Not authorised"};
    }
    spdlog::info("Processing paypal payment type");
    spdlog::info("Verifying security code: {0}", email_address_);
    new_order_->SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Paypal payment processor for order {0}", to_string(new_order_->GetId()));
  }

 private:
  std::shared_ptr<NewOrder> new_order_;
  std::string_view email_address_;
  std::shared_ptr<SMSAuthorizer> sms_authorizer_;
};

Now the use case is the following:

int main() {
  Item item1{"Keyboard", 1, 50.0};
  Item item2{"SSD", 1, 150.0};
  Item item3{"USB cable", 2, 5.0};
  NewOrder an_order{};

  an_order.AddItem(item1);
  an_order.AddItem(item2);
  an_order.AddItem(item3);

  an_order.PrintOrder();

  PaymentProcessorDebitISInh processor1{an_order, "65379"};
  try {
    processor1.DisplayInfo();
    processor1.AuthSMS("264423");
    processor1.Pay();
  } catch (const std::exception &e) {
    spdlog::error(e.what());
  }

  PaymentProcessorCreditISInh processor2{an_order, "65379"};
  try {
    processor2.DisplayInfo();
    processor2.Pay();
  } catch (const std::exception &e) {
    spdlog::error(e.what());
  }

  PaymentProcessorPaypalISInh processor3{an_order, "angelos@in.gr"};
  try {
    processor3.DisplayInfo();
    processor3.AuthSMS("764423");
    processor3.Pay();
  } catch (const std::exception &e) {
    spdlog::error(e.what());
  }

  return 0;
}

In that, we can see that we can use the Payment Processor in a proper way. In credit one there is no need to cheat because that class inherits the interface without the authorisation functionality because it cannot exist. So we resolve the Interface segregation issue as well as the Liskow substitution violation that was created.

In parallel with the inheritance approach exist the composition one. This is another technique that most of the time leads to cleaner code, avoiding the long inheritance trees trying to cover every possible permutation. In that case, we remove, again, from the Payment Processor abstract class the authorisation functionality.

struct PaymentProcessorAbstractIS {
  virtual void Pay() const = 0;
  virtual void DisplayInfo() const = 0;
  virtual ~PaymentProcessorAbstractIS() = default;
};

Then we define the two-factor (SMS) authoriser as a concrete class like that:

struct SMSAuthorizer {

  void VerifyCode(std::string_view code) {
    spdlog::debug("Verified SMS {0}", code);
    authorized_ = true;
  }

  [[nodiscard]] bool IsAuthorized() const {
    return authorized_;
  }

 private:
  bool authorized_{false};

};

Therefore the situation with the individual Payment Processor classes is again clear. For the ones that they need authentication, we add as a member variable a reference to the SMS authoriser class.

struct PaymentProcessorDebitISComp final : public PaymentProcessorAbstractIS {
  explicit PaymentProcessorDebitISComp(const NewOrder &new_order, std::string_view security_code, std::shared_ptr<SMSAuthorizer> sms_authorizer)
      : new_order_{std::make_shared<NewOrder>(new_order)}
      , security_code_{security_code}
      , sms_authorizer_{std::move(sms_authorizer)}
  {}

  void Pay() const override {
    if (!sms_authorizer_->IsAuthorized()) {
      throw Trouble{"Not authorised"};
    }
    spdlog::info("Processing debit payment type");
    spdlog::info("Verifying security code: {0}", security_code_);
    new_order_->SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Debit payment processor for order {0}", to_string(new_order_->GetId()));
  }

 private:
  std::shared_ptr<NewOrder> new_order_;
  std::string_view security_code_;
  std::shared_ptr<SMSAuthorizer> sms_authorizer_;
};

and

struct PaymentProcessorPaypalISComp final : public PaymentProcessorAbstractIS {
  explicit PaymentProcessorPaypalISComp(const NewOrder &new_order, std::string_view email_address, std::shared_ptr<SMSAuthorizer> sms_authorizer)
      : new_order_{std::make_shared<NewOrder>(new_order)}
      , email_address_{email_address}
      , sms_authorizer_{std::move(sms_authorizer)} {}

  void Pay() const override {
    if (!sms_authorizer_->IsAuthorized()) {
      throw Trouble{"Not authorised"};
    }
    spdlog::info("Processing paypal payment type");
    spdlog::info("Verifying security code: {0}", email_address_);
    new_order_->SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Paypal payment processor for order {0}", to_string(new_order_->GetId()));
  }

 private:
  std::shared_ptr<NewOrder> new_order_;
  std::string_view email_address_;
  std::shared_ptr<SMSAuthorizer> sms_authorizer_;
};

and for the ones that do not support it, like the credit, we simply ignore it. There is no member variable like that:

struct PaymentProcessorCreditISComp final : public PaymentProcessorAbstractIS {
  explicit PaymentProcessorCreditISComp(const NewOrder &new_order, std::string_view security_code)
      : new_order_{std::make_shared<NewOrder>(new_order)}
      , security_code_{security_code} {}

  void Pay() const override {
    spdlog::info("Processing credit payment type");
    spdlog::info("Verifying security code: {0}", security_code_);
    new_order_->SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Credit payment processor for order {0}", to_string(new_order_->GetId()));
  }

 private:
  std::shared_ptr<NewOrder> new_order_;
  std::string_view security_code_;
};

Yes, it’s clear. So we expect that a use case will look that:

int main() {
  Item item1{"Keyboard", 1, 50.0};
  Item item2{"SSD", 1, 150.0};
  Item item3{"USB cable", 2, 5.0};
  NewOrder an_order{};

  an_order.AddItem(item1);
  an_order.AddItem(item2);
  an_order.AddItem(item3);

  an_order.PrintOrder();

  auto authorizer1 = std::make_shared<SMSAuthorizer>();
  PaymentProcessorDebitISComp processor1{an_order, "65379", authorizer1};
  try {
    processor1.DisplayInfo();
    authorizer1->VerifyCode("7987356");
    processor1.Pay();
  } catch (const std::exception &e) {
    spdlog::error(e.what());
  }

  PaymentProcessorCreditISComp processor2{an_order, "65379"};
  try {
    processor2.DisplayInfo();
    processor2.Pay();
  } catch (const std::exception &e) {
    spdlog::error(e.what());
  }

  auto authorizer3 = std::make_shared<SMSAuthorizer>();
  PaymentProcessorPaypalISComp processor3{an_order, "angelos@in.gr", authorizer3};
  try {
    processor3.DisplayInfo();
    authorizer3->VerifyCode("9778555");
    processor3.Pay();
  } catch (const std::exception &e) {
    spdlog::error(e.what());
  }

  return 0;
}

There is no magic there. In every case that supports two-factor authentication, we create objects from the corresponding classes and we pass them in the Payment Processor objects. They work as expected.

Dependency Inversion Principle

Ouf, we are reaching the end, the fifth and last one. The meaning of dependency inversion will sound familiar. It is the case that we want our classes to depend on abstract classes (interfaces) rather than concrete ones. Yes, it makes sense. We want to build flexible and open architectures with reusable entities. That is not the case with the SMS authoriser in our example. The Payment Processor classes depend on a specific authorizer (the SMS one) and not in nice abstraction. How to solve it? Easy job. Let’s create an abstract class about the Authorizers:

struct Authorizer {
  [[nodiscard]] virtual bool IsAuthorized() const = 0;
  virtual ~Authorizer() = default;
};

The Payment Processor abstract class remains the same (which means do not include any authorisation details)

struct PaymentProcessorAbstractIS {
  virtual void Pay() const = 0;
  virtual void DisplayInfo() const = 0;
  virtual ~PaymentProcessorAbstractIS() = default;
};

Having all this functionality in place it’s time to go wild and implement apart from the SMS two-factor authentication and a not a robot user. Both of them inherits the Authorizer abstract class.

struct SMSAuthorizerDI final : public Authorizer  {

  void VerifyCode(std::string_view code) {
    spdlog::debug("Verified SMS {0}", code);
    authorized_ = true;
  }

  [[nodiscard]] bool IsAuthorized() const override {
    return authorized_;
  }

 private:
  bool authorized_{false};

};

and

struct NotARobotDI final : public Authorizer  {

  void VerifyCode() {
    spdlog::debug("Are you a robot? Naa");
    authorized_ = true;
  }

  [[nodiscard]] bool IsAuthorized() const override {
    return authorized_;
  }

 private:
  bool authorized_{false};

};

Now our Payment Processor classes depend on the abstract Authorizer class and not in any specific implementation. Let’s have a look:

struct PaymentProcessorDebitDIComp final : public PaymentProcessorAbstractIS {
  explicit PaymentProcessorDebitDIComp(const NewOrder &new_order, std::string_view security_code, std::shared_ptr<Authorizer> authorizer)
      : new_order_{std::make_shared<NewOrder>(new_order)}
      , security_code_{security_code}
      , authorizer_{std::move(authorizer)}
  {}

  void Pay() const override {
    if (!authorizer_->IsAuthorized()) {
      throw Trouble{"Not authorised"};
    }
    spdlog::info("Processing debit payment type");
    spdlog::info("Verifying security code: {0}", security_code_);
    new_order_->SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Debit payment processor for order {0}", to_string(new_order_->GetId()));
  }

 private:
  std::shared_ptr<NewOrder> new_order_;
  std::string_view security_code_;
  std::shared_ptr<Authorizer> authorizer_;
};

and

struct PaymentProcessorPaypalDIComp final : public PaymentProcessorAbstractIS {
  explicit PaymentProcessorPaypalDIComp(const NewOrder &new_order, std::string_view email_address, std::shared_ptr<Authorizer> authorizer)
      : new_order_{std::make_shared<NewOrder>(new_order)}
      , email_address_{email_address}
      , authorizer_{std::move(authorizer)} {}

  void Pay() const override {
    if (!authorizer_->IsAuthorized()) {
      throw Trouble{"Not authorised"};
    }
    spdlog::info("Processing paypal payment type");
    spdlog::info("Verifying security code: {0}", email_address_);
    new_order_->SetStatus(Status::Paid);
  }

  void DisplayInfo() const override {
    spdlog::info("Paypal payment processor for order {0}", to_string(new_order_->GetId()));
  }

 private:
  std::shared_ptr<NewOrder> new_order_;
  std::string_view email_address_;
  std::shared_ptr<Authorizer> authorizer_;
};

The credit one remains the same (not included) since we do not need to support authorisation. The use case looks like this:

int main() {
  Item item1{"Keyboard", 1, 50.0};
  Item item2{"SSD", 1, 150.0};
  Item item3{"USB cable", 2, 5.0};
  NewOrder an_order{};

  an_order.AddItem(item1);
  an_order.AddItem(item2);
  an_order.AddItem(item3);

  an_order.PrintOrder();

  auto authorizer1 = std::make_shared<SMSAuthorizerDI>();
  PaymentProcessorDebitDIComp processor1{an_order, "65379", authorizer1};
  try {
    processor1.DisplayInfo();
    authorizer1->VerifyCode("7987356");
    processor1.Pay();
  } catch (const std::exception &e) {
    spdlog::error(e.what());
  }

  PaymentProcessorCreditDIComp processor2{an_order, "65379"};
  try {
    processor2.DisplayInfo();
    processor2.Pay();
  } catch (const std::exception &e) {
    spdlog::error(e.what());
  }

  auto authorizer3 = std::make_shared<NotARobotDI>();
  PaymentProcessorPaypalDIComp processor3{an_order, "angelos@in.gr", authorizer3};
  try {
    processor3.DisplayInfo();
    authorizer3->VerifyCode();
    processor3.Pay();
  } catch (const std::exception &e) {
    spdlog::error(e.what());
  }

  return 0;
}

Simply beautiful. Flexibility to the maximum. Our Payment Processors can take any kind of Authorizers that adhere to the generic Authorizer interface.

Conclusion

The SOLID principle might sound difficult at the beginning but if you compare the non optimised version with the latest one, you quickly realise that we talk about the beauty and the beast. The usual weapons that we use are abstract classes (interfaces), inheritance and (why not) composition. Try gradually to integrate a few bits of these techniques of your code development and you will see quickly the quality goes up. As usual, the complete code repository can be found on GitHub. Full credits to Arjan Egges and his beautiful video tutorials about design patterns.