Happy Employees == Happy ClientsCAREERS AT DEPT®
DEPT® Engineering BlogProcess

A comprehensive guide to understanding the SOLID principles

Through each of these sections, we’ll focus on various design principles, with particular attention to the five SOLID principles, which will be illustrated through examples.

A design principle empowers you to handle the complexity of the design process more efficiently, serving as a best practice to simplify design. Design Principles don’t provide a direct answer, but they offer a framework for approaching and solving problems.

Introduced by Robert C. Martin in 2000 in his paper on design principles and patterns, let’s first understand the distinctions between design principles and patterns.

Differences between design principles and design patterns:

AspectDesign PatternsDesign Principles
PurposeReusable solutions for common problemsGuidelines and best practices for design and problem-solving
ApplicationDirect solutions or answers to specific problemsOverarching approaches to follow best practices
FlexibilitySpecific and should be applied when neededBroad and applicable in various design situations
Interaction with othersCan be used in conjunction with design principlesComplement each other and can sometimes conflict
ExamplesSingleton, Observer, Factory Method, etc.SOLID principles, DRY (Don't Repeat Yourself), KISS (Keep It Simple, Stupid), etc.

Through each of these sections, we’ll focus on various design principles, with particular attention to the five SOLID principles, these will be illustrated through examples.

SOLID is a widely embraced principle for Object-Oriented programming languages. It’s an acronym for five principles:

  • Single Responsibility Principle (SRP)
  • Open-Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

Single Responsibility Principle: Writing clean and maintainable code

SRP suggests that classes, modules, and functions should each have a singular focus. The approach involves breaking down these entities into smaller components, each handling a distinct task. This strategy accelerates development and testing, enhancing understanding of each component’s individual role. By adhering to this principle, you can evaluate class scope to ensure it doesn’t breach SRP.

Let’s say we want to create a user class in which we obtain an image for the user and upload it to the S3 bucket. After obtaining the path to the uploaded file, we will update the database, and set the file path as the profile path for the user. So, let’s start with an empty class containing an empty method called updateAvatar

class UserService{
  public async updateAvatar(id: number, file: any) {
    try {
      // connect to S3
      // upload to S3
      // get path
      // find user and update it with given path
    } catch (error) {
      console.error('Avatar update failed:', error);
    }
  }
}

As you can see, we have an empty method for updating the avatar, which receives the path and updates the database. Now, let’s proceed to actually push it to S3.

class UserService{
  public async updateAvatar(id: number, file: any) {
    try {
      const s3 = new AWS.S3();
      const params = {
         Bucket: 'my-bucket',
         Key: `avatars/${this.id}-${Date.now()}.jpg`,
         Body: file,
      };

      const uploadResult = await s3.upload(params).promise();
      const avatarPath = uploadResult.Location;
    } catch (error) {
      console.error('Avatar update failed:', error);
    }
  }
}

Now that we have the upload result, we can update the database.

const user = await this.usersRepository.findById(id);
user.avatarPath = avatarPath;
await user.save();

So, we’re going to end up with a class that looks like this:

class UserService {
  private usersRepository: UserRepository;

  constructor(usersRepository: UserRepository) {
   this.usersRepository = usersRepository;
  }

  public async updateAvatar(id: number, file: any) {
    try {
      const s3 = new AWS.S3();
      const params = {
        Bucket: 'my-bucket',
        Key: `avatars/${this.id}-${Date.now()}.jpg`,
        Body: file,
      };

      const uploadResult = await s3.upload(params).promise();
      const avatarPath = uploadResult.Location;

      const user = await this.usersRepository.findById(id);
      user.avatarPath = avatarPath;
      await user.save();
    } catch (error) {
      console.error('Avatar update failed:', error);
    }
  }
}

As you can see in the example above, both uploading a file to S3 and interacting with the database, specifically writing to it, have been combined within a single method. This means that our updateAvatar method has two responsibilities:

  1. Uploading a file.
  2. Writing to the database.

This practice contradicts the first principle of SOLID, which is the Single Responsibility Principle. Now, let’s refactor the code:

  1. First, we create a “File” class that is responsible for uploading files.
  2. Then, we create a “UserUpdater” class, which is responsible for updating user-related information.
  3. Finally, we can include the file upload method inside our updateAvatar method to orchestrate these two methods together.

By separating these responsibilities into distinct classes, we adhere to SOLID principles and maintain a more modular and maintainable codebase.

class FileService {
  public async upload(file: any): Promise<string> {
    const s3 = new AWS.S3();
    const extension = this.getFileExtension(file);
    const params = {
      Bucket: 'my-bucket',
      Key: `avatars/${Date.now()}.${extension}`,
      Body: file,
    };

    const uploadResult = await s3.upload(params).promise();
    return uploadResult.Location;
  }

  public getFileExtension(file: any): string {
    const fileName = file.name || '';
    const parts = fileName.split('.');
    if (parts.length > 1)
      return parts[parts.length - 1];
    return '';
  }
}

As you can see, our FileService class now has the sole responsibility of handling files and includes a method to upload a file. Now, let’s refactor the “User” class as well to adhere to the Single Responsibility Principle (SRP).

class UserService {
  private usersRepository: UserRepository;
  private fileService: FileService;

  constructor(usersRepository: UserRepository, fileService: FileService) {
    this.usersRepository = usersRepository;
    this.fileService = fileService;
  }

  public async update(id: number, fields: Record<string, any>) {
    let user = await this.usersRepository.findById(id);
    user = {...user, ...fields};
    await user.save();
    return user;
  }
 
  public async updateAvatar(id: number, file: any) {
    try {
      const avatarPath = await this.fileService.upload(file);
      await this.update(id, { avatar: avatarPath });
    } catch (error) {
      console.error('Avatar update failed:', error);
    }
  }
}

With this approach, we’ll have code that is more testable and reusable. The file uploader can be reused in other services, and the user update functionality can be used to update other fields of the user. However, when used together within the updateAvatar method, they will cooperate seamlessly. This design promotes modularity and maintainability in our codebase.

Open-Closed Principle: Extending your code without modification

The Open-Closed Principle states that objects or entities should be open for extension but closed for modification. This means they should be extendable without altering their core implementation. This principle can be applied in Object-Oriented Programming (OOP) by creating new classes that extend the original class and override its methods, rather than modifying the original class directly.

In functional programming, this can be achieved through the use of function wrappers, where you can call the original function and apply new functionality to it without changing the original function itself.

The decorator design pattern is also a useful tool for adhering to this design principle. With decorators, you can attach new responsibilities or behaviors to objects without modifying their source code, thus keeping them closed for modification and open for extension.

In the example below, we are going to expand the “updateAvatar” method to include validation, preventing any non-image extensions from being uploaded. Let’s begin by modifying the “updateAvatar” method first.

public async updateAvatar(id: number, file: any) {
    try {
        // Get the file extension
        const fileExtension = this.fileService.getFileExtension(file);

        // Check if it's JPG, throw an error if not
        if (["jpg", "png"].includes(fileExtension.toLowerCase())) {
            throw new Error('Unsupported avatar format. Only image is allowed.');
        }

        const avatarPath = await this.fileService.upload(file);
        await this.update(id, {avatar: avatarPath});
        console.log('Avatar updated successfully.');
    } catch (error) {
        console.error('Avatar update failed:', error);
    }
}

As you can see, the code above violates the Open-Closed Principle (OCP), which is the second principle of SOLID, as it requires us to modify our code if we want to add more extensions. Now, let’s refactor this code to adhere to the OCP.

  1. We will create a file validator where we can pass a file and the expected extension.
  2. Then, our validator will check if the file extension matches the expected extension before allowing it to proceed.

Let’s begin by adding new methods to the “File” class.

class FileService {
    public async upload(file: any, extension: string): Promise<string> {
        const s3 = new AWS.S3();
        const params = {
            Bucket: 'my-bucket',
            Key: `avatars/${Date.now()}.${extension}`,
            Body: file,
        };

        const uploadResult = await s3.upload(params).promise();
        return uploadResult.Location;
    }

    public validate(file: any, supportedFormats: string[]): boolean {
        const fileExtension = this.getFileExtension(file);

        if (supportedFormats.includes(fileExtension.toLowerCase()))
            return true;

        throw new Error("File extension not allowed!");
    }

    public getFileExtension(file: any): string {
        const fileName = file.name || '';
        const parts = fileName.split('.');
        if (parts.length > 1)
            return parts[parts.length - 1];
        return '';
    }
}

As you can see, the old method for uploading a file has not been modified; instead, we have simply added new methods to the File class.

The second step is to add the validation method to our “updateAvatar” function.

public async updateAvatar(id:number, file:any) {
    try {
        this.fileService.validate(file, ["jpg"]);
        const avatarPath = await this.fileService.upload(file);
        await this.update(id, {avatar: avatarPath});
    } catch (error) {
        console.error('Avatar update failed:', error);
    }
}

Although there is still a minor concern — what if we had more supported formats? — you are completely correct. In that case, we would need to modify this method. So, let’s perform another refactoring on the code. We will separate the supported image formats from the “updateAvatar ” method and move them to global variables or application configuration, where we will only manage the constants, not the logic.

// config.js

export const SUPPORTED_IMAGE_FORMATS = ["jpg", "png", "jpeg", "svg", "webp"];

And then we can use these constants in our “updateAvatar” method.

this.fileService.validate(file, SUPPORTED_IMAGE_FORMATS);

Note: The reason we haven’t directly imported this constant into the “Validate” method is to keep it reusable. This way, we can use the “Validate” method for videos, documents, and other extensions as well.

Liskov Substitution Principle: Inheritance and polymorphism done right!

LSP suggests that any superclass needs to be replaceable with its subclasses without breaking the application. This means that if we have class A and class B is extended from class A, and there is a client for class B (a function, module, or anything that uses any property or method from class B), it should be able to use class A instead. So, if you use a superclass instance instead of a subclass, everything should still work correctly.

Let’s take a look at this example of the “UserService”, where we aim to implement new methods for database operations.

class UserService {
    private usersRepository: UserRepository;

    constructor(usersRepository: UserRepository) {
        this.usersRepository = usersRepository;
    }

    public async create(fields: Record<string, any>) {
        const user = await this.usersRepository.insert(fields);
        return user;
    }

    public async get(id: number) {
        const user = await this.usersRepository.findById(id);
        return user;
    }

    public async update(id: number, fields: Record<string, any>) {
        let user = await this.usersRepository.findById(id);
        user = {...user, ...fields};
        await user.save();
        return user;
    }

    public async delete(id: number) {
        await this.usersRepository.delete(id);
    }
}

In this example, the “UserService ”has implemented all the necessary methods from the “UserRepository”, which adds a new layer to our application for performing database operations. These methods not only interact with the database but also allow us to perform additional tasks, manipulate the results, or add validation.

However, let’s say we want to create similar services for other entities, such as “Roles,” where we manage roles and permissions. In this case, we would create a new class for the “Roles” service.

class RoleService {
    private roleRepository: RoleRepository;

    constructor(roleRepository: RoleRepository) {
        this.roleRepository = roleRepository;
    }

    public async create(fields: Record<string, any>) {
        const user = await this.roleRepository.insert(fields);
        return user;
    }

    public async get(id: number) {
        const user = await this.roleRepository.findById(id);
        return user;
    }

    public async update(id: number, fields: Record<string, any>) {
        let user = await this.roleRepository.findById(id);
        user = {...user, ...fields};
        await user.save();
        return user;
    }

    public async delete(id: number) {
        await this.roleRepository.delete(id);
    }
}

As you can see, these services are nearly identical, with the only difference being the injection of a different repository for each service. To reduce code duplication and promote reusability, let’s start by creating a superclass that handles the repository integration. We can then extend this superclass in subclasses for different entities.

class BaseService {
    private repository;

    constructor(repository) {
        this.repository = repository;
    }

    public async create(fields: Record<string, any>) {
        const user = await this.repository.insert(fields);
        return user;
    }

    public async get(id: number) {
        const user = await this.repository.findById(id);
        return user;
    }

    public async update(id: number, fields: Record<string, any>) {
        let user = await this.repository.findById(id);
        user = {...user, ...fields};
        await user.save();
        return user;
    }

    public async delete(id: number) {
        await this.repository.delete(id);
    }
}

Now that we have our “BaseService ”class, we can extend it to create the “UserService ”and “RoleService ”classes. This approach allows us to reuse common functionality and minimize code duplication.

class UserService extends BaseService {
    constructor(repository: UserRepository) {
        this.repository = repository;
    }
}

class RoleService extends BaseService {
    constructor(repository: RoleRepository) {
        this.repository = repository;
    }
}

As you can see, the “UserService ”and “RoleService ”classes no longer contain repository integration; they simply switch the repository they use. This demonstrates the beauty of the Liskov Substitution Principle (LSP), where we can replace these subclasses with their superclass, such as “BaseService”, without causing the application to crash.

Now, let’s take a look at the code where an instance of “UserService ”is created.

const userService = new UserService(new UserRepository());

The code where an instance of “UserService ”is created can be replaced by the superclass.

const userService = new BaseService(new UserRepository());

You can replace it with any other repository to create a new service for different repositories or entities.

Interface Segregation Principle: Flexible interfaces for specific needs

ISP refers to implementing only the methods of an interface that are needed. So, clients of an interface should not be forced to implement all the methods of an interface if those methods are not used. In other words, interface segregation points out the fact that having many small interfaces is more beneficial than having one general interface.

Let’s examine this example where we have a “UserService ”class extended by the “CustomerService ”and “SellerService ”classes. In this design, the “UserService ”has its own interface that enforces the subclasses extended from it to implement methods that may not necessarily be needed.

Now, let’s begin by creating the interfaces for our services.

interface BaseServiceInterface {
  create(fields: Record<string, any>): Promise<User>;

  get(id: number): Promise<User>;

  update(id: number, fields: Record<string, any>): Promise<User>;

  delete(id: number): Promise<void>;
}

interface UserServiceInterface extends BaseServiceInterface {
  getReviews(userId: number): Promise<Review[]>;

  getOrders(userId: number): Promise<Order[]>;

  getSells(userId: number): Promise<Order[]>;

  getShops(userId: number): Promise<Shop[]>;
}

Now, let’s implement the necessary methods that our “UserService ”is missing. This will ensure that the subclasses, such as “CustomerService ”and “SellerService”, can provide their own implementations for these methods as needed.

class UserService extends BaseService implements UserServiceInterface {
  private reviewService: ReviewService;
  private orderService: OrderService;
  private shopService: ShopService;

  constructor(
    repository: UserRepository,
    reviewService: ReviewService,
    orderService: OrderService,
    shopService: ShopService,
  ) {
    this.repository = repository;
    this.reviewService = reviewService;
    this.orderService = orderService;
    this.shopService = shopService;
  }

  getReviews(userId: number): Promise<Order[]> {
    this.reviewService.findAll({userId})
  }

  getOrders(userId: number): Promise<Order[]> {
    this.orderService.findAll({userId})
  }

  getSells(userId: number): Promise<Order[]> {
    this.orderService.findAll({sellerId: userId})
  }

  getShops(userId: number): Promise<Order[]> {
    this.shopService.findAll({userId})
  }
}

As you can see, our “UserService ”has implemented all the methods it needs and will inherit the remaining methods such as “get,” “create,” etc., from “BaseService”.

Now, let’s proceed to extend our “UserService ”into “CustomerService ”and “SellerService”.

class CustomerService extends UserService implements UserServiceInterface {
  //
  //
}

class SellerService extends UserService implements UserServiceInterface {
  //
  //
}

However, there is an issue: a regular customer can’t have sales or a shop, while a seller can’t post a review or have orders. Therefore, we need to override these methods to prevent them from being used.

class CustomerService extends UserService implements UserServiceInterface {
  getSells() {
    throw new Error("User is not a seller");
  }

  getShops() {
    throw new Error("User is not a seller");
  }
}

class SellerService extends UserService implements UserServiceInterface {
  getReviews() {
    throw new Error("User is not a seller");
  }

  getOrders() {
    throw new Error("User is not a seller");
  }
}

The problem has been resolved, and instances from the customer or seller can now only use the allowed methods. However, this example currently violates the Interface Segregation Principle by having one large, generic interface instead of many small interfaces. To rectify this, we can follow these steps:

  1. Break the “UserServiceInterface ”into two interfaces: “CustomerServiceInterface ”and “SellerServiceInterface”.
  2. Move methods that belong to one of these interfaces but not the other. If there are common methods, they can remain in the “UserServiceInterface”.
  3. Instead of implementing the generic interface on “SellerService ”and “CustomerService ”classes, we will implement the specific interfaces.

Let’s begin with these steps.

interface UserServiceInterface extends BaseServiceInterface {
  //
}

interface CustomerServiceInterface extends UserServiceInterface {
  getReviews(userId: number): Promise<Review[]>;

  getOrders(userId: number): Promise<Order[]>;
}

interface SellerServiceInterface extends UserServiceInterface {
  getSells(userId: number): Promise<Order[]>;

  getShops(userId: number): Promise<Shop[]>;
}

So, we have just split our interface into two smaller interfaces. Next, we will move the necessary methods into the related classes only.

class CustomerService extends UserService implements CustomerServiceInterface {
  private reviewService: ReviewService;
  private orderService: OrderService;

  constructor(
    repository: UserRepository,
    reviewService: ReviewService,
    orderService: OrderService,
  ) {
    this.repository = repository;
    this.reviewService = reviewService;
    this.orderService = orderService;
  }

  getReviews(userId: number): Promise<Order[]> {
    this.reviewService.findAll({userId})
  }

  getOrders(userId: number): Promise<Order[]> {
    this.orderService.findAll({userId})
  }
}

class SellerService extends UserService implements SellerServiceInterface {
  private orderService: OrderService;
  private shopService: ShopService;

  constructor(
    repository: UserRepository,
    orderService: OrderService,
    shopService: ShopService,
  ) {
    this.repository = repository;
    this.orderService = orderService;
    this.shopService = shopService;
  }

  getSells(userId: number): Promise<Order[]> {
    this.orderService.findAll({sellerId: userId})
  }

  getShops(userId: number): Promise<Order[]> {
    this.shopService.findAll({userId})
  }
}

As you can see, we no longer have methods and properties that are not used in those classes, and there is no requirement to implement unused methods. This adheres to the Interface Segregation Principle and results in more focused and efficient interfaces for each class.

Dependency Inversion Principle: Building flexible and maintainable software designs!

The Dependency Inversion Principle (DIP) suggests that high-level modules should not have direct dependencies on low-level modules. Instead, both high-level and low-level modules should depend on abstractions or interfaces. Furthermore, abstractions should not rely on implementation details; rather, implementation details should depend on abstractions. By adhering to this principle, the risk of unintended side effects in high-level modules caused by changes in low-level modules is minimized. With the introduction of an abstract layer, dependencies are inverted, reducing the traditional top-down dependency structure.

Let’s examine an example involving a “UserService” and “RoleRepository” class. In this scenario, the “UserService” includes an implemented “getRole” method to retrieve the role of a user.

class UserService extends BaseService {
  constructor(repository: UserRepository) {
    this.repository = repository;
  }
}

In the previous examples, the “UserService” implemented the “BaseService”. However, to add a new method called “getRole”, we will include it in the “UserService” to retrieve the user’s role. Additionally, within the “getRole” method, we will create an instance of “RoleRepository” to access the “RoleRepository”.

class UserService extends BaseService {
  constructor(repository: UserRepository) {
    this.repository = repository;
  }

  public async getRole(userId: number): Promise<Role> {
    const roleRepository = new RoleRepository();
    const user = await this.get(userId);
    const role = await roleRepository.findById(user.roleId);
    return role;
  }
}

With the current approach, we can achieve our desired functionality, but we are also violating the fifth principle of SOLID, which is Dependency Inversion. This is because our “UserService ” is dependent on the details of the “RoleRepository” and creates an instance of it.

To address this issue, we need to inject the “RoleRepository” as a dependency into the “UserService” instead of creating a class instance within it. Furthermore, since “RoleRepository” implements “BaseRepository”, which is an abstract class, we can ensure that the get method exists in that abstract class. This approach helps us avoid relying on implementation details and promotes better adherence to the Dependency Inversion Principle.

class UserService extends BaseService {
  private roleRepository: RoleRepository;

  constructor(repository: UserRepository, roleRepository: RoleRepository) {
    this.repository = repository;
    this.roleRepository = roleRepository;
  }

  public async getRole(userId: number): Promise<Role> {
    const user = await this.get(userId);
    const role = await this.roleRepository.findById(user.roleId);
    return role;
  }
}

In the refactored code, we can easily replace “RoleRepository” with any other repository that handles roles. An improvement in this code is that, instead of using the “RoleRepository” class, we can utilize the “RoleService”. This approach keeps the “RoleRepository” layer isolated within the “RolesModule”, promoting a more modular and maintainable design.

class UserService extends BaseService {
  private roleService: RoleService;

  constructor(repository: UserRepository, roleService: RoleService) {
    this.repository = repository;
    this.roleService = roleService;
  }

  public async getRole(userId: number): Promise<Role> {
    const user = await this.get(userId);
    const role = await this.roleService.get(user.roleId);
    return role;
  }
}

Concluding the understanding of the SOLID Principles

In this exploration of SOLID principles, we've delved into key principles that serve as guiding lights in software design. These principles, namely the Single Responsibility Principle (SRP), Open-Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP), play a pivotal role in shaping robust, maintainable, and flexible software architectures.

  1. Single Responsibility Principle (SRP): Keep classes, modules, and functions focused on a single task. This streamlines development, testing, and code understanding.
  2. Open-Closed Principle (OCP): Design code to be open for extension but closed for modification. This allows you to add new features without changing existing code.
  3. Liskov Substitution Principle (LSP): Subclasses should be able to replace their superclasses without causing issues. This ensures that new subclasses fit seamlessly into existing code.
  4. Interface Segregation Principle (ISP): Create small, focused interfaces rather than large, unwieldy ones. This prevents unnecessary method implementations and keeps code clean.
  5. Dependency Inversion Principle (DIP): High-level modules should not directly depend on low-level modules. Both should rely on abstractions, reducing the risk of unintended side effects when making changes.

By applying these principles wisely, software development becomes more manageable, adaptable, and efficient. SOLID principles are valuable tools for building resilient, maintainable, and extensible software systems.