javascriptsolid-principles

SOLID principles: extract code in superclass


In my application I have N child classes all extending a superclass. Every class has its own implementation of serialize but they share some common code. For example:

class Serializer {
   serialize = () => throw Exception("...")
}

class JSONSerializer extends Serializer {
   serialize = () => {
      console.log("Formatting...")
      // ...
   }
}

class XMLSerializer extends Serializer {
   serialize = () => {
      console.log("Formatting...")
      // ...
   }
}

Do I violate the SOLID principles (in particular interface inheritance over implementation inheritance -Liskov substitution-) if I wrap the common code inside the superclass in a function format and call it inside the child classes? If yes, how can I adhere to the principle?

class Serializer {
   format = () => console.log("Formatting...")
   serialize = () => throw Exception("...")
}

class JSONSerializer extends Serializer {
   serialize = () => {
     this.format()
     // ...
   }
}

class XMLSerializer extends Serializer {
   serialize = () => {
     this.format()
     // ...
   }
}

Solution

  • Your modifications to the code do not violate the SOLID principles. Extracting common code into the superclass and calling them inside the child classes aligns with the principles.

    Here's why:

    Single Responsibility Principle (SRP): Each class has a single responsibility. Serializer is responsible for formatting, and JSONSerializer and XMLSerializer are responsible for creating their specific types of serialization.

    Open-Closed Principle (OCP): The Serializer class is open for extension but closed for modification. You can add new classes that inherit from Serializer, but you're not modifying the Serializer class every time you need a new type of serializer.

    Liskov Substitution Principle (LSP): Child classes can be used in place of parent classes without causing issues. You're not violating this because the serialize function in the child classes is not doing anything that would break the function as it is defined in the Serializer class.

    Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use. We're not seeing any interface dependencies here, so this principle is not violated.

    Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions. The high-level serialize method in Serializer does not depend on the details of the low-level format method.

    In addition, Dry (Don't Repeat Yourself) principle is well applied by moving the common code format into the parent class Serializer, thereby enhancing code maintainability and reducing potential errors.

    Just a note of caution though, throwing exceptions directly from super-class might not be an ideal practice, instead, you could structure it to return a not implemented message or handling it in some other way.

    Interface vs Implementation Inheritance

    Interface inheritance (also known as subtyping) is when a class provides the implementation for the methods of an interface. This type of inheritance is purely a "contract" that ensures the class adheres to a specific structure. In this case, a class agrees to implement a series of methods with specific input and return types.

    On the other hand, implementation inheritance (or subclassing) is when a class inherits behaviors (methods) and state (properties) from another class, which we call a superclass or a parent class. The idea behind this is to create more specific classes based on a general class.

    Your example is more about implementation inheritance since you're making subclasses JSONSerializer and XMLSerializer that bring over the format method from the parent Serializer class.

    Now, in terms of SOLID principles, it's generally recommended to prefer composition over inheritance and to rely on interfaces (interface inheritance). This principle is known as the Dependency Inversion Principle, the 'D' in SOLID. This principle tells us that the high-level modules should not depend on low-level modules directly but should depend on abstractions (usually interfaces). But, each case has its own context and needs, so there could be situations where implementation inheritance is more applicable.

    To apply composition in your case, you could:

    1 - Extracting the shared format() method into a separate Formatter class:

    class Formatter {
        format = () => console.log("Formatting...")
    }
    

    2- Instead of extending Serializer, our JSONSerializer and XMLSerializer classes will include an instance of Formatter to handle formatting:

    class JSONSerializer {
        constructor(){
           this.formatter = new Formatter(); 
        }
    
        serialize = () => {
            this.formatter.format();
            // ... JSON specific serialization
        }
    }
    
    class XMLSerializer {
        constructor(){
           this.formatter = new Formatter(); 
        }
    
        serialize = () => {
            this.formatter.format();
            // ... XML specific serialization
        }
    }
    

    Or if you prefer with dependecy injection:

    class Formatter {
        format = () => console.log("Formatting...")
    }
    
    class JSONSerializer {
        constructor(formatter){
           this.formatter = formatter;
        }
    
        serialize = () => {
            this.formatter.format();
            // ... JSON specific serialization
        }
    }
    
    class XMLSerializer {
        constructor(formatter){
           this.formatter = formatter; 
        }
    
        serialize = () => {
            this.formatter.format();
            // ... XML specific serialization
        }
    }
    
    const formatter = new Formatter();
    const jsonSerializer = new JSONSerializer(formatter);
    const xmlSerializer = new XMLSerializer(formatter);