The 20 Essential Principles of Software Development: LoD, SoC, SOLID, and Beyond.

Pavlo Kolodka
Level Up Coding
Published in
19 min readApr 14, 2024

--

Thumbnail created by Author

Introduction

Software design principles are the foundation of software development. As a software engineer, you can find them in your work tools, languages, frameworks, paradigms, and patterns. They are the core pillars of “good” and “readable” code. Once you understand them, you can see them everywhere.

The skill of seeing and applying them is what distinguishes a good engineer from a bad one. No one framework or tool can improve your quality of writing good code without understanding the fundamentals; moreover, without them, you become a hostage of that tool.

This article isn’t a reference guide but rather my try to systemize a list of core principles that need to be refreshed from time to time.

Abstraction

Abstraction is one of the most significant principles in general. To abstract something means to focus on the important part while neglecting other details. Abstraction can be interpreted in two main ways: as a process of generalization and as the result of this generalization itself.

In software development, it often comes paired with encapsulation, a method used to hide the implementation of the abstracted parts. You can observe abstraction in various forms. For example, when you define a type, you abstract yourself from the memory representation of a variable. Similarly, when you abstract an interface or the signature of a function, you focus on what’s important: the contract to work with. When designing a class, you select only the relevant attributes for your domain and the specific business use cases. There are tons of other examples, but the main purpose of abstraction is that you don’t need to know the details of implementation to work with something; therefore, you can better focus on what is essential for you.

This principle is not exclusive to application development. You, as a programmer, abstracted through language syntax from underlying actions with an operation system. The OS, in turn, abstract your language translater from underlying operations with a CPU, memory, NIC, and so on. The more you go deeper, the more you understand that this is just a matter of abstraction.

Source: Reddit

Encapsulate what varies

As you can see, abstraction can manifest itself in different forms — from data (implementation) abstraction to hierarchical. A general rule of thumb for using abstraction is the principle: “Encapsulate what varies.” Identify the potentially changeable part and declare a concrete interface for it. This way, even if the internal logic changes, the client will still have the same interaction.

Suppose you need to calculate a currency conversion. At the moment, you only have two currencies. You can come up with something like this:

if (baseCurrency == "USD" and targetCurrency == "EUR") return amount * 0.90;
if (baseCurrency == "EUR" and targetCurrency == "USD") return amount * 1.90;

But another type of currency may be added in the future, which would require changes to the client code. Instead, it is better to abstract and encapsulate all the logic in a separate method and call that method from the client side when needed.

function convertCurrency(amount, baseCurrency, targetCurrency) {
if (baseCurrency == "USD" and targetCurrency == "EUR") return amount * 0.90;
if (baseCurrency == "EUR" and targetCurrency == "USD") return amount * 1.90;
if (baseCurrency == "USD" and targetCurrency == "UAH") return amount * 38.24;

}

DRY

DRY (don’t repeat yourself), also known as DIE (duplication is evil), states that you shouldn’t duplicate information or knowledge across your code base.

“Every piece of knowledge must have a single, unambiguous, authoritative representation within a system” — Andy Hunt and Dave Thomas, The Pragmatic Programmer.

The benefit of reducing code repetition is the simplicity of changing and maintaining. If you duplicate your logic in several places and then find a bug, you’re likely to forget to change it in one of the places, which will lead to different behavior for seemingly identical functionality. Instead, find a repetitive functionality, abstract it in the form of a procedure, class, etc., give it a meaningful name and use it where needed. This advocates a single point of change and minimizes the breaking of unrelated functionality.

KISS

The KISS (keep it simple, stupid) phrase was coined by aircraft engineer Kelly Johnson, who challenged his engineering team that the jet aircraft they were designing must be repairable by an average mechanic in the field under combat conditions with only specific tools.

The main idea behind it is to focus on the simplicity of a system, which increases understanding and reduces overengineering while using only the tools you really need.

YAGNI

When you design a solution to the problem, you are thinking about two things: how to better adapt it to the current system and how to make it extensible for possible future requirements. In the second case, the desire to build a premature feature for the sake of better extensibility is usually wrong: even if you now think that this will reduce the cost of integration, the maintenance and debugging of such code may not be obvious and unnecessarily complicated. Thus, you violate the previous principle by increasing the redundant complexity of the solution to the current problem. Also, don’t forget here’s a good chance that your presumed functionality may not be needed in the future, and then you’re just wasting resources.

That’s is what YAGNI or “You aren’t gonna need it” all about. Don’t get it wrong; you should think about what will be with your solution in the future, but only add code when you actually need it.

LoD

The Law of Demeter (LoD), sometimes referred to as the principle of least knowledge, advises against talking to “strangers”. Because LoD is usually considered with OOP, a “stranger” in that context means any object not directly associated with the current one.

The benefit of using Demeter’s Law is maintainability, expressed by avoiding immediate contact between unrelated objects.

As a result, when you interact with an object and one of the following scenarios is not met, you violate this principle:

  • When the object is the current instance of a class (accessed via this)
  • When the object is a part of a class
  • When the object passed to a method through the parameters
  • When the object instantiated inside a method
  • When the object is globally available.

To give an example, let’s consider a situation when a customer wants to make a deposit into a bank account. We might end up here having three classes — Wallet, Customer and Bank.

class Wallet {
private decimal balance;

public decimal getBalance() {
return balance;
}

public void addMoney(decimal amount) {
balance += amount
}

public void withdrawMoney(decimal amount) {
balance -= amount
}
}

class Customer {
public Wallet wallet;

Customer() {
wallet = new Wallet();
}
}

class Bank {
public void makeDeposit(Customer customer, decimal amount) {
Wallet customerWallet = customer.wallet;

if (customerWallet.getBalance() >= amount) {
customerWallet.withdrawMoney(amount);
//...
} else {
//...
}
}
}

You can see the violation of the Demeter law in the makeDeposit method. Accessing a customer wallet in terms of LoD is right (although it’s strange behavior from the logic perspective). But here, a bank object invokes the getBalance and withdrawMoney from the customerWallet object, thus talking to a stranger (wallet), instead of a friend (customer).

Before applying the LoD principle

Here’s how to fix it:

class Wallet {
private decimal balance;

public decimal getBalance() {
return balance;
}

public boolean canWithdraw(decimal amount) {
return balance >= amount;
}

public boolean addMoney(decimal amount) {
balance += amount
}

public boolean withdrawMoney(decimal amount) {
if (canWithdraw(amount)) {
balance -= amount;
}
}
}

class Customer {
private Wallet wallet;

Customer() {
wallet = new Wallet();
}

public boolean makePayment(decimal amount) {
return wallet.withdrawMoney(amount);
}
}

class Bank {
public void makeDeposit(Customer customer, decimal amount) {
boolean paymentSuccessful = customer.makePayment(amount);

if (paymentSuccessful) {
//...
} else {
//...
}
}
}

Now all interaction with a customer wallet is going through the customer object. This abstraction favors loose coupling, easy changing of the logic inside the Wallet and Customer classes (a bank object shouldn’t worry about the customer’s internal representation), and testing.

After adopting the LoD principle

Generally, you can say that LoD fails when there are more than two dots applied to one object, like object.friend.stranger instead of object.friend.

SoC

The Separation of Concerns (SoC) principle suggests breaking a system into smaller parts depending on its concerns. A “concern” in that meaning implies a distinctive feature of a system.

For example, if you are modeling a domain then each object can be treated as a special concern. In a layered system, each layer has its own care. In a microservice architecture, each service has its own purpose. This list can continue indefinitely.

The main thing to take out about the SoC is:

  1. Identify the system’s concerns;
  2. Divide the system into separate parts that solve these concerns independently of each other;
  3. Connect these parts through a well-defined interface.

In this fashion, the separation of concerts is very similar to the abstraction principle. The result of adhering to SoC is easy-to-understand, modular, reusable, built on stable interfaces and testable code.

SOLID

The SOLID principles are a set of five design principles introduced by Robert Martin aimed to clarify the initial constraint of object-oriented programming and make programs more flexible and adaptable.

Single responsibility principle

“A class should have one, and only one, reason to change.”

In other words:

“Gather together the things that change for the same reasons. Separate those things that change for different reasons.”

This is very similar to SoC, isn’t it? The difference between these two principles is that SRP aims at class-level separation, while SoC is a general approach that works both on high (e.g., layers, systems, services) and low-level (classes, functions, etc.) abstraction.

The single responsibility principle has all the advantages of SoC, in particular, it promotes high cohesion and low coupling and avoids the god object anti-pattern.

Open-closed principle

“Software entities should be open for extension but closed for modification.”

When you implement a new feature, you should keep existing code from breaking changes.

A class is considered open when you can extend it and add required modifications. A class is considered closed when it has clearly defined interfaces and won’t be changing in the future, i.e., it is available to use for another piece of code.

Imagine a classic OOP inheritance: you created a parent class and then later extended it with a child class with addition functionality. Then, for some reason, you decided to change an internal structure for the parent class (e.g. add a new field or remove some method), that is also accessible or directly impacts the derived class. By doing it, you violate this principle, because now you not only have to change the parent class, you also should adapt the child class for new changes. It’s happening because information hiding is not being properly applied. Instead, if you give a child class a stable contract through a public property or method, you are free to change your internal structure as long as it does not affect that contract.

This encourages client dependency on abstraction (e.g., interface or abstract class) rather than implementation (a concrete class). Acting in this way, a client that depended on abstraction is considered closed, but at the same time, it’s open for extension because all new modifications that comply with that abstraction can be seamlessly integrated for the client.

Let me give you another example. Let’s imagine we are developing discount calculation logic. So far, we only have two types of discount. Before applying the open-closed principle:

class DiscountCalculator {
public double calculateDiscountedPrice(double amount, DiscountType discount) {
double discountAmount = 15.6;
double percentage = 4.0;
double appliedDiscount;

if (discount == 'fixed') {
appliedDiscount = amount - discountAmount;
}

if (discount == 'percentage') {
appliedDiscount = amount * (1 - (percentage / 100)) ;
}

// logic
}
}

Now, the client (DiscountCalculator) is dependent on the external DiscountType. If we add a new type of discount, we will need to go to this client logic and extend it. That’s undesirable behavior.

After applying the open-closed principle:

interface Discount {
double applyDiscount(double amount);
}

class FixedDiscount implements Discount {
private double discountAmount;

public FixedDiscount(double discountAmount) {
this.discountAmount = discountAmount;
}

public double applyDiscount(double amount) {
return amount - discountAmount;
}
}

class PercentageDiscount implements Discount {
private double percentage;

public PercentageDiscount(double percentage) {
this.percentage = percentage;
}

public double applyDiscount(double amount) {
return amount * (1 - (percentage / 100));
}
}

class DiscountCalculator {
public double calculateDiscountedPrice(double amount, Discount discount) {
double appliedDiscount = discount.applyDiscount(amount);
// logic
}
}

Here, you take advantage of the open-closed principle and polymorphism instead of adding multiple if statements to determine the type and future behavior of some entity. All classes that implement the Discount interface are closed with respect to the public applyDiscount method, but at the same time, they are open for modification of their internal data.

Liskov substitution

“Derived classes must be substitutable for their base classes.”

Or, more formally:

“Let φ(x) be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T” (Barbara Liskov & Jeannette Wing, 1994)

In simple words, when you extend a class, you shouldn’t break the contract established in it. By “breaking a contract”, it means a failure to fulfill one of the next requirements:

  1. Don’t change the parameters in derived classes: child classes should conform parent’s method signatures, i.e., accept the same parameters as the parent does or accept more abstract parameters.
  2. Don’t change the return type in derived classes: child classes should return the same type as the parent does or return more concrete (subtype) parameters.
  3. Don’t throw an exception in derived classes: child classes shouldn’t throw an exception in their methods unless the parent class does. In that case, a type of exception should be the same or be a subtype of the parent’s exception.
  4. Don’t strengthen preconditions in derived classes: child classes shouldn’t change the expecting client’s behavior by restricting their work to some condition, e.g., in a parent class, you accept a string, but in a child class, you accept a string not more than 100 characters.
  5. Don’t weaken postconditions in derived classes: child classes shouldn’t change the expecting client’s behavior by allowing turn down some work, e.g., don’t clean up a state after an operation, don’t close a socket, etc.
  6. Don’t weaken invariants in derived classes: child classes shouldn’t change conditions defined in the parent class, e.g., don’t reassign the field of the parent class because you may not realize the whole logic around it.

Interface segregation

“Make fine-grained interfaces that are client-specific.”

Any code shouldn’t depend on methods that it doesn’t need. If a client doesn’t use some behavior of an object, why should it be forced to depend on it? Similarly, if a client doesn’t use some methods, why implementer should be forced to provide this functionality?

Break “fat” interfaces into more specific ones. If you change a concrete interface, these changes won’t affect unrelated clients.

Dependency inversion

“Depend on abstractions, not on concretions.”

Uncle Bob described this principle as a strict following of OCP and LSP:

“In this column, we discuss the structural implications of the OCP and the LSP. The structure that results from rigorous use of these principles can be generalized into a principle all by itself. I call it “The Dependency Inversion Principle” (DIP).” — Robert Martin.

Dependency inversion consists of two main statements:

  1. High-level modules should not depend upon low-level modules. Both should depend on abstractions
  2. Abstractions should not depend on details. Details should depend on abstractions.

For illustration, let’s say we are developing a user service responsible for user management. To persist changes, we decided to use PostgreSQL.

class UserService {
private PostgresDriver postgresDriver;

public UserService(PostgresDriver postgresDriver) {
this.postgresDriver = postgresDriver;
}

public void saveUser(User user) {
postgresDriver.query("INSERT INTO USER (id, username, email) VALUES (" + user.getId() + ", '" + user.getUsername() + "', '" + user.getEmail() + "')");
}

public User getUserById(int id) {
ResultSet resultSet = postgresDriver.query("SELECT * FROM USER WHERE id = " + id);
User user = null;
try {
if (resultSet.next()) {
user = new User(resultSet.getInt("id"), resultSet.getString("username"), resultSet.getString("email"));
}
} catch (SQLException e) {
e.printStackTrace();
}
return user;
}

// ...
}

Right now, UserService is tightly coupled to its dependency (PostgresDriver). But later on, we decided to migrate to the MongoDB database. Because MongoDB differs from PostgreSQL, we need to rewrite every method in our UserService class.

The solution for it is to introduce an interface:

interface UserRepository {
void saveUser(User user);
User getUserById(int id);
// ...
}

class UserPGRepository implements UserRepository {
private PostgresDriver driver;

public UserPGRepository(PostgresDriver driver) {
this.driver = driver;
}

public void saveUser(User user) {
// ...
}

public User getUserById(int id) {
// ...
}
// ...
}

class UserMongoRepository implements UserRepository {
private MongoDriver driver;
public UserPGRepository(MongoDriver driver) {
this.driver = driver;
}

public void saveUser(User user) {
// ...
}

public User getUserById(int id) {
// ...
}
// ...
}

class UserService {
private UserRepository repository;

public UserService(UserRepository database) {
this.repository = database;
}

public void saveUser(User user) {
repository.saveUser(user);
}

public User getUserById(int id) {
return repository.getUserById(id);
}
// ...
}

Now high-level module (UserService) is dependent on abstraction (UserRepository), and abstraction does not depend on details (SQL API for PostgreSQL and Query API for MongoDB); it depends on the interface constructed for the client.

In particular, to achieve dependency inversion, the dependency injection technique can be used, which you can read about at the link below:

GRASP

The General Responsibility Assignment Principles (GRASP) is a set of nine principles used in object-oriented design present by Craig Larman in his book Applying UML and Patterns.

Similar to SOLID, these principles aren’t built from scratch but rather composed of time-tested programming guidelines in the context of OOP.

High cohesion

“Keep related functionalities and responsibilities together.”

The high cohesion principle focused on keeping complexity manageable. In this context, the cohesion is a degree of how close the responsibilities of an object are. If a class has low cohesion, it means it’s doing work unrelated to its primary purpose or doing work that can be delegated to another subsystem.

Generally, a class designed with high cohesion has a small number of methods, and all these methods are highly related by their functionality. Doing that increases the maintainability, understanding, and reuse of code.

Low coupling

“Reduce relations between unstable elements.”

This principle aims to provide low dependency between elements, therefore preventing side effects arising from a code change. Here, coupling means a measure of how strongly one entity is dependent (has knowledge of or relies on) on another entity.

Program elements with high coupling very strongly rely on each other. If you have a class with high coupling, changes in it will lead to local changes in other parts of a system or vice versa. Such a design limits code reuse and takes more time to understand. On the other hand, low coupling supports the design of classes that are more independent, which reduces the impact of change.

The principles of coupling and cohesion go hand in hand. If you have two classes with high cohesion, the connection between them is usually weak. Similarly, if those classes have a low coupling between each other, by definition, they have a high cohesion.

The relation between coupling and cohesion

Information expert

“Place responsibilities with data.”

Information expert pattern answers the question of how we should assign responsibility for knowing a piece of information or doing some work. By following this pattern, an object who has direct access to needed information is considered to be an information expert on it.

Remember the example of applying Demeter’s Law between a customer and a bank? It’s essentially the same thing:

  • The Wallet class is an information expert on knowing balance and managing it.
  • The Customer class is an information expert regarding its internal structure and behavior.
  • The Bank class is an information expert in a bank area.

The fulfillment of a responsibility often requires gathering information across different parts of a system. Because of that, there should be intermediate information experts. With them, objects keep their internal information, which increases encapsulation and lowers coupling.

Creator

“Assign the responsibility for object creation to a closely related class.”

Who should be responsible for creating a new object instance? According to the Creator pattern, to create a new instance of x class, a creator class should have one of the following properties:

  • Aggregate x;
  • Contain x;
  • Records x;
  • Closely use x;
  • Have required initialization data for x.

This principle promotes low coupling because if you find the right creator for an object, i.e., a class that is already somehow related to that object, you won’t increase their connectivity.

Controller

“Assing the responsibility for handling system messages to a specific class.”

A controller is a system object responsible for receiving and delegating user events to the domain layer. It’s the first element that receives a service request from UI. Usually, one controller is used for handling similar use cases, such as UserController for managing user entity interaction.

Keep in mind that the controller should not do any business work. It should be as thin as possible. It should delegate work to the appropriate classes, not be responsible for it.

As an example, we can find the controller in MVC-like design patterns. Instead of direct communication between the model and the view, MVC introduces the controller — an intermediate part responsible for handling interaction between the view and model. With this component, the model becomes independent of external interaction with it.

Source: MDN Web Docs

Indirection

“To support a low coupling, assign the responsibility to an intermediate class.”

There is a famous aphorism of Butler Lampson: “All problems in computer science can be solved by another level of indirection.”

The indirection principle has the same idea as the dependency inversion principle: to introduce an intermediate between two elements so they become indirect. This is done to support weak coupling and has all the advantages that come with it.

Polymorphism

“When related alternative behaviors vary by type, use polymorphism to assign responsibility to the types for which the behavior varies.”

If you saw the code that checks object types using if/switch statements, it probably has a lack of polymorphism application. When required to extend this code with new functionality, you have to go to the place where the condition is checked and add a new if statement. That’s a poor design.

Using the polymorphism principle for different classes with related behavior, you unify different types, which leads to interchangeable software components, each responsible for specific functionality. Depending on the language, it can be done in numerous ways, but the common ones are to implement the same interface or use inheritance, in particular, giving the same name to methods for different objects. In the end, you get pluggable, easy-to-extend elements that don’t require changing unrelated code.

Like with the similar open-closed principle, it’s important to use polymorphism properly. It’s needed to apply only when you are sure that some components will or may vary. You don’t need to create an abstraction for implementing polymorphism, for example, on top of language-internal classes or a framework. They are already stable and you are just doing unnecessary work.

Pure fabrication

“To support a high cohesion, assign the responsibility to the convenient class.”

Sometimes, to follow high cohesion/low coupling principles, there is no corresponding real-world entity. From this point of view, you are creating a fabrication, something that does not exist in a domain. It’s pure because the responsibilities of this entity are clearly designed. Craig Larman suggests using this principle when an information expert applies logically wrong.

For example, a controller pattern is a pure fabrication. A DAO or a repository is also a fabrication. These classes are not exclusive to some domains but are convenient for developers. Although, we could put data access logic directly in the domain classes (since there are expert in their area), this would lead to a violation of high cohesion because data management logic is not directly related to how the domain object behaves. Coupling is also increased because we need to depend on the database interface. And code duplication is likely to be high because managing the data for different domain entities has similar logic. In other words, this leads to mixing different abstractions in one place.

The benefit of using pure fabrication classes is to group related behavior in objects, for which there is no alternative in the real world. This results in a good design with code reuse and low dependency on different responsibilities.

Protected variations

“Protect predicted variations by introducing a stable contract.”

To provide future changes without breaking other parts, you need to introduce a stable contract that stops undetermined impacts. This principle emphasizes the importance of earlier discussed principles for separating responsibilities between different objects: you need to apply the indirection to easily switch between different implementations, you need to use the information expert to decide who should be responsible for the fulfillment of a requirement, you need to design your system with the polymorphism in mind to introduce varying pluggable solutions, etc.

The protected variations principle is a core concept that drives other design patterns and principles.

“At one level, the maturation of a developer or architect can be seen in their growing knowledge of ever-wider mechanisms to achieve PV, to pick the appropriate PV battles worth fighting, and their ability to choose a suitable PV solution. In the early stages, one learns about data encapsulation, interfaces, and polymorphism — all core mechanisms to achieve PV. Later, one learns techniques such as rule-based languages, rule interpreters, reflective and metadata designs, virtual machines, and so forth — all of which can be applied to protect against some variation.” — Craig Larman, Applying UML and Patterns.

Conclusion

I am sure that before reading this article you have already used some of the principles discussed unconsciously. Now you have a name for them and it makes it easier to communicate.

You may have noticed that some of the principles have the same underlying idea. Essentially, it’s how it is. For example, information expert, SoC, high cohesion & low coupling, SRP, interface segregation, etc., all have the same point of view to separate concerns between different software elements. And they do it to fulfill protected variations, dependency inversion, and indirection to get maintainable, extensible, understandable, and testable code.

As with any tool, this is a guideline, not a strict rule. The key is to understand the trade-offs and make informed decisions. I intentionally did not touch upon the topic of the misapplication of software principles so that you could think for yourself and find answers. Not knowing the other side of the coin is as bad as not knowing these principles at all.

Thank you for reading this article!

Any questions or suggestions? Feel free to write a comment.

I’d also be happy if you followed me and gave this article a few claps! 😊

Check out some of my latest articles here:

Software Engineering

7 stories

Node.js

3 stories

--

--

Passionate Software Engineer with a love for learning and sharing interesting things.