Applying SOLID Principles in TypeScript with Node.js and Apollo Server: A Real-World GraphQL Example 🚀

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:

  1. Introduction to SOLID Principles
  2. Setting Up the Project
  3. Single Responsibility Principle (SRP)
  4. Open/Closed Principle (OCP)
  5. Liskov Substitution Principle (LSP)
  6. Interface Segregation Principle (ISP)
  7. Dependency Inversion Principle (DIP)
  8. 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:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  },
  "include": ["src"],
  "exclude": ["node_modules"]
}
tsconfig.json

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:

import 'reflect-metadata';
import { ApolloServer } from 'apollo-server';
import { buildSchema } from 'type-graphql';

async function startServer() {
  const schema = await buildSchema({
    // Resolvers will be added here
  });

  const server = new ApolloServer({ schema });

  server.listen({ port: 4000 }, () =>
    console.log('Server is running on http://localhost:4000/graphql')
  );
}

startServer();
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
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[] {
    return this.users;
  }
}
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:

class UserLogger {
  log(users: User[]) {
    console.log('Users:', users);
  }
}
UserLogger.ts

Then use it in the UserResolver:

@Resolver(User)
export class UserResolver {
  private userLogger = new UserLogger();

  @Query(() => [User])
  getUsers(): User[] {
    this.userLogger.log(this.users);
    return this.users;
  }
}
UserResolver.ts

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

import { Arg, Query } from 'type-graphql';

@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[] {
    return this.users;
  }

  @Query(() => [User])
  filterUsersByName(@Arg('name') name: string): User[] {
    return this.users.filter(user => user.name.includes(name));
  }
}
UserResolver.ts

OCP Violation Example

Now, let's violate the OCP by directly modifying the getUsers method to include filtering.

@Query(() => [User])
getUsers(@Arg('name', { nullable: true }) name?: string): User[] {
  if (name) {
    return this.users.filter(user => user.name.includes(name));
  }
  return this.users;
}
UserResolver.ts

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

import { Field, ObjectType } from 'type-graphql';
import { User } from './User';

@ObjectType()
export class SpecialUser extends User {
  @Field()
  isAdmin: boolean;
}
SpecialUser.ts

Step 2: Update the Resolver

@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[] {
    return this.users;
  }

  @Query(() => [SpecialUser])
  getSpecialUsers(): SpecialUser[] {
    return this.users.map(user => ({
      ...user,
      isAdmin: false,
    })) as SpecialUser[];
  }
}
UserResolver.ts

LSP Violation Example

Let's violate the LSP by changing the behavior of the subclass in a way that breaks the expected behavior.

@ObjectType()
export class SpecialUser extends User {
  @Field()
  isAdmin: boolean;

  // Overriding a method to break LSP
  getUserEmail() {
    throw new Error('Email is restricted');
  }
}
SpecialUser.ts

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

// src/services/IUserService.ts
export interface IUserService {
  getUsers(): User[];
  getUserById(id: string): User | undefined;
}

// src/services/IAdminService.ts
export interface IAdminService {
  promoteToAdmin(id: string): void;
}
IUserService.ts

ISP Violation Example

Let's violate the ISP by creating a single interface that combines both user and admin services.

export interface IUserAdminService {
  getUsers(): User[];
  getUserById(id: string): User | undefined;
  promoteToAdmin(id: string): void;
}
IUserAdminService.ts

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

// src/repositories/IUserRepository.ts
export interface IUserRepository {
  findAll(): User[];
  findById(id: string): User | undefined;
}
IUserRepository.ts

Step 2: Implement the Repository

// src/repositories/UserRepository.ts
import { IUserRepository } from './IUserRepository';
import { User } from '../entities/User';

export class UserRepository implements IUserRepository {
  private users: User[] = [
    { id: '1', name: 'John Doe', email: 'john@example.com' },
    { id: '2', name: 'Jane Smith', email: 'jane@example.com' },
  ];

  findAll(): User[] {
    return this.users;
  }

  findById(id: string): User | undefined {
    return this.users.find(user => user.id === id);
  }
}
UserRepository.ts

Step 3: Use the Repository in the Resolver

import { UserRepository } from '../repositories/UserRepository';

@Resolver(User)
export class UserResolver {
  private userRepository: IUserRepository;

  constructor() {
    this.userRepository = new UserRepository();
  }

  @Query(() => [User])
  getUsers(): User[] {
    return this.userRepository.findAll();
  }
}
UserResolver.ts

DIP Violation Example

Let's violate DIP by directly depending on a low-level implementation instead of an abstraction.

import { UserRepository } from '../repositories/UserRepository';

@Resolver(User)
export class UserResolver {
  private userRepository = new UserRepository(); // Direct dependency

  @Query(() => [User])
  getUsers(): User[] {
    return this.userRepository.findAll();
  }
}
UserResolver

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:

@Resolver(User)
export class UserResolver {
  constructor(private userRepository: IUserRepository) {}

  @Query(() => [User])
  getUsers(): User[] {
    return this.userRepository.findAll();
  }
}
UserResolver

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.