DEV Community

loading...

Do me a S.O.L.I.D.

moresaltmorelemon profile image Ezra Schwepker ・8 min read

When we first learn Object Oriented Programming, we're taught with simple concepts like a Dog is a Canine is an Animal. But those are just cutesy examples that obscure the real difficulties of designing an Object Oriented system.

When OOP was first introduced, it was championed as a way to write re-usable, modular code. If we cleanly separate everything into tidy groups of data and the methods that operate upon them, then we can treat everything like legos and build and build. However there's an issue lurking: no matter how perfect the initial design, requirements evolve, and what was once perfect is now inadequate and must be changed.

The question then becomes, how much has to change? Can we add a few lines of code and call it done? Do we have to rework a class, or watch as a quick fix cascades outwards, touching the entire code base?

Semantic Versioning

Before we go further, let's briefly discuss semantic versioning, a simple system for indicating the impact changes within a code base can be:

  • Patch version: the changes are minor bugfixes, are backwards compatible, and the new version can be substituted for prior versions with the same API with no discernible impact.
  • Minor version: the changes add functionality, but remain backwards compatible as the previous API was extended but not altered.
  • Major version: changes were made that break compatibility with prior APIs. Any code interacting with the API must also be updated to function properly.

While semantic versioning is typically reserved for applications and modules, we can apply this concept at a more granular level within our code.

SOLID

The 5 principles of SOLID design form a strategy for reducing the impact of changes within an Object Oriented System to primarily Path and Minor version updates.

  • Single Responsibility Principle: sensibly divide your code, so that each module is focused upon one task.
  • Open-Closed Principle: extend functionality without modifying the existing interface of a module.
  • Liskov's Substitution Principle: subclasses implement their parent's interface and functionality such that they can be substituted without altering the result.
  • Interface Segregation: define a focused and stable interface and limit changes to functionality improvements behind the interface.
  • Dependency Inversion: to prevent a tightly coupled system, implement abstracted interfaces between dependencies and consuming modules.

Single Responsibility Principle

A class or module should be focused upon a single purpose, so that only if that purpose changes should the module itself have to change.

Rather than lumping functionality into a messy utility drawer, subdivide your classes so that each has a specific purpose and reason for being. Careful subdivision results in isolated code which remains stable and re-usable, and reduces the impact of changes elsewhere in the system.

If for example we were to put all of the methods and information needed to access our backend into a single class, we would swiftly end up with a mess that would be constantly refactored as requirements change.

class AccessBackend {
  userInfo
  jwt
  baseURI
  registerUser: () =>
  authenticate: () =>
  getData: () =>
  postData: () =>
}

If instead we split the class according to purpose, we can now alter the implementation details of any class without effecting the others.

class User {
  userInfo
  registerUser: () =>
  loginUser: () =>
}

class Authenticate {
  jwt
  authenticate: () =>
  storeJWT: () =>
}

class HttpHandler {
  baseURI
  get: () =>
  post: () =>
  put: () =>
}

Alright, vocab time:

Connascence: the measure of the complexity caused by an interdependent system. A system is more connascent the wider the impact changing a single component has.

Core to the idea of connascence is the tension between two other concepts: coupling and cohesion.

Think of cohesion like a tidy workbench. A place for everything and everything in its place. Things that go together are together. The hammer is stored with the nails, while the drill bits are with the drill. The components needed to perform a single task are located in a single space.

On the other end, the more coupled your workbench is, the more components that are needed to perform a task aren't stored together, so when you want to do something, you have to open up every drawer and rummage through.

But a drill doesn't only use drill bits, and drill bits can be used by multiple kinds of drills. And the more you try and fit in a particular drawer, the harder it is to keep tidy and clean. So what might have seemed simple: put like things together, quickly becomes a task requiring careful thought.

Cohesion and coupling are two sides of the same coin. We can't just put everything in its own container, and we can't just dump it all in a single pile. Nor can we perfectly divide a system so that each drawer has everything needed for a specific task without duplicating the contents.

There will be coupling across containers and imperfect cohesion within. When we make changes to one container, at some point those changes will impact the contents of another container. However with careful planning, we can reduce the frequency with which that occurs.

Open/Closed Principle

Once you have defined a set of functionality which is accessible outside of a class, module or function, and that functionality is in use, any changes made to it will introduce potentially breaking changes to code dependent upon it.

When writing code that is designed to be re-used, responsible maintenance requires respecting the impact any change you make has upon others. Particularly in popular modules, another developer's interaction with your code may be tightly coupled and finely grained, including accommodations for errors and poor design choices.

One of the worst examples of this tight coupling is JavaScript itself. Despite the many flaws in the language, because JavaScript is so deeply embedded in the modern Internet, addressing those flaws is effectively impossible. Fixing something as fundamental as typeof null === "object" would break millions of websites. As a consequence, the development of the JavaScript language focuses upon extending the language while respecting the existing API.

Similarly, as you continue to build upon your own code, be mindful of any breaking changes. Avoid them, and if you cannot prevent them, time their release strategically, so that downstream developers are not constantly forced to refactor in order to maintain compatibility.

Liskov's Substitution Principle

Liskov's Substitution Principle gets a little strange, so stay with me. First we need to talk about Types and Type signatures.

A Type is effectively a set of expectations for how a particular set of properties can be interacted with. At a low level, we have data types like strings or arrays, which can be manipulated with operators like + or methods like .concat(). But at a higher level, we can define our own complex types.

type User {
  name: string;
  address: string;
  greet: (person: User) => User;
}

The declaration above tells us that if we encounter a data structure of the type User, it should have the defined properties. Similarly, the Type signature of the method greet indicates that it expects an input of the User type, and will return a User class.

If we define a class with a set of data and methods as a type, and if we define a class which inherits from that class as a subtype, then, because the subtype implements the functionality of the supertype, we can replace the supertype with the subtype and our program will continue to function without issue.

type User {
  greet: (person: User) => string;
}

type Moderator extends User {
  greet: (person: User) => string;
  promote: (person: User) => Moderator;
}

type Administrator extends Moderator {
  greet: (person: User) => string;
  promote: (person: User) => Administrator;
  ban: (person: User) => User;
}

Because a subtype may be substituted for its supertype, it is important that we retain compatibility with the Types that its methods may receive as arguments. For example the Administrator's promote method only requires that the given person be compatible with the User type, which means that the Administrator class can be substituted for the Moderator class without error.

Because the subtype is compatible with the supertype, we are not similarly constrained to return a supertype. Instead, as we have extended functionality within the subtype, we can return the subtype.

Vocab:

  • contravariance: supertypes ok, types ok, subtypes not ok
  • covariance: supertypes not ok, types ok, subtypes ok

In order to preserve compatibility among subtypes, we need to observe some rules:

  • contravariance of method arguments on the subtype: a method inherited from the suptype should accept the same type as the supertype's method.
  • covariance of return type: the return type of a subtype's method cannot be the supertype, but should be the subtype.
  • any thrown exceptions should be subtypes of exceptions thrown by the supertype. The error handling system should remain compatible with a substituted subtype.

A method of a subtype should be able to accept the same type of input as the supertype, but should return a subtype of the supertype's return type. Yikes. Let's rephrase that again.

class SuperType {
  method: (input: InputType) => ReturnType;
}

// ok
class SubType {
  method: (input: InputType) => SubReturnType;
}

// not ok
class SubType {
  method: (input: subInputType) => SubReturnType;
}

// not ok
class SubType {
  method: (input: subInputType) => ReturnType;
}

Liskov's Substitution Principle has a final consequence: because a type must be replaceable by its subtype, the subtype must implement the supertype fully. If a subtype does not implement the full supertype, then we need to rethink whether or not all the properties of the supertype actually belong at that level in the hierarchy.

We may need to alter the hierarchy so that the supertype only implements all shared properties across all subtypes, and a collection of subtypes which further share their own set of properties inherit from their own common ancestor.

class Bird {
  fly: () =>
}

class Seagull extends Bird {
  fly: () =>
}

class Penguin extends Bird {
  // fly: () =>
}

While we commonly think of flight as a core property of birds, it isn't shared by all, and unlike biology, Liskov's principle doesn't care about lineage, it cares about compatibility.

Instead we can insert a subtype of Bird which introduces the fly method and is shared by all flying birds:

class Bird {}

class FlightfullBird extends Bird {
  fly: () =>
}

class Seagull extends FlightfullBird {
  fly: () =>
}

class Penguin extends Bird {}

Interface Segregation

Interfaces govern how we interact with the internals of a module. They may provide a degree of abstraction, as well as protection by governing how a module is accessed.

A module is not limited to a single interface, and you may in fact choose to define multiple interfaces, each oriented towards a single, segregated purpose, altering what aspects of the module are accessible through each particular interface.

Dependency Inversion

Object Oriented programming promised easy code reuse, however the issue with reusable code is all the code being reused.

Unless a module is written to be explicitly dependency free, it will have dependencies, which may in turn have their own dependencies and so on. Each of these dependencies is tied to a specific version, and upgrading a version may introduce subtle bugs or break the functionality of downstream consumers entirely. We can easily arrive at a situation where our dependency tree contains multiple references to different versions of the same module.

While it is not practical to eliminate all dependencies, we can reduce the degree of coupling between our consuming modules and the dependencies that we require by inverting the typical approach.

Instead of directly referencing and utilizing a dependency within our modules, we define an abstract interface without regard to the actual details of implementation in both our module and the dependency. This interface acts as translator between the two, and localizes the external details of implementation to a single, internally controllable interface defined according to the abstract needs of the project.

SOLID, rephrased:

  • Decouple dependencies. Well defined modules should not be interacting directly with their dependencies. Instead define interfaces between them which abstract the details of interaction.
  • Keep APIs consistent and stable. Refactor behind the API.
  • Divide along the lines of refactoring. Expect to extend and refactor, and group the contents of a module accordingly. Each module should have a singular purpose and only a modification of that purpose should require alteration.
  • Extension over Modification. build upon existing functionality without modifying it, leaving consumers of the existing functionality unaffected.

Discussion

pic
Editor guide