← Academy Blog

Mastering Clean Code: Unlocking Programming Principles' Power.

Each programming language has its own set of best practices. These practices help developers write scalable, readable, and organized code. Some of these practices are global and can be used with any of the programming languages. These global best practices are called Programming Principles. Programming Principles are rules and best practices that can be used across any programming domain and with any programming language. These principles are the fundamental knowledge for software development. Whether you just started programming or you are already an experienced developer, understanding and applying these principles will make a huge difference in your work.

In this article, we will analyze 3 programming principles that can and should be used in everyday programming implementation which can significantly improve the quality of your code. These principles, known as KISS (Keep It Simple, Stupid), DRY (Don’t Repeat Yourself), and SRP (Single Responsibility Principle), provide great guidance on how to write maintainable, adaptable, and robust code and can be used in all development domains.

Within this article, we will try to refactor the following code snippet, which can be used for making HTTP requests:

// src/packages/users/libs/types/user-dto.type.ts
type UserDto = {
  id: number;
  name: string;
};

// src/packages/posts/libs/types/post-dto.type.ts
type PostDto = {
  title: string;
  content: string;
};

// src/libs/packages/http/http.package.ts
class Http {
  baseUrl: string;

  headers: Headers;

  public constructor() {
    this.baseUrl = '';
    this.headers = new Headers();
  }

  public setBaseUrl(url: string): void {
    this.baseUrl = url;
  }

  public setHeaders(headers: Headers): void {
    this.headers = headers;
  }

  public async get<T = unknown>(url: string): Promise<T> {
    const response = await fetch(this.baseUrl + url, {
      method: 'GET',
      headers: this.headers,
    });
    const data = await response.json();

    console.log(`GET ${this.baseUrl + url} - Status: ${response.status}`);
    console.log('Response Data:', data);

    return data as T;
  }

  public async post<T = unknown>(url: string, data: T): Promise<T> {
    const response = await fetch(this.baseUrl + url, {
      method: 'POST',
      headers: this.headers,
      body: JSON.stringify(data),
    });
    const responseData = await response.json();

    console.log(`POST ${this.baseUrl + url} - Status: ${response.status}`);
    console.log('Response Data:', responseData);

    return responseData as T;
  }
}

// src/libs/packages/http/http.ts
const http = new Http();
http.setBaseUrl('https://api.example.com');
const headers = new Headers();
headers.append('Content-Type', 'application/json');
http.setHeaders(headers);

// src/slices/users/actions.ts
const getUserById = (id: number): Promise<UserDto> => {
  return http.get<UserDto>(`/user/${id}`);
};

// src/slices/posts/actions.ts
const createPost = (title: string, content: string): Promise<PostDto> => {
  return http.post<PostDto>('/posts', { title, content });
};

KISS - Keep It Simple, Stupid!

Often, developers unnecessarily overcomplicate the code they write. Writing complex code makes it harder in the future to debug it and prolongs development time. The KISS principle encourages developers to write the code as simply as possible. In most cases, it can be difficult or even impossible to do so, but you always should try to keep your code simple. Simplicity of code guarantees faster development, easier debugging, and also increases the code readability exponentially.

What is a simple code?

Simple code is not only about the code logic, it is also about clear variable naming and following quality criteria. These steps will ensure the creation of readable and maintainable code. Simple code does not always mean short code. This should especially be considered when writing one-liners. One-liners might look cool, doing a task only in one line of code. But when it comes to readability, it is important to keep it simple. The advantages of following KISS principle:

  1. Simplicity and readability: Code that is straightforward and easy to understand reduces the risk of introducing bugs and helps in debugging.
  2. Easier maintenance: KISS principle reduces the complexity of a codebase. When code is simple, maintaining, updating, and refactoring it becomes much easier.
  3. Faster Development: Simple code is quicker to develop, test, and update because it minimizes unnecessary complexity.

In our code snippet, we do not need to have methods setBaseUrl and setHeaders. With these methods, we might need to set different baseUrl and headers for each request. Instead, we can send baseUrl when we create the service and compute headers inside the service.

Here is how the code from the beginning of this article looks if we apply KISS principle:

// src/libs/types/content-type.type.ts
type ContentType = 'application/json' | 'multipart/form-data';

// src/libs/packages/http/http.package.ts
type Constructor = {
  baseUrl: string;
};

class Http {
  private baseUrl: string;

  public constructor({ baseUrl }: Constructor) {
    this.baseUrl = baseUrl;
  }
  // Other methods

  private getHeaders(contentType?: ContentType): Headers {
    const headers = new Headers();

    if (contentType) {
      headers.append('Content-Type', contentType);
    }

    return headers;
  }

  // Other methods
}

// src/libs/packages/http/http.ts
const http = new Http({ baseUrl: 'https://api.example.com' });

The fixed version is much easier to maintain. We can easily use this class for different endpoints, by creating an instance with baseUrl and we can easily add new headers, for example, if our Backend requires authentication. The new headers will be added in one place and we will not have to add headers separately in every place, where we use this class.

How to apply the KISS principle?

  1. Divide big programs into multiple smaller ones
  2. Use readable and meaningful variable names
  3. Avoid unnecessary complications

DRY - Don’t Repeat Yourself

As the name of this principle states, it encourages developers to never repeat the same code twice. According to this principle, the particular logic should be written only once within a codebase. The easiest way to violate the DRY principle is to copy and paste your own code.

Benefits of writing DRY code

  1. Code reusability: Avoiding duplicated code saves development time, ensures consistency in behavior, and reduces the chances of introducing new bugs.
  2. Easier debugging and maintenance: The debugging of DRY code happens within a few files. Finding the bug or adding a new feature to non-DRY code usually means doing the same changes in lots of files.
  3. Code Consistency: DRY code ensures that the logic is consistent across the files and different part of an application. This reduces the risk of errors caused by the inconsistencies between the duplicated code.

In our code snippet, we repeat the logic for GET and POST requests. We can combine methods for GET and POST requests and create one method named load, which will handle all request methods.

Here is how the code from the beginning of this article looks if we apply DRY principle:

// src/libs/packages/http/libs/types/http-request-parameters.ts
type HttpRequestParameters = {
  method: HttpMethod;
  url: string;
  payload?: BodyInit | null;
  contentType?: ContentType;
};

// src/libs/packages/http/http.package.ts
class Http {
  // Other methods

  public async load<T = unknown>({
    method,
    url,
    payload,
    contentType,
  }: HttpRequestParameters): Promise<T> {
    const headers = this.getHeaders(contentType);
    const response = await fetch(`${this.baseUrl}${url}`, {
      method,
      headers,
      body: payload ? JSON.stringify(payload) : null,
    });

    console.log(`${method} ${url} - Status: ${response.status}`);
    console.log('Response:', response);

    return response.json();
  }

  // Other methods
}

// src/libs/packages/http/http.ts
const http = new Http({ baseUrl: 'https://api.example.com' });

How to write DRY code?

  1. Always write reusable code, where it is possible
  2. Never copy-paste the same code
  3. Break your code into modular functions

SRP - Single Responsibility Principle

The Single Responsibility Principle (SRP) states that a class or module should have only one reason to change. In other words, each class or module should have a single responsibility or task to perform. This principle helps to keep the codebase organized, maintainable, and scalable.

By implementing the SRP principle, you can achieve the following advantages:

  1. Code Organization: Each class or module has a clear and defined purpose, making it easier to locate and understand its functionality.
  2. Ease of Maintenance: With a single responsibility, changes and updates to a specific functionality can be made without impacting other parts of the codebase.
  3. Enforcing KISS and DRY Principles: By having a clear and focused responsibility, it becomes easier to keep the code simple and avoid code duplication.

In our snippet, Http service is responsible for making requests and logging the message. The logging should be handled within its own class and Http service should not be responsible for that. As this principle states, each service should be responsible for handling one task.

Here is how the code from the beginning of this article looks if we apply the SRP principle:

// src/libs/packages/http/http.package.ts
type Constructor = {
  baseUrl: string;
  logger: Logger;
};

class Http {
  private baseUrl: string;

  private logger: Logger;

  public constructor(baseUrl, logger) {
    this.baseUrl = baseUrl;
    this.logger = logger;
  }
  // Other methods

  public async load<T = unknown>({
    method,
    url,
    payload,
    contentType,
  }: LoadArguments): Promise<T> {
    const headers = this.getHeaders(contentType);
    const response = await fetch(`${this.baseUrl}${url}`, {
      method,
      headers,
      body: payload ? JSON.stringify(payload) : null,
    });

    this.logger.log(`${method} ${url} - Status: ${response.status}`);
    this.logger.log(`Response: ${JSON.stringify(response)}`);

    return response.json();
  }

  // Other methods
}

// src/libs/packages/logger/logger.package.ts
class Logger {
  public log(message: string) {
    console.log('LOGGER: ', message);
  }
}

// src/libs/packages/logger/logger.ts
const logger = new Logger();

// src/libs/packages/http/http.ts
const http = new Http({
  baseUrl: 'https://api.example.com',
  logger,
});

We have created a separate Logger class, which is responsible for logging messages. Now HTTP requests and message logging are separated and handled by different classes.

By following the SRP principle, you can create more modular and maintainable code, which leads to better overall software quality.

How to apply SRP principle?

  1. Clearly define responsibilities
  2. Follow SRP in functions and methods
  3. First identify responsibilities, then separate them

Conclusion

In conclusion, these programming principles (KISS, DRY, and SRP) are essential for writing high-quality, maintainable, and readable code. By following these principles, developers can improve the efficiency of their development process, reduce code duplication, and make their code more organized and adaptable. It is important to note that these principles are not specific to any programming language and can be applied universally in any development domain. Embracing these principles will greatly benefit developers in their programming journey. There are lots of other principles, such as SOLID, YAGNI, and others. You can search and learn more about these principles, but they can be much difficult to understand.

After refactoring our snippet part by part, one principle at a time, here is the final version:

// src/libs/types/content-type.type.ts
type ContentType = 'application/json' | 'multipart/form-data';

// src/libs/packages/http/libs/types/http-method.type.ts
type HttpMethod = 'GET' | 'POST';

// src/packages/users/libs/types/user-dto.type.ts
type UserDto = {
  id: number;
  name: string;
};

// src/packages/posts/libs/types/post-dto.type.ts
type PostDto = {
  title: string;
  content: string;
};

// src/libs/packages/http/http.package.ts
type LoadArguments = {
  method: HttpMethod;
  url: string;
  payload?: BodyInit | null;
  contentType?: ContentType;
};

type Constructor = {
  baseUrl: string;
  logger: Logger;
};

class Http {
  private baseUrl: string;

  private logger: Logger;

  public constructor({ baseUrl, logger }: Constructor) {
    this.baseUrl = baseUrl;
    this.logger = logger;
  }

  private getHeaders(contentType?: ContentType): Headers {
    const headers = new Headers();

    if (contentType) {
      headers.append('Content-Type', contentType);
    }

    return headers;
  }

  public async load<T = unknown>({
    method,
    url,
    payload,
    contentType,
  }: LoadArguments): Promise<T> {
    const headers = this.getHeaders(contentType);
    const response = await fetch(`${this.baseUrl}${url}`, {
      method,
      headers,
      body: payload ? JSON.stringify(payload) : null,
    });

    this.logger.log(`${method} ${url} - Status: ${response.status}`);
    this.logger.log(`Response: ${JSON.stringify(response)}`);

    return response.json();
  }
}

// src/libs/packages/logger/logger.package.ts
class Logger {
  public log(message: string) {
    console.log('LOGGER: ', message);
  }
}

// src/libs/packages/logger/logger.ts
const logger = new Logger();

// src/libs/packages/http/http.ts
const http = new Http({ baseUrl: 'https://api.example.com', logger });

// src/slices/users/actions.ts
const getUserById = (id: number): Promise<UserDto> => {
  return http.load<UserDto>({
    url: `/user/${id}`,
    method: 'GET',
  });
};

// src/slices/posts/actions.ts
const createPost = (title: string, content: string): Promise<PostDto> => {
  return http.load<PostDto>({
    url: '/posts',
    method: 'POST',
    contentType: 'application/json',
    payload: JSON.stringify({ title, content }),
  });
};