In the world of software development, writing maintainable and scalable code is crucial for long-term success. The SOLID principles, a set of five design principles, are designed to help developers achieve these goals by promoting good software design practices. In this article, we'll explore how to apply the SOLID principles in a real-world Node.js application using TypeScript and Apollo Server to build a GraphQL API.
This article is divided into several sections:
- Introduction to SOLID Principles
- Setting Up the Project
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
- Conclusion
1. Introduction to SOLID Principles
SOLID is an acronym for five design principles that aim to make software more understandable, flexible, and maintainable. Here's a quick overview:
- Single Responsibility Principle (SRP): A class should have only one reason to change, meaning it should have only one job or responsibility.
- Open/Closed Principle (OCP): Software entities should be open for extension but closed for modification.
- Liskov Substitution Principle (LSP): Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
- Interface Segregation Principle (ISP): No client should be forced to depend on methods it does not use.
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions.
In this article, we'll implement a GraphQL API using Apollo Server and TypeScript, applying these principles to create a robust and scalable architecture.
2. Setting Up the Project
Before applying the SOLID principles, let's develop a basic Node.js project with TypeScript and Apollo Server.
step 1: Initialize the project
On the terminal, execute the following command:
mkdir solid-graphql && cd solid-graphql && npm init -y
step 2: Install dependencies
On the terminal, execute the following command:
npm install apollo-server graphql reflect-metadata type-graphql
npm install --save-dev typescript ts-node nodemon @types/node
step 3: Configure TypeScript
Create a tsconfig.json
file:
step 4: Create the project structure
mkdir src && touch src/index.ts
step 5: Set up Apollo Server
Let's start by setting up a basic Apollo Server in src/index.ts
:
With this setup, we're ready to start applying the SOLID principles. Let's Go 🧑💻
3. Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class should have only one reason to change. In the context of our GraphQL API, this means that each part of our application should be responsible for a single aspect of functionality. Let's implement a simple user management feature to demonstrate SRP.
Example: User Entity and Resolver
Let's create a User
entity and its corresponding resolver.
Step 1: Create the User Entity
mkdir -p src/entities
touch src/entities/User.ts
Step 2: Create the User Resolver
mkdir -p src/resolvers
touch src/resolvers/UserResolver.ts
Step 3: Register the Resolver
Update the src/index.ts
file to include the resolver:
import { UserResolver } from './resolvers/UserResolver';
const schema = await buildSchema({
resolvers: [UserResolver],
});
SRP in Action
In this example, the User
entity is only responsible for defining the structure of a user, and the UserResolver
is responsible for handling GraphQL queries related to users. This separation ensures that changes to how we fetch or modify users will not affect the user structure, adhering to SRP. Each class has a single responsibility, making the code easier to maintain.
SRP Violation Example
Now, let's see how violating SRP can cause issues. Imagine we decide to add user logging functionality directly into the UserResolver
.
import { Query, Resolver } from 'type-graphql';
import { User } from '../entities/User';
@Resolver(User)
export class UserResolver {
private users: User[] = [
{ id: '1', name: 'John Doe', email: 'john@example.com' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com' },
];
@Query(() => [User])
getUsers(): User[] {
this.logUsers(); // Added logging functionality
return this.users;
}
private logUsers() {
console.log('Users:', this.users);
}
}
Why This Violates SRP
The UserResolver
now has two responsibilities: fetching users and logging them. This coupling can make the code harder to change and test. If the logging requirement changes, you'll need to modify the UserResolver
, which violates SRP.
Solution: Create a separate UserLogger
class that handles logging:
Then use it in the UserResolver
:
This way, the UserResolver
remains focused on its primary responsibility.
4. Open/Closed Principle (OCP)
OCP in Action
The Open/Closed Principle suggests that software entities should be open for extension but closed for modification. Let's extend the UserResolver
to support filtering users by name without modifying the existing getUsers
method.
Step 1: Add a New Query
OCP Violation Example
Now, let's violate the OCP by directly modifying the getUsers
method to include filtering.
Why This Violates OCP
By modifying the getUsers
method to include filtering logic, we are changing the existing functionality. This violates OCP because any changes to the filtering logic could potentially break the original getUsers
method.
Solution: Instead of modifying the existing method, we should add a new method (like filterUsersByName
) that extends the functionality while keeping the original method unchanged.
5. Liskov Substitution Principle (LSP)
LSP in Action
The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. Let's create a SpecialUser
class that extends the User
class.
Step 1: Create the SpecialUser Class
Step 2: Update the Resolver
LSP Violation Example
Let's violate the LSP by changing the behavior of the subclass in a way that breaks the expected behavior.
Why This Violates LSP
The getUserEmail
method in SpecialUser
behaves differently from what is expected in the User
class. If we substitute a User
object with a SpecialUser
object, the application will break when trying to access the user's email. This violates LSP because the subclass does not adhere to the contract established by the superclass.
Solution: Ensure that the subclass does not change the expected behavior of the superclass. Any new functionality should be additive and not modify the existing contract.
6. Interface Segregation Principle (ISP)
ISP in Action
The Interface Segregation Principle states that no client should be forced to depend on methods it does not use. Let's create interfaces for different user services.
Step 1: Define Interfaces
ISP Violation Example
Let's violate the ISP by creating a single interface that combines both user and admin services.
Why This Violates ISP
By combining user and admin services into one interface, we force all clients to implement methods they might not need. For example, a client that only needs to retrieve users would also have to implement promoteToAdmin
, even if it's not relevant to their functionality.
Solution: Break down the interface into smaller, more specific interfaces (like IUserService
and IAdminService
). Clients should only implement the interfaces that are relevant to their needs.
7. Dependency Inversion Principle (DIP)
DIP in Action
The Dependency Inversion Principle suggests that high-level modules should not depend on low-level modules but on abstractions. Let's implement a simple user repository.
Step 1: Define the Repository Interface
Step 2: Implement the Repository
Step 3: Use the Repository in the Resolver
DIP Violation Example
Let's violate DIP by directly depending on a low-level implementation instead of an abstraction.
Why This Violates DIP
The UserResolver
is tightly coupled to the UserRepository
implementation. If we ever need to switch to a different repository implementation, we would need to modify the UserResolver
, which violates the DIP.
Solution: Depend on abstractions, not implementations. Use an interface (IUserRepository
) and inject the implementation:
This allows you to swap out the repository implementation without modifying the resolver.
8. Conclusion
Applying SOLID principles in your TypeScript and Node.js applications can significantly improve code quality, making it more maintainable, scalable, and robust. By understanding and adhering to these principles, you can avoid common pitfalls and create a cleaner, more modular architecture. However, it's also important to recognize when these principles are being violated and the potential consequences of such violations. Through careful design and thoughtful application of SOLID principles, you can build better software that stands the test of time.