3 Programming Principles Boiled Down to Memes

Sanjay
15 min readMay 23, 2024

Software Design Principles that they don’t teach you at school — KIS, DRY and SOLID, explained simply with memes.

Discrete mathematics, data structures and algorithms, calculus, probability and statistics, and, of course, programming are what schools and universities teach you when you do a Computer Science degree or a post-graduation. These are good skills to have, no doubt, but when you leave university and enter the industry, you need to know these important principles and concepts to have an easy transition.

In this blog, we will be discussing D.R.Y, K.I.S (a.k.a K.I.S.S) and S.O.L.I.D principles in programming, all of which are straightforward and easy to understand.

What will you gain from this blog?

  • 3 Programming principles simply explained that they don’t teach you at school
  • Must-Reads for Programming in 2024

Must-Reads for Programming in 2024

  • The Staff Engineer’s Path by Tanya Reilly 👉 here
  • Software Architecture by Neal Ford, Mark Richards, Pramod Sadalage, Zhamak Dehghani 👉 here
  • The Software Engineer’s Guidebook by Gergely Orosz 👉 here
  • Engineers Survival Guide by Merih Taze 👉 here

D.R.Y — Don’t Repeat Yourself

Every piece of knowledge must have a single, unambiguous, authoritative representation within a system Andy Hunt & Dave Thomas in The Pragmatic Programmer

This is a very important principle that is usually learned through experience writing or maintaining enterprise-level code. If you think you are performing the same logic or code over and over again as part of a program, you can create a function and reuse it.

Here is an example of a piece of code that doesn’t comply with the D.R.Y principle:

const product1Price = 100;
const salesTax1 = product1Price * 0.07;
console.log(`The sales tax for product 1 is: ${salesTax1}`);

const product2Price = 200;
const salesTax2 = product2Price * 0.07;
console.log(`The sales tax for product 2 is: ${salesTax2}`);

const product3Price = 300;
const salesTax3 = product3Price * 0.07;
console.log(`The sales tax for product 3 is: ${salesTax3}`);

To adhere to the D.R.Y principle, you can create a function that calculates the sales tax, reducing code duplication:

function calculateSalesTax(price: number, taxRate: number): number {
return price * taxRate;
}

const product1Price = 100;
const salesTax1 = calculateSalesTax(product1Price, 0.07);
console.log(`The sales tax for product 1 is: ${salesTax1}`);

const product2Price = 200;
const salesTax2 = calculateSalesTax(product2Price, 0.07);
console.log(`The sales tax for product 2 is: ${salesTax2}`);

const product3Price = 300;
const salesTax3 = calculateSalesTax(product3Price, 0.07);
console.log(`The sales tax for product 3 is: ${salesTax3}`);

This way, if there is any bug in the sales tax calculation logic, I don't have to fix the code at n different places. Changing it once inside the function will fix the bug everywhere.

K.I.S — Keep It Simple

It is also known as the K.I.S.S — Keep It Simple, Stupid principle, but I prefer to omit the stupid part and just call it K.I.S. Again, this is a principle that experience teaches, especially when working with different teams over your career. Oftentimes, enterprise tech and data teams have groups of developers working on different aspects of the project. Imagine, if you were to drop your hat and wear somebody else’s hat, would you prefer to work on a codebase that is messy, uncommented, lacks meaningful variable names, or too complex to understand what’s happening under the hood? Or would you prefer to work on a beautiful well-written, documented code, properly structured and easy to understand? Obviously, the latter is for me, and I hope it is for you, too.

Here are some scenarios where the K.I.S principle will really help you or your team:

  • You are working on a project alone, and you encounter bugs every day and need to debug and fix them. How easy would it be to debug your code if it was more complicated than it has to be?
  • You’re writing a complex business logic on a team project, and imagine you fall sick and your teammate has to take over. Would they prefer to start with a codebase which is too difficult to understand or would they prefer to start with something else? I would prefer the latter.
  • You come back from your sick leave, and since nobody took over your business logic work in your absence, you’re tasked with completing it now. Now that you’ve lost touch with the code base for a while and lost track of the thought processes that led you to write the complex code, would you prefer to work on it anymore? Or would you prefer to start with something you understand? I would prefer the latter.
Credit: HenryKrinkie

All of the scenarios we discussed above point to one thing — Keep It Simple.

Any fool can write code that a computer can understand. Good programmers write code that humans can understand. Martin Fowler, author of Refactoring: Improving the Design of Existing Code

Machines don’t care whether the code is complex to read and understand. As long as it’s compilable/interpretable, it executes it. In contrast, human brains get fatigued reading complex and hard-to-understand code.

Here is an example of a piece of code that is not compliant with the K.I.S principle:

function calculatePrice(initialPrice: number, taxRate: number, discountRate: number): number {
let finalPrice = initialPrice;
finalPrice += initialPrice * taxRate; // Add tax
finalPrice -= finalPrice * discountRate; // Apply discount

// Unnecessary complexity: checking for conditions that might never apply
if (finalPrice < 0) {
console.error("Final price cannot be negative.");
return 0;
}

return Math.round(finalPrice * 100) / 100; // Rounding to two decimal places
}

Now here is the K.I.S compliant version of the same code:

function calculatePrice(initialPrice: number, taxRate: number, discountRate: number): number {
const priceAfterTax = initialPrice * (1 + taxRate);
const finalPrice = priceAfterTax * (1 - discountRate);
return parseFloat(finalPrice.toFixed(2)); // Simple rounding to two decimal places
}

Here’s what’s changed in the K.I.S compliant version:

  • Streamlined Calculations: The calculation is compressed into fewer lines with direct, easy-to-understand arithmetic operations.
  • Avoiding Unnecessary Conditions: Removed the check for negative prices, assuming that input values are always logical (non-negative rates and prices).
  • Simplicity in Output: Uses toFixed(2) to handle the rounding, which both rounds and converts it back to a number in one line.S.O.L.I.D Principles

S.O.L.I.D Principles

This one is unique as it’s not one but five principles. All of these principles are crucial in the software development world.

S — Single Responsibility

Single Responsibility Principle fails in a Swiss Knife

This principle basically means that every method or class should have one and only one reason to change. It is the easiest of the five principles in S.O.L.I.D to understand. Every method or class must perform one task only.

Here is a piece of code that is not solid-compliant:

import fetch from "node-fetch";

interface Post {
id: number;
title: string;
body: string;
[key: string]: any; // Allows other properties but they are not specifically typed
}

// Function to fetch posts for a specific user
const getPosts = async (userId: number): Promise<Post[]> => {
try {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}/posts`);
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
const posts: Post[] = await response.json();
// Remove 'userId' from posts
const cleanedPosts = posts.map(({ userId, ...post }) => post);
return cleanedPosts;
} catch (error) {
console.error(error);
throw new Error("Error while fetching posts!");
}
};

// Main function to demonstrate use of getPosts
const main = async () => {
try {
const result = await getPosts(1);
console.log(result);
} catch (error) {
console.error("Failed to fetch or process posts:", error);
}
};

main();

Now here is the S.O.L.I.D compliant version of the same code:

import fetch from "node-fetch";

interface Post {
id: number;
title: string;
body: string;
[key: string]: any; // Allows other flexible properties but they are not specifically typed
}

// Function to fetch posts for a specific user from an API
const fetchPosts = async (userId: number): Promise<Post[]> => {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}/posts`);
if (!response.ok) {
throw new Error(`Failed to fetch posts: HTTP status ${response.status}`);
}
return response.json();
};

// Function to clean up the list of posts by removing the 'userId' property
const cleanPosts = (posts: Post[]): Post[] => {
return posts.map(({ userId, ...post }) => post);
};

// Function to handle errors during post fetching and logging
const handleError = (error: unknown): never => {
console.error("An error occurred:", error);
throw new Error("Error while processing posts!");
};

// Main function to orchestrate the fetching, cleaning, and handling errors
const main = async () => {
try {
const userId = 1; // example user ID
const posts = await fetchPosts(userId);
const cleanedPosts = cleanPosts(posts);
console.log(cleanedPosts);
} catch (error) {
handleError(error);
}
};
main();

O — Open Closed Principle

The core idea behind this principle is made clear by its definition: “Open to extension, closed for modification.” Once a class or method is implemented, it should be closed for further modification, but if any additional functionality is required in the future, it can be added as an extension using features like inheritance. The central idea is not to break existing code bases or unit tests, and by doing this, we also modularise our codebase.

Credit: Developer Kafası, Twitter

Consider this monolithic implementation of a PaymentProcessor class:

class PaymentProcessor {
processPayment(method: string, amount: number): void {
if (method === "CreditCard") {
console.log(`Processing credit card payment: $${amount}`);
} else if (method === "PayPal") {
console.log(`Processing PayPal payment: $${amount}`);
} else {
throw new Error("Payment method not supported");
}
}
refundPayment(method: string, amount: number): void {
if (method === "CreditCard") {
console.log(`Refunding credit card payment: $${amount}`);
} else if (method === "PayPal") {
console.log(`Refunding PayPal payment: $${amount}`);
} else {
throw new Error("Payment method not supported");
}
}
}

This class will need to be modified each time a new payment method is added, or an existing method is changed. For example, if we need to add a new form of payment using Bitcoin, the class will be modified as shown below:

class PaymentProcessor {
processPayment(method: string, amount: number): void {
if (method === "CreditCard") {
console.log(`Processing credit card payment: $${amount}`);
} else if (method === "PayPal") {
console.log(`Processing PayPal payment: $${amount}`);
} else if (method === "Bitcoin") {
console.log(`Processing Bitcoin transaction: ${amount} BTC`);
} else {
throw new Error("Payment method not supported");
}
}
refundPayment(method: string, amount: number): void {
if (method === "CreditCard") {
console.log(`Refunding credit card payment: $${amount}`);
} else if (method === "PayPal") {
console.log(`Refunding PayPal payment: $${amount}`);
} else if (method === "Bitcoin") {
console.log(`Refunding Bitcoin transaction: ${amount} BTC`);
} else {
throw new Error("Payment method not supported");
}
}
}

The problems with this monolithic implementation are:

  • Lack of Flexibility: Every time a new payment method is introduced, the PaymentProcessor class must be opened and modified. This increases the risk of introducing errors into the existing code.
  • High Maintenance Cost: As the number of payment methods grows, the code becomes harder to maintain. This complexity could lead to bugs and make the code more difficult to understand.
  • Violation of Open/Closed Principle: This approach clearly violates the Open/Closed Principle because the class is not closed for modification whenever new functionality is added.

Here is the Open/Closed compliant version of the same code above using polymorphism to extend functionality without modifying existing code. We start with the structure of the base class PaymentProcessor which the individual payment processors will extend.

abstract class PaymentProcessor {
abstract processPayment(amount: number): void;
abstract refundPayment(amount: number): void;
}

Next, we create specific implementations for different types of payment methods, such as credit card and PayPal payments. Each of these classes will extend the PaymentProcessor class and implement the abstract methods.

class CreditCardProcessor extends PaymentProcessor {
processPayment(amount: number): void {
console.log(`Processing credit card payment: $${amount}`);
}
refundPayment(amount: number): void {
console.log(`Refunding credit card payment: $${amount}`);
}
}
class PayPalProcessor extends PaymentProcessor {
processPayment(amount: number): void {
console.log(`Processing PayPal payment: $${amount}`);
}
refundPayment(amount: number): void {
console.log(`Refunding PayPal payment: $${amount}`);
}
}

To add a new payment method to our payment system, we simply create another class that extends PaymentProcessor. For instance, let's add Bitcoin as a new payment method.

class BitcoinProcessor extends PaymentProcessor {
processPayment(amount: number): void {
console.log(`Processing Bitcoin transaction: ${amount} BTC`);
}
refundPayment(amount: number): void {
console.log(`Refunding Bitcoin transaction: ${amount} BTC`);
}
}

Here is how the Open/Closed way of implementation is superior to monolithic implementation:

  • Base Class (PaymentProcessor): This abstract class provides a common interface for all payment processors.
  • Concrete Classes (CreditCardProcessor, PayPalProcessor, BitcoinProcessor): These classes provide specific implementations for the payment processing methods. Adding a new payment method does not require changes to existing classes, only adding new ones.

L — Liskov Substitution Principle

Liskov Substitution In Real Life

The Liksov Substitution Principle (LSP) states that any object of type S can be substituted with any subclasses (S1, S2, S3). Barbara Liskov first introduced this type of substitution, which is known as the Liskov Substitution Principle.

Below is an example of where LSP is violated:

abstract class PaymentProcessor {
abstract processPayment(amount: number): string;
}

class CreditCardProcessor extends PaymentProcessor {
processPayment(amount: number): string {
if (amount < 100) {
throw new Error("Minimum payment amount for credit card is $100");
}
console.log(`Processing credit card payment: $${amount}`);
return "Processed";
}
}
class PayPalProcessor extends PaymentProcessor {
processPayment(amount: number): string {
console.log(`Processing PayPal payment: $${amount}`);
return "Processed";
}
}
class BitcoinProcessor extends PaymentProcessor {
// This processor introduces a completely different return type, violating LSP
processPayment(amount: number): boolean {
console.log(`Processing Bitcoin transaction: ${amount} BTC`);
return true; // This should be a string as per the base class contract
}
}

Here are the problems with the above example:

  1. CreditCardProcessor: Throws an error for amounts less than $100, which is not expected from the base class. If used interchangeably in place of the superclass, it could lead to unexpected errors in contexts where the calling code does not expect exceptions based on payment amount.
  2. BitcoinProcessor: Changes the return type from string to boolean. This violates LSP because anyone using this class in place of its parent must now handle a different type, breaking polymorphism.
function executePayment(processor: PaymentProcessor, amount: number) {
try {
let result = processor.processPayment(amount);
console.log(`Payment status: ${result}`); // This line will fail for BitcoinProcessor
} catch (error) {
console.error("Error processing payment:", error.message);
}
}

// Using the processors
const creditCardProcessor = new CreditCardProcessor();
const payPalProcessor = new PayPalProcessor();
const bitcoinProcessor = new BitcoinProcessor();
executePayment(creditCardProcessor, 50); // Will throw an error, unlike other processors
executePayment(payPalProcessor, 50); // Works fine
executePayment(bitcoinProcessor, 50); // Type error: Payment status: true (should be a string message)

This implementation violates LSP because:

  • Not all subclasses can be used interchangeably without modifying the expected output or behaviour.
  • Clients using these classes must know details about the subclasses to use them correctly, which breaks the principle of type transparency required by LSP.

Here’s how we can implement the same code to adhere to LSP:

abstract class PaymentProcessor {
abstract processPayment(amount: number): string;
}
class CreditCardProcessor extends PaymentProcessor {
processPayment(amount: number): string {
console.log(`Processing credit card payment: $${amount}`);
return "Credit card payment processed";
}
}
class PayPalProcessor extends PaymentProcessor {
processPayment(amount: number): string {
console.log(`Processing PayPal payment: $${amount}`);
return "PayPal payment processed";
}
}
class BitcoinProcessor extends PaymentProcessor {
processPayment(amount: number): string {
console.log(`Processing Bitcoin transaction: ${amount} BTC`);
return "Bitcoin transaction processed";
}
}
function processPayments() {
const payments: PaymentProcessor[] = [
new CreditCardProcessor(),
new PayPalProcessor(),
new BitcoinProcessor()
];
payments.forEach(processor => {
console.log(processor.processPayment(100)); // Each processes $100
});
}
processPayments();

Here’s why this implementation adheres to LSP:

  • Base Class (PaymentProcessor): This class serves as a contract for all payment processors. It’s closed for modification, as you don’t need to change it when adding new payment types.
  • Subclasses (CreditCardProcessor, PayPalProcessor, BitcoinProcessor): Each class extends the base class, providing specific implementations for processing payments. These are open for extension as new payment methods can be added as subclasses.
  • ProcessPayments Function: This function demonstrates polymorphism, where each type of payment processor can be used interchangeably without the function processPayments knowing the specifics of each processor.

I — Interface Segregation Principle

ISP in real life

The Interface Segregation Principle (ISP) states that instead of using a generalised interface for a class, it’s better to use separate, segregated interfaces with limited functionalities. This is to maintain loose coupling in interfaces.

Let’s consider an example where a single interface is used for multiple functionalities, which not all implementing classes need:

interface Worker {
work(): void;
eat(): void;
sleep(): void;
}

class HumanWorker implements Worker {
work(): void {
console.log("Human working...");
}
eat(): void {
console.log("Human eating...");
}
sleep(): void {
console.log("Human sleeping...");
}
}
class RobotWorker implements Worker {
work(): void {
console.log("Robot working...");
}
eat(): void {
// Not applicable for robot but has to be implemented
throw new Error("Robot does not eat");
}
sleep(): void {
// Not applicable for robot but has to be implemented
throw new Error("Robot does not sleep");
}
}

This implementation violates the Interface Segregation Principle because RobotWorker is forced to implement eat and sleep methods that it does not need or use, which can lead to problematic and error-prone implementations.

Here’s how you can fix it:

interface Workable {
work(): void;
}

interface Eatable {
eat(): void;
}
interface Sleepable {
sleep(): void;
}
class HumanWorker implements Workable, Eatable, Sleepable {
work(): void {
console.log("Human working...");
}
eat(): void {
console.log("Human eating...");
}
sleep(): void {
console.log("Human sleeping...");
}
}
class RobotWorker implements Workable {
work(): void {
console.log("Robot working...");
}
}

Here’s how ISP makes this implementation better:

  • Workable Interface: Contains only the work() method, which is applicable to both humans and robots.
  • Eatable and Sleepable Interfaces: Only include the eat() and sleep() methods, respectively. These interfaces are implemented only by HumanWorker because these actions are relevant only to humans in this context.
  • HumanWorker: Implements all three interfaces (Workable, Eatable, and Sleepable) because a human can work, eat, and sleep.
  • RobotWorker: Implements only the Workable interface because a robot needs only to work.

Here’s how the refactoring helps:

  • Adheres to the Interface Segregation Principle, ensuring that classes are not forced to implement interfaces they do not use.
  • It makes the system more maintainable and reduces the risk of runtime errors (e.g., throwing exceptions for methods that should not be implemented by certain classes).
  • It provides a clear contract for what each type of worker can do, enhancing the code’s clarity and readability.

One of my favourite design principles in SOLID is Interface Segregation. It suggests dividing large interfaces into smaller ones with specific purposes, promoting loose coupling, better code management, and easier code usability.

D — Dependency Inversion Principle

Excavator Design follows DIP

The Dependency Inversion Principle asserts that high-level modules shouldn’t rely on low-level modules; instead, both should depend on abstractions. Additionally, these abstractions should not be based on details, but details should depend on abstractions. This principle aims to reduce dependencies between the components of a program, making it easier to manage as it scales. While this may seem like common sense, it’s easy to overlook these nuances in practice when designing software architecture.

Here is an example implementation that does not adhere to DIP:

class XMLHttpRequestService {
request(url: string): string {
// Simulate HTTP request
return `Data from ${url}`;
}
}

class ContentFetcher {
private httpService: XMLHttpRequestService;
constructor() {
this.httpService = new XMLHttpRequestService(); // Direct dependency on a specific HTTP service
}
fetchData(url: string): string {
return this.httpService.request(url);
}
}
const fetcher = new ContentFetcher();
console.log(fetcher.fetchData("http://example.com"));

Here are the issues with this implementation:

  • The ContentFetcher class is directly dependent on the XMLHttpRequestService class.
  • It is difficult to replace XMLHttpRequestService with another type of HTTP service without modifying ContentFetcher.
  • The code is not easily testable because ContentFetcher is tightly coupled with XMLHttpRequestService.

After refactoring the code to make it compliant with DIP:

interface HTTPService {
request(url: string): string;
}

class XMLHttpRequestService implements HTTPService {
request(url: string): string {
// Simulate HTTP request
return `Data from ${url}`;
}
}
class ContentFetcher {
private httpService: HTTPService; // Depend on abstraction, not concretion
constructor(httpService: HTTPService) {
this.httpService = httpService; // Dependency injection
}
fetchData(url: string): string {
return this.httpService.request(url);
}
}
// Usage with dependency injection
const xmlHttpRequestService = new XMLHttpRequestService();
const fetcher = new ContentFetcher(xmlHttpRequestService);
console.log(fetcher.fetchData("http://example.com"));

Here are the benefits of this implementation:

  • Flexibility: ContentFetcher can work with any HTTP service that implements the HTTPService interface, not just XMLHttpRequestService.
  • Testability: It is easier to test ContentFetcher by mocking the HTTPService interface.
  • Loose Coupling: High-level modules and low-level modules are loosely coupled through interfaces, making the system easier to maintain and extend.

If you find this post useful, I would highly appreciate it if you could share it on your social media 🙏 and if you want my articles delivered straight to your inbox, please consider subscribing to my newsletter Hustle & Code. Thank you for your time and attention.

--

--

Sanjay

I’m Sanjay. Founder of Its My Bio (itsmyb.io), Tech and Data Evangelist, Technical Writer and Blogger at IamSanjay.net and now a new Dad!