DEV Community

Cover image for Software Development SOLID, LIFT, and FIRST principles
Leo Lanese
Leo Lanese

Posted on • Updated on

Software Development SOLID, LIFT, and FIRST principles

SOLID, LIFT, and FIRST principles

They are all sets of best practices and design principles in the field of software development, intended to improve the maintainability, readability, and scalability of your code. They each serve a different purpose but complement each other, aiming to produce high-quality, manageable, and robust software.

SOLID:

This is an acronym for five design principles in object-oriented programming and design. These principles help developers create systems that are easy to maintain, understand, and expand over time.

LIFT:

This principle provides a set of guidelines that help in structuring and organising code, especially in large code bases. It helps developers to quickly locate and identify the code they're looking for.

FIRST:

This is primarily related to writing good tests, particularly unit tests, in software development. FIRST principles help in creating effective tests that run quickly, can operate independently, provide consistent results, validate themselves, and can be written in a timely manner.

While SOLID, LIFT, and FIRST might focus on different aspects of the software development process, they share the same goal: To improve code quality. Now, when used together, these principles can help create code that is clean, efficient, and maintainable.

The combination of good design (SOLID), clear organisation (LIFT), and effective testing (FIRST) can greatly improve the robustness and quality of a software project.


S.O.L.I.D

SOLID Describes the five basic principles of object-oriented software design. It advocates a method of development that allows you to produce software that can easily be extended and is also easier to read.

• S - (SRP) Single Responsibility Principle
A Class should only do ONE job, or "Responsibility"



// Violates SRP
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root',
})
export class UserService {
  constructor(private http: HttpClient) { }

  getUserData(userId: string) {
    return this.http.get(`https://example.com/user/${userId}`);
  }

  saveUserDataInLocalStorage(userData) {
    localStorage.setItem('user', JSON.stringify(userData));
  }
}


Enter fullscreen mode Exit fullscreen mode


// Follows SRP
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root',
})
export class UserDataService {
  constructor(private http: HttpClient) { }

  getUserData(userId: string) {
    return this.http.get(`https://example.com/user/${userId}`);
  }
}

@Injectable({
  providedIn: 'root',
})
export class LocalStorageService {
  saveUserDataInLocalStorage(userData) {
    localStorage.setItem('user', JSON.stringify(userData));
  }
}


Enter fullscreen mode Exit fullscreen mode

• O - (OCP) Open/Closed Principle
Software entities (classes, modules, function, etc) should be: open for extension (inherit, extend, interfaces of, etc), but closed for modification (it works, do not touch.)

In Angular (similar to ReactJS), one way to apply OCP is to use dependency injection (DI). Dependency injection allows us to provide different implementations of a service without changing the service's code.

In the FallbackUserService, we use the original UserService to fetch the user data. If it fails, we provide default user data. This way, UserService remains "closed" for modification, but it's "open" for extension by FallbackUserService.



@Injectable({
  providedIn: 'root',
})
export class FallbackUserService {
  // UserService remains "closed" for changes but "open" for extension
  constructor(private userService: UserService) { }

  getUserData(userId: string) {
    return this.userService.getUserData(userId).pipe(
      catchError(() => of({ id: userId, name: 'Default User' }))
    );
  }
}


Enter fullscreen mode Exit fullscreen mode

• L - (LSP) Liskov Substitution Principle (LSP)
Objects of a "BASE CLASS" should be "REPLASABLE" (subtititable) with any of its "SUB-CLASSES" without breaking stuff, so without altering the correctness of that program, because a "Sub-Class must behaves like its Parent". In brief, its sub-class should be able to do what its Parent or superclass can do, now, it's not necessarily true that a superclass should always be able to do what a subclass does.

Now, let suppose we have to support another kind of user data retrieval.

Example:
VIP users, who have their own API endpoint and might have more data fields.

Without LSP, we might be tempted to just add another method in UserDataService like getVIPUserData(). However, following LSP, we would instead create a new class extending UserDataService:



@Injectable({
  providedIn: 'root',
})
export class UserDataService {
  constructor(protected http: HttpClient) { }

  getUserData(userId: string) {
    return this.http.get(`https://example.com/user/${userId}`);
  }
}

@Injectable({
  providedIn: 'root',
})
export class VIPUserDataService extends UserDataService {
  getUserData(userId: string) {
    return this.http.get(`https://example.com/vipuser/${userId}`);
  }
}


Enter fullscreen mode Exit fullscreen mode

Now, VIPUserDataService is a subclass of UserDataService and they are interchangeable without affecting the functionality of the program. This ensures that any function using a UserDataService can also accept a VIPUserDataService.

• I - Interface segregation principle (ISP)
Clients (or in this case, components or other services) should not be forced to depend upon interfaces they do not use. Essentially, it is better to have many small, "specific" interfaces than one large, general, "multi-purpose" interface



// Interface for reading user data
export interface UserDataReader {
  getUserData(userId: string): Observable<User>;
}

// Interface for managing user data
export interface UserDataManager {
  createUser(user: User): Observable<User>;
  updateUser(user: User): Observable<User>;
  deleteUser(userId: string): Observable<void>;
}


Enter fullscreen mode Exit fullscreen mode


@Injectable({
  providedIn: 'root',
})
export class UserDataService implements UserDataReader, UserDataManager {
  constructor(private http: HttpClient) { }

  getUserData(userId: string): Observable<User> {
    return this.http.get<User>(`https://example.com/user/${userId}`);
  }

  createUser(user: User): Observable<User> {
    return this.http.post<User>('https://example.com/user', user);
  }

  updateUser(user: User): Observable<User> {
    return this.http.put<User>(`https://example.com/user/${user.id}`, user);
  }

  deleteUser(userId: string): Observable<void> {
    return this.http.delete<void>(`https://example.com/user/${userId}`);
  }
}


Enter fullscreen mode Exit fullscreen mode

Now, any component or service that only needs to read user data can depend on UserDataReader and won't be forced to also depend on the methods for creating, updating, and deleting user data (which are part of UserDataManager).

This adheres to the ISP, and it makes our code more flexible, easier to understand, and less likely to break from changes.

• D - (DIP) Dependency Inversion Principle
High-level modules (which define the business logic) should not depend on low-level modules (that implement lower-level functionality), instead both should depend on abstractions or interfaces rather than concretions (tangible implementations of an abstraction or interface).
In brief, it promotes decoupling between components in a system by introducing an abstraction layer. Example: Dependency injection is one method following this principle.

To implement the Dependency Inversion Principle in Angular with our UserDataService example, we will first need to create an abstract service (an interface in TypeScript). This interface will be the contract that any 'user data service' will need to implement:



export interface IUserDataService {
  getUserData(userId: string);
}


Enter fullscreen mode Exit fullscreen mode

Then, we make our UserDataService implement this interface:



import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root',
})
export class UserDataService implements IUserDataService {
  constructor(private http: HttpClient) { }

  getUserData(userId: string) {
    return this.http.get(`https://example.com/user/${userId}`);
  }
}


Enter fullscreen mode Exit fullscreen mode

Now, any component or service that uses UserDataService should depend on IUserDataService (the abstraction), not directly on UserDataService (the detail).



import { Component, OnInit } from '@angular/core';
import { IUserDataService } from './i-user-data.service';

@Component({
  selector: 'app-user',
  templateUrl: './user.component.html',
  styleUrls: ['./user.component.css']
})
export class UserComponent implements OnInit {
  constructor(private userDataService: IUserDataService) { } // depends on the abstraction, not the detail

  ngOnInit() {
    this.userDataService.getUserData('123').subscribe(
      data => {...}
    );
  }
}


Enter fullscreen mode Exit fullscreen mode

Now, this way, UserComponent depends on an abstraction (IUserDataService), not a detail (UserDataService). If we ever need to change how we fetch user data (for example, if we want to fetch data from local storage instead of an HTTP server), we can create a new service that implements IUserDataService, and UserComponent won't need to change at all.


FIRST

The FIRST principles are a great approach to building clean, maintainable code. It aligns with many of the concepts in SOLID and other best practices in software development. Let's break each principle down. Shall we?

FIRST Keep components: Focused, Independent, Reusable, Small And Testable.

F - every object should have a single responsibility (This principle aligns closely with the Single Responsibility Principle (SRP) from SOLID)

I - make it moreINDEPENDENT and testable` (The goal here is to reduce the dependencies of your components to a minimum)

R - Reusable code can save you time and make it portable

S - Smaller APIs are easier to learn and teach to others. This is generally helped if you do one thing and do it well. (. This principle also aligns with SRP, since a component that does only one thing will tend to be small.)

T - Test your code (If a component is focused, independent, reusable, and small, it will also easier to test the code as behave predictably, similar to LSP principle)


LIFT:

L: Locating our code is easy

I: Identify code at a glance

F: Flat structure as long as we can

T: Try to stay DRY


Final notes:

If you are into clean code practices, I bet you already read the: "Clean Code: A Handbook of Agile Software Craftsmanship"
by Robert C. Martin (2008) it is must read.


💯 Thanks!

Now, don't be an stranger. Let's stay in touch!


leolanese’s GitHub image

🔘 Linkedin: LeoLanese
🔘 Twitter: @LeoLanese
🔘 Portfolio: www.leolanese.com
🔘 DEV.to: dev.to/leolanese
🔘 Blog: leolanese.com/blog
🔘 Questions / Suggestion / Recommendation: developer@leolanese.com

Top comments (0)