The SOLID Principles: A Beginner’s Guide to Writing Better Code

birkan atıcı
10 min readFeb 1, 2023

In software development, creating high-quality, reliable, and maintainable software that meets the needs of its users is a constant goal. This includes ensuring that the software is functional, efficient, and easy to use and that it is developed in a way that allows for future changes and updates. One of the most important ways to accomplish this is by applying SOLID design principles and writing clean code.

Imagine you are a developer in an e-commerce startup. As the business grows and the platform becomes more popular, so does the number of orders. Suddenly, the development team starts receiving complaints from customers about the slow platform, and the customer support team reports that they have received numerous emails about failed payments. As a developer, you were tasked with investigating the issue and saw the payment service below.

class PaymentService {
private MysqlConnection mysqlConnection;

public PaymentService() {
this.mysqlConnection = createMySQLConnection();
}

public void makePayment(Payment payment, Order order) {
boolean success = false;

if (payment.getPaymentType() == PaymentType.VISA) {
success = processVisaPayment(payment);
} else if (payment.getPaymentType() == PaymentType.MASTERCARD) {
success = processMastercardPayment(payment);
} else if (payment.getPaymentType() == PaymentType.PAYPAL) {
success = processPaypalPayment(payment, order);
}

if (success) {
payment.setStatus(PaymentStatus.SUCCESS);
updateInventory(order);
sendEmail("Payment Success", order.getCustomerEmail());
} else {
payment.setStatus(PaymentStatus.FAILED);
sendEmail("Payment Failed", order.getCustomerEmail());
}

savePaymentStatus(payment);
}

public void cancelPayment(Payment payment) {
if (payment.getPaymentType() != PaymentType.PAYPAL) {
// cancel payment
} else {
throw new UnsupportedOperationException("Paypal payments cannot be cancelled");
}
}

private boolean processVisaPayment(Payment payment) {
validateVisaCard(payment);
// process visa payment
}

private boolean processMastercardPayment(Payment payment) {
validateMasterCard(payment);
// process mastercard payment
}

private boolean processPaypalPayment(Payment payment, Order order) {
validateEmail(order.getCustomerEmail());
// process paypal payment
}

private void validateEmail(String email) {
// validate email
}

private void validateMasterCard(Payment payment) {
// validate mastercard
}

private void validateVisaCard(Payment payment) {
// validate visa card
}

private void updateInventory(Order order) {
this.mysqlConnection.descreaseInventory(order.getInventoryId());
}

private void savePaymentStatus(Payment payment) {
// save payment status in MySQL database
}

private MysqlConnection createMySQLConnection() {
// create MySQL connection
}

private void sendEmail(String message, String email) {
// send email
}
}

You look at the payment service code and immediately notice that it handles multiple responsibilities. From the looks of it, it is running the validation of credit cards, processing payments, updating inventory, and sending notifications to customers, all within the same class. It's difficult to understand the flow of the code, and where potential bugs may be coming from needs to be clarified. Additionally, the code is tightly coupled, making it hard to make changes or add new features without affecting other parts of the codebase. This is not a good sign, and it's clear that this payment service needs to be fixed. In that cases, SOLID principles provide guidelines for creating software that helps developers understand the code's logic, making it easier to extend, modify, and fix bugs.

What are SOLID Design Principles?

SOLID stands for five design principles that Robert C. Martin first introduced in his book, "Agile Software Development, Principles, Patterns, and Practices."

SOLID Principles

These principles are brief:

  • S — Single Responsibility: One class, one responsibility
  • O — Open-Closed: Extensible, not modifiable
  • L — Liskov Substitution: Substitute with subtypes
  • I — Interface Segregation: Smaller, specific interfaces
  • D — Dependency Inversion: Depend on abstractions, not concretions

Single Responsibility Principle (SRP):

A class or module should have a single, well-defined responsibility encapsulated by its interfaces and methods and should not have any additional duties or dependencies. This principle makes understanding and maintaining code easier, ensuring that changes to one part of the system do not inadvertently affect other parts. It also promotes reusability, as classes and modules with a single responsibility are more likely to be reusable in different contexts. On the other hand, if a class or module has multiple responsibilities, it becomes harder to understand and maintain. Changes to one duty may unintentionally affect the others, resulting in bugs and making the codebase harder to understand and maintain over time.

Here's one example of a violation of SRP:

public class PaymentService {
private EmailService emailService;
private InventoryDAO inventoryDAO;
private PaymentDAO paymentDAO;

public void processPayment(Payment payment, Order order) {
// Validate payment
if (!validateCard(payment)) {
throw new IllegalArgumentException("Invalid Card");
}

// Process payment
if (!makePayment(payment, order)) {
throw new PaymentFailedException();
}

// Update inventory
inventoryDAO.decreaseStock(order.getInventoryId());

// Save payment
paymentDAO.save(payment);

// Notify customer
emailService.sendMail("Payment Successful", order.getCustomerEmail());
}

private boolean validatePayment(Payment payment) {
// Validation logic here
}

private boolean makePayment(Payment payment, Order order) {
// Payment processing logic here
}
}

In this example, the PaymentService class is responsible for several tasks, such as validating the card, making the payment, updating the inventory, saving the payment, and sending a notification email to the customer. Each of these tasks should be the responsibility of a separate class or module, as they are all individual concerns that may change independently. This class violates the Single Responsibility principle as it does multiple things and makes it hard to understand, maintain and test.

Let's refactor this code example by applying SRP.

public class PaymentService {
private CardValidator cardValidator;
private PaymentProcessor paymentProcessor;
private PaymentNotifier paymentNotifier;
private PaymentRepository paymentRepository;
private InventoryProcessor inventoryProcessor;

public void processPayment(Payment payment, Order order) {
cardValidator.validate(payment)

boolean success = paymentProcessor.process(payment, order);

if (success) {
inventoryProcessor.decreaseInvenotory(order);
paymentNotifier.notifySuccess(order);
} else {
paymentNotifier.notifyFailure(order);
}

paymentRepository.save(payment);
}
}

Now, we have separated the responsibilities of the PaymentService class into four separate classes: CardValidator, PaymentProccessor, InventoryProcessor, and PaymentNotifier. Each of these classes has a clear, focused responsibility and can be easily tested and maintained independently of the others. PaymentService class is now only responsible for coordinating the execution of these other classes and handling the payment process flow.

Additionally, we added another class PaymentRepository responsible for saving the payment after the process; this also makes sense if we want to keep the payment in another storage in the future. It will be easy to change it.

Open-Closed Principle (OCP):

This principle states that a software module or class should be open for extension but closed for modification. This means new functionality should be added to a class or module through inheritance or composition rather than modifying the existing code. By following this principle, you can create a more robust and stable codebase that is easy to change and extend as the requirements of your application evolve.

An example of a class that violates the Open-Closed Principle is a PaymentProccessor class that handles multiple types of payments. The class contains a big switch statement that checks the payment type and calls the appropriate method.

class PaymentProcessor {
public void processPayment(Payment payment) {
switch(payment.getType()) {
case PAYMENT_TYPE.VISA:
processVisaPayment(payment);
break;
case PAYMENT_TYPE.MASTER_CARD:
processMasterCardPayment(payment);
break;
case PAYMENT_TYPE.PAYPAL:
processPayPalPayment(payment);
break;
// additional cases for other types of payments
}
}

private void processVisaPayment(Payment payment) {
// Visa processing logic
}

private void processMasterCardPayment(Payment payment) {
// MasterCard processing logic
}

private void processPayPalPayment(Payment payment) {
// PayPal processing logic
}
}

This class violates the OCP because every time a new type of payment is added, the switch statement must be modified. A better approach would be to create an abstract PaymentService class and have each payment type extend from it.

abstract class PaymentService {
public abstract void process();
}

class VisaPaymentService extends PaymentService {
@Override
public void process() {
// Visa processing logic
}
}

class MasterCardPaymentService extends PaymentService {
@Override
public void process() {
// MasterCardprocessing logic
}
}

class PayPalPaymentService extends PaymentService {
@Override
public void process() {
// PayPal processing logic
}
}

And now, PaymentProcessor class can accept any PaymentService object and the process method is called without modification.

class PaymentProcessor {
public void processPayment(PaymentService paymentService) {
paymentService.process();
}
}

Liskov Substitution Principle (LSP):

Liskov Substitution is a principle in object-oriented programming that states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the running of the software. This is possible if the subclass adheres to the contract established by the superclass or interface.

Here's an example of how PaymentService class might look when it violates the Liskov Substitution Principle.

abstract class PaymentService {
public abstract void validate();
}

class VisaPaymentService extends PaymentService {
@Override
public void validate() {
// Visa validate logic
}
}

class MasterCardPaymentService extends PaymentService {
@Override
public void validate() {
// MasterCard validate logic
}
}

class PayPalPaymentService extends PaymentService {
@Override
public void validate() {
throw new UnsupportedOperationException("Paypal does not have card number");
}
}

This code defines an abstract PaymentService class, and three subclasses VisaPaymentService, MasterCardPaymentService, and PayPalPaymentService that inherit from it. The PaymentService class defines an abstract validate method, which is implemented by the subclasses.

The issue with this code is that it violates the LSP. Because if a program is using the PaymentService class and calls the validate method, it may not expect that the PayPalPaymentService class will throw an exception when it is used.

To fix this violation of the LSP, the PaymentService class could be refactored to separate the validation logic for different payment types into different methods, and the PayPalPaymentService class could not implement the method for card number validation instead of throwing an exception.


interface PaymentService {
void validate();
}

class VisaCardPaymentService implements PaymentService {
private String cardNumber;
public void validate() {
// validate visa card number
}
}
class MasterCardPaymentService implements PaymentService {
private String cardNumber;
public void validate() {
// validate master card number
}
}
class PayPalPaymentService implements PaymentService {
private String email;
public void validate() {
// validate paypal email
}
}

public class PaymentProcessor {
public void processPayment(PaymentService paymentService) {
paymentService.validate();
// process payment
}
}

Interface Segregation Principle (ISP):

This principle states that clients should not be forced to implement interfaces they do not use. In other words, it's about creating small, specific interfaces with a single, well-defined purpose. This helps prevent large, bloated interfaces with many methods clients may not need or want to implement. By breaking down a large interface into smaller, more focused interfaces, we can improve the flexibility and maintainability of our code.

interface PaymentService {
void pay(Payment payment);
void refund(Payment payment);
void cancel(Payment payment);
}

class VisaPaymentService implements PaymentService {
public void pay(Payment payment) {
// code to process payment
}
public void refund(Payment payment) {
// code to process refund
}
public void cancel(Payment payment) {
// code to process cancel
}
}

class PayPalPaymentService implements PaymentService {
public void pay(Payment payment) {
// code to process payment
}
public void refund(Payment payment) {
// code to process refund
}
public void cancel(Payment payment) {
throw new UnsupportedOperationException("Cancel operation not supported for PayPal.");
}
}

In this example, the PaymentService interface defines three methods: pay, refund, and cancel. However, not all payment types support all of these operations. For example, PayPal payments cannot be canceled. But since the PayPalPaymentService class implements the PaymentService interface, it has to provide an implementation of the cancel() method, even though it doesn't make sense for PayPal payments. This violates the Interface Segregation Principle, which states that interfaces should be small and focused and that clients should not be forced to implement methods they don't use.

To fix this violation, we can create separate interfaces for each operation:

interface Payable {
void pay(Payment payment);
}
interface Refundable {
void refund(Payment payment);
}
interface Cancelable {
void cancel(Payment payment);
}

class VisaPayment implements Payable, Refundable, Cancelable {
public void pay(Payment payment) {
// code to process payment
}
public void refund(Payment payment) {
// code to process refund
}
public void cancel(Payment payment) {
// code to process cancel
}
}

class PayPalPayment implements Payable, Refundable {
public void pay(Payment payment) {
// code to process payment
}
public void refund(Payment payment) {
// code to process refund
}
}

This way, classes that only need to support specific operations are not forced to implement unnecessary methods.

Dependency Inversion Principle (DIP):

This states that high-level modules should not depend on low-level modules, but rather both should depend on abstractions. This principle is about creating a separation of concerns and decoupling the different parts of the system so that changes in one area do not affect the other areas.

An example of a PaymentService class that violates the Dependency Inversion Principle:

class PaymentService {
private MySQLDatabase mysql;

public PaymentService() {
this.mysql = new MySQLDatabase();
}

public void makePayment(Payment payment) {
// some payment logic
this.mysql.saveTransaction(payment);
}
}

In this example, the PaymentService class is tightly coupled to the MySQLDatabase class. If we wanted to change the database implementation to a different database system, we would also need to change the PaymentService class. This violates the Dependency Inversion Principle, which states that high-level modules should not depend on low-level modules.

One way to fix this issue would be to use dependency injection to inject the database dependency into the PaymentService class.

class PaymentService {
private Database database;

public PaymentService(Database database) {
this.database = database;
}

public void makePayment(Payment payment) {
// some payment logic
this.database.saveTransaction(payment);
}
}

This way, the PaymentService class is not tied to a specific database implementation, and we can easily change the implementation by providing a different implementation of the Database interface.

Another way would be to use an abstract factory pattern, where the PaymentService class would only depend on a factory class, which will create the database implementation, This way PaymentService class is not tied to any specific database implementation, and we can change the implementation of the database by providing a different factory class.

interface DatabaseFactory{
Database create();
}
class MySQLDatabaseFactory implements DatabaseFactory{
public Database create(){
return new MySQLDatabase();
}
}
class PaymentService {
private DatabaseFactory db;

public PaymentService(DatabaseFactory factory) {
this.db = factory.create();
}

public void makePayment(Payment payment) {
// some payment logic
this.db.saveTransaction(payment);
}
}

This way, the PaymentService class is not tied to a specific database implementation; we can change the implementation by providing a different factory class.

Conclusion:

This post explained SOLID principles, provided examples for each of these principles, and demonstrated how violations of these principles could lead to problems in software design. By understanding and applying these principles, developers can create more flexible, maintainable, and easy-to-understand software systems.

It’s worth noting that these principles are not the only design rules; in some cases, it may be more appropriate to violate one of these principles. However, in general, following these principles can help to create more robust and maintainable software.

Additionally, these principles are not only applicable to OOP language but also they can be applied to other paradigms such as functional programming. Principles are language-independent and give general rules for software design and architecture.

Cheers!

--

--