Social Network Presence: Clean Architecture

Node, Typescript, Express, React

Photo by drmakete lab on Unsplash

Contents

  1. What is Clean Architecture?
  2. What Are the SOLID Principles?
  3. Why Use It?
  4. Directory Structure
  5. Key Components of Hexagonal Architecture
  6. Step-by-Step Guide with Red/Green Refactoring
  7. Step 1: Setting Up the Project
  8. Step 2: Red Phase - Write a Failing Test for the Post Entity
  9. Step 3: Green Phase - Implement the Post Entity
  10. Step 4: Refactor Phase - Improve Code Quality
  11. Step 5: Red Phase - Add a Failing Test for updateContent
  12. Step 6: Green Phase - Implement updateContent
  13. Step 7: Red Phase - Write a Failing Test for CreatePost Use Case
  14. Step 8: Green Phase - Implement CreatePost Use Case
  15. Step 9: Code Coverage
  16. Step 10: Refactor for better code quality

What is Clean Architecture?

Clean Architecture, also known as Hexagonal Architecture, is a software design paradigm that emphasizes modularity, maintainability, and testability. By decoupling the core business logic from external systems (like databases, APIs, or UI frameworks), applications become more adaptable and easier to evolve over time.

Code is often organised into layers, each layer with a specific responsibility:

  • Core (or Domain) Layer: The heart of the application containing business logic, interfaces and entities.
  • Application Layer: Coordinates workflows between the core and ports.
  • Adapters Layer: Implementations of ports that allow interaction with specific technologies.
  • Infrastructure Layer: Contains the application setup and configuration.

What are the SOLID Principles?

The SOLID principles guide developers in creating maintainable and scalable systems. These principles are foundational to Clean Architecture:

  • Single Responsibility Principle (SRP): A class should only have one reason to change.
  • Open/Closed Principle (OCP): Classes should be open for extension but closed for modification.
  • Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types without altering program behavior.
  • Interface Segregation Principle (ISP): Interfaces should only contain methods relevant to the implementing class.
  • Dependency Inversion Principle (DIP): High-level modules should depend on abstractions, not on concrete implementations.

By adhering to these principles, we can build software that is easy to maintain, extend, and test.

Why Use It?

  • Modularity: By separating concerns into different layers, the codebase becomes more modular and easier to understand.
  • Testability: Isolating the core logic from external dependencies makes it easier to write unit tests.
  • Flexibility: Adapters allow for easy swapping of technologies, making the application more adaptable to future changes.
  • Maintainability: Separation of concerns makes the codebase easier to maintain over time.

Traditional Structure

In a typical express application - without clean architecture - the directory structure often looks as follows:

/api
├── src/
│   ├── controllers/
│   │   ├── postController.js
│   ├── models/
│   │   ├── postModel.js
│   ├── routes/
│   │   ├── postRoutes.js
│   ├── /middlewares
│   │   ├── authMiddleware.js
│   ├── utils/
│   │   ├── logger.js
│   ├── config/
│   │   ├── db.js
│   │   ├── env.js
│   ├── package.json
│   ├── app.js
├── tests/
├── .gitignore
├── package.json
├── tsconfig.json
└── README.md

Problems with the traditional structure

  • Tight Coupling: Controllers often directly integrate with models creating tight coupling between the HTTP layer and database layer.
  • Testability challenges: Testing the business logic often requires mocking external systems such as the database since the logic is intermingled with the controllers/models.
  • Difficulty in swapping technologies: If we wanted to swap the database from an in-memory database to MongoDB, we would have to make significant changes to the controllers and models.
  • Mixed responsiblities: The business logic can often end up spread across controllers, models and even utilities.

Clean Architecture Structure

In contrast a clean architecture organises the codebase into layers focusing on the separation of concerns, here is a simple example:

api/
│
├── src/
│   ├── core/                      # Core business logic
│   │   ├── domain/                # Pure domain entities
│   │   │   ├── entities/          # Business entities (e.g., Post)
│   │   │   │   ├── Post.ts
│   │   │   │   └── ...
│   │   │   └── use-cases/         # Business use cases
│   │   │       ├── CreatePost.ts
│   │   │       ├── DeletePost.ts
│   │   │       └── ...
│   │   └── repositories/          # Repository interfaces (abstract)
│   │       ├── PostRepository.ts
│   │       └── ...
│   │
│   ├── infrastructure/            # Implementation of dependencies
│   │   ├── database/              # Database setup and entities
│   │   │   ├── connection.ts
│   │   │   └── postEntity.ts
│   │   └── repositories/          # Repository implementations
│   │       ├── PostRepositoryImpl.ts
│   │       └── ...
│   │
│   ├── application/               # Application layer logic
│   │   ├── controllers/           # API controllers
│   │   │   ├── PostController.ts
│   │   │   └── ...
│   │   ├── dtos/                  # Data Transfer Objects
│   │   │   ├── CreatePostDTO.ts
│   │   │   └── ...
│   │   └── services/              # Application-level services
│   │       ├── ValidationService.ts
│   │       └── ...
│   │
│   └── config/                    # Configuration files
│       ├── app.ts
│       ├── database.ts
│       └── ...
├── tests/                         # Unit and integration tests
├── .gitignore
├── package.json
├── tsconfig.json
└── README.md

Key Components of Hexagonal Architecture

  1. Core src/core:
  • Contains the business logic, entities, interfaces and rules of the application.
  • Does not depend on any other layer.
  • Example: Post.ts and PostRepository.ts
  1. Infrastructure src/infrastructure:
  • Contains the application setup and configuration (e.g. Express app initialisation, environment variables, server setup).
  • Example: app.ts and server.ts
  1. Application Layer src/application:
  • Coordinates the communication between the core (domain) business logic and the infrastructure layer.
  • Contains controllers, dtos, and services.
  • Example: postController.ts, createPostDTO.ts, and validationService.ts
  1. Config src/config:
  • Contains the application configuration (e.g. environment variables, database).
  • Example: app.ts and database.ts

Coding Task Analysis

We are tasked with building:

  • a backend API that interacts with a third party API to fetch a graph representation of a user's social network
  • a frontend React application that calls our backend API

What do we know?

  • We are not expected to provide a real implementation of the third party API.
  • The company favour clean architecture, good design practices and separation of concerns.
  • They are looking for a solution where the candidate shows skills around application design, clean code and TDD.
  • They do not expect us to have all fully working, just some test suites that we can run using your preferred build tool.

User Stories

  • As a user, I want to query how many people are not connected to anyone for the given social network so I know who to propose new connections to.
Given a social network name Facebook
And a full Facebook graph
Return count of people with no connections
  • As a user, I want to query how many people are connected to a given person by 1 or 2 degrees of separation for all social networks (facebook and twitter) so I understand her/his social influence.
Given a person name Peter
And a Facebook graph for Peter
And a Twitter graph for Peter
Return count of connections of 1 degree + count of connections of 2 degree

User Stories to Use Cases

  • CountPeopleWithNoConnections
  • CountConnectionsByDegrees

Entities

Given the example json response from the third party API:

{
  "name": "facebook",
  "people": [
    { "name": "John" },
    { "name": "Harry" },
    { "name": "Peter" },
    { "name": "George" },
    { "name": "Anna" }
  ],
  "relationships": [
    { "type": "HasConnection", "startNode": "John", "endNode": "Peter" },
    { "type": "HasConnection", "startNode": "John", "endNode": "George" },
    { "type": "HasConnection", "startNode": "Peter", "endNode": "George" },
    { "type": "HasConnection", "startNode": "Peter", "endNode": "Anna" }
  ]
}

We can keep things simple and define the following interfaces to get started:

Interfaces

  • SocialNetworkGraph

Directory Structure

social-network-presence/
│
├── backend/
│   ├── src/
│   │   ├── core/
│   │   │   ├── domain/                             # Business logic
│   │   │   │   ├── interfaces/
│   │   │   │   │   └── SocialNetworkGraph.ts
│   │   │   │   └── use-cases/
│   │   │   │       ├── CountPeopleWithNoConnections.ts
│   │   │   │       └── CountConnectionsByDegrees.ts
│   │   │   └── infrastructure/                     # External dependencies
│   │   │   │   └── services/                       # Third-party API interactions
│   │   │   │       └── SocialNetworkGraphServiceImpl.ts
│   │   └──server.ts
│   ├── tests/                                      # Test cases
│   │   ├── core/
│   │   │   └── use-cases/
│   │   │       ├── CountPeopleWithNoConnections.spec.ts
│   │   │       └── CountConnectionsByDegrees.spec.ts
│   │   └── infrastructure/
│   │       └── services/
│   │           └── SocialNetworkGraphService.spec.ts
│   └── package.json
├── frontend/                         # React frontend
│   ├── src/
│   └── package.json
├── package.json
└── README.md

Step-by-Step Guide with Red/Green Refactoring

Red/Green Refactoring

Red/Green Refactoring is a disciplined test-driven development (TDD) approach that involves three distinct phases:

  • Red Phase: Write a failing test that defines the expected behavior of a feature or function.
  • Green Phase: Write the simplest code possible to make the test pass.
  • Refactor Phase: Improve the code while ensuring the test continues to pass.

This approach ensures that your code is both functional and maintainable, with tests driving the design and implementation. In this article, we’ll use red/green refactoring to incrementally build a Blog backend application, starting with minimal functionality and expanding step by step.

Step 1: Setting Up the Project

Before diving into code, let's set up the bare essentials:

  1. Create a new directory for the project, navigate into it and initialise a node package.json file:
mkdir social-network-presence
cd social-network-presence
npm init -y
  1. Setup a repository on GitHub and push the initial codebase to the repository:
echo "# social-network-presence" >> README.md
git init
git add README.md
git commit -m "first commit"
git branch -M main
git remote add origin git@github.com:jonsully1/social-network-presence.git
git push -u origin main
  1. Create a basic directory structure for the project and move to the backend directory:
mkdir backend frontend
cd backend
  1. Install TypeScript and testing tools:
npm install typescript ts-node jest @types/jest ts-jest --save-dev

  1. Open the codebase in your preferred IDE and update the package.json scripts in the backend directory to run jest:
...
"scripts": {
  "test": "jest"
}
...

  1. Configure TypeScript by creating a tsconfig.json file:
{
  "compilerOptions": {
    "target": "ES6",
    "module": "commonjs",
    "strict": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules"]
}

  1. Configure Jest by creating a jest.config.json file:
{
  "preset": "ts-jest",
  "testEnvironment": "node",
  "transform": {
    "^.+\\.tsx?$": "ts-jest"
  },
  "transformIgnorePatterns": ["node_modules/(?!YOUR_PACKAGE_NAME)"]
}

  1. Create a basic project structure - just the essentials for now, we will expand this in the following steps:
social-network-presence/
├── backend/
│   ├── node_modules/
│   ├── src/
│   │   ├── core/
│   │   │   ├── domain/
│   │   │   │   ├── entities/
│   │   │   │   └── use-cases/
│   ├── tests/
├── jest.config.json
├── package.json
├── tsconfig.json
└── README.md
  1. Create a .gitignore file to exclude node_modules directory from being committed to the repository:

Code: .gitignore

node_modules/
  1. Create an example test to confirm our setup is working:

Test: src/tests/exampleTest.spec.ts

describe('Example Test', () => {
  it('should return true when the value is true', () => {
    const value = true;
    expect(value).toBe(true);
  });
});
  1. Configure code coverage and thresholds to ensure we are meeting some minimum coverage goals.

Code: ./backend/jest.config.json

{
  "preset": "ts-jest",
  "testEnvironment": "node",
  "transform": {
    "^.+\\.tsx?$": "ts-jest"
  },
  "transformIgnorePatterns": ["node_modules/(?!YOUR_PACKAGE_NAME)"],
  "collectCoverage": true, // added
  "coverageDirectory": "coverage", // added
  "collectCoverageFrom": ["src/**/*.ts"], // added
  "coverageThreshold": {
    // added
    "global": {
      "branches": 80,
      "functions": 90,
      "lines": 95,
      "statements": 95
    }
  }
}
  1. Add --watch flag to the test script in the package.json file to enable watch mode.

Code: ./backend/package.json

...
"scripts": {
  "test": "jest --watch"
}
...

We're all set up:

 PASS  tests/exampleTest.spec.ts
  Example Test
    ✓ should return true when the value is true (3 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |       0 |        0 |       0 |       0 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.355 s, estimated 1 s
Ran all test suites related to changed files.

Watch Usage
 › Press a to run all tests.
 › Press f to run only failed tests.
 › Press p to filter by a filename regex pattern.
 › Press t to filter by a test name regex pattern.
 › Press q to quit watch mode.
 › Press Enter to trigger a test run.

Step 2: Red Phase - Write a Failing Test for the CountPeopleWithNoConnections Use Case

The first thing we’ll do is create a failing test for the CountPeopleWithNoConnections use case.

Test: src/tests/core/use-cases/CountPeopleWithNoConnections.spec.ts

import { SocialNetworkGraph } from '../../../../src/core/domain/interfaces/SocialNetworkGraph';
import { CountPeopleWithNoConnections } from '../../../../src/core/domain/use-cases/CountPeopleWithNoConnections';
import { SocialNetworkGraphService } from '../../../../src/core/domain/services/SocialNetworkGraphService';

// Mock data indicates one person with no connections
const mockGraph: SocialNetworkGraph = {
  name: 'facebook',
  people: [
    { name: 'John' },
    { name: 'Harry' },
    { name: 'Peter' },
    { name: 'George' },
    { name: 'Anna' },
  ],
  relationships: [
    { type: 'HasConnection', startNode: 'John', endNode: 'Peter' },
    { type: 'HasConnection', startNode: 'John', endNode: 'George' },
    { type: 'HasConnection', startNode: 'Peter', endNode: 'George' },
    { type: 'HasConnection', startNode: 'Peter', endNode: 'Anna' },
  ],
};

class MockSocialNetworkGraphService implements SocialNetworkGraphService {
  // implement method and props
}

describe('CountPeopleWithNoConnections', () => {
  const mockSocialNetworkGraphService = new MockSocialNetworkGraphService();
  const countPeopleWithNoConnections = new CountPeopleWithNoConnections(
    mockSocialNetworkGraphService,
  );

  it('should return 0 when there are no people with no connections', () => {
    const count = countPeopleWithNoConnections.execute(mockGraph);

    expect(count).toBe(1);
  });
});

Notice that the path to our test file is nested to reflect the structural location of the file under test in our clean architecture. This is good practice, it makes the codebase easier to understand and navigate.

Run the test:

npm run test

And as expected, the test fails as we have not yet created the SocialNetworkGraph interface, SocialNetworkGraphService interface, or the CountPeopleWithNoConnections use case:

 FAIL  tests/core/domain/use-cases/CountPeopleWithNoConnections.spec.ts
  ● Test suite failed to run

    tests/core/domain/use-cases/CountPeopleWithNoConnections.spec.ts:1:36 - error TS2307: Cannot find module '../../../../src/core/domain/interfaces/SocialNetworkGraph' or its corresponding type declarations.

    1 import { SocialNetworkGraph } from "../../../../src/core/domain/interfaces/SocialNetworkGraph";
                                         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    tests/core/domain/use-cases/CountPeopleWithNoConnections.spec.ts:2:46 - error TS2307: Cannot find module '../../../../src/core/domain/use-cases/CountPeopleWithNoConnections' or its corresponding type declarations.

    2 import { CountPeopleWithNoConnections } from "../../../../src/core/domain/use-cases/CountPeopleWithNoConnections";
                                                   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    tests/core/domain/use-cases/CountPeopleWithNoConnections.spec.ts:3:43 - error TS2307: Cannot find module '../../../../src/core/domain/services/SocialNetworkGraphService' or its corresponding type declarations.

    3 import { SocialNetworkGraphService } from "../../../../src/core/domain/services/SocialNetworkGraphService";
                                                ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |       0 |        0 |       0 |       0 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        1.288 s
Ran all test suites.

Step 3: Green Phase - Make the CountPeopleWithNoConnections Use Case Pass

Let's create the missing interfaces and mock implementation details to make the test pass.

SocialNetworkGraph Interface

Code: src/core/domain/interfaces/SocialNetworkGraph.ts

interface Person {
  name: string;
}

interface Relationship {
  type: string;
  startNode: string;
  endNode: string;
}

export interface SocialNetworkGraph {
  name: string;
  people: Person[];
  relationships: Relationship[];
}

SocialNetworkGraphService Interface

Code: src/core/domain/services/SocialNetworkGraphService.ts

import { SocialNetworkGraph } from '../interfaces/SocialNetworkGraph';

export interface SocialNetworkGraphService {
  countPeopleWithNoConnections(graph: SocialNetworkGraph): number;
}

CountPeopleWithNoConnections Use Case

import { SocialNetworkGraph } from '../interfaces/SocialNetworkGraph';
import { SocialNetworkGraphService } from '../services/SocialNetworkGraphService';

export class CountPeopleWithNoConnections {
  #socialNetworkGraphService: SocialNetworkGraphService;

  constructor(socialNetworkGraphService: SocialNetworkGraphService) {
    this.#socialNetworkGraphService = socialNetworkGraphService;
  }

  execute(graph: SocialNetworkGraph): number {
    return this.#socialNetworkGraphService.countPeopleWithNoConnections(graph);
  }
}

Update the CountPeopleWithNoConnections test

import { SocialNetworkGraph } from '../../../../src/core/domain/interfaces/SocialNetworkGraph';
import { SocialNetworkGraphService } from '../../../../src/core/domain/services/SocialNetworkGraphService';
import { CountPeopleWithNoConnections } from '../../../../src/core/domain/use-cases/CountPeopleWithNoConnections';

const mockGraph: SocialNetworkGraph = {
  name: 'facebook',
  people: [
    { name: 'John' },
    { name: 'Harry' },
    { name: 'Peter' },
    { name: 'George' },
    { name: 'Anna' },
  ],
  relationships: [
    { type: 'HasConnection', startNode: 'John', endNode: 'Peter' },
    { type: 'HasConnection', startNode: 'John', endNode: 'George' },
    { type: 'HasConnection', startNode: 'Peter', endNode: 'George' },
    { type: 'HasConnection', startNode: 'Peter', endNode: 'Anna' },
  ],
};

class MockSocialNetworkGraphService implements SocialNetworkGraphService {
  countPeopleWithNoConnections(graph: SocialNetworkGraph): number {
    const connectedPeople = new Set<string>();

    for (const relationship of graph.relationships) {
      const { type, startNode, endNode } = relationship;
      if (type === 'HasConnection') {
        connectedPeople.add(startNode);
        connectedPeople.add(endNode);
      }
    }

    return graph.people.filter((person) => !connectedPeople.has(person.name)).length;
  }
}

describe('CountPeopleWithNoConnections', () => {
  const mockSocialNetworkGraphService = new MockSocialNetworkGraphService();
  const countPeopleWithNoConnections = new CountPeopleWithNoConnections(
    mockSocialNetworkGraphService,
  );

  it('should return 0 when there are no people with no connections', () => {
    const count = countPeopleWithNoConnections.execute(mockGraph);

    expect(count).toBe(1);
  });
});

Run the test again:

npm run test

And we have a passing test:

 PASS  tests/core/domain/use-cases/CountPeopleWithNoConnections.spec.ts
  CountPeopleWithNoConnections
    ✓ should return 0 when there are no people with no connections (3 ms)

--------------------------------|---------|----------|---------|---------|-------------------
File                            | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------------------|---------|----------|---------|---------|-------------------
All files                       |     100 |      100 |     100 |     100 |
 ...tPeopleWithNoConnections.ts |     100 |      100 |     100 |     100 |
--------------------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.364 s
Ran all test suites related to changed files.

Step 4: Refactor Phase - Improve Code Quality

First lets refactor our test file.

We can:

  • use beforeEach to create the mockSocialNetworkGraphService and countPeopleWithNoConnections instances once before each test as we might want to reuse them in multiple tests.
  • fix the typo in the test name, we accidentally named our test, "should return 0 when there are no people with no connections", when we were actually asserting that the count of people with no connections is 1. Therefore we should rename the test to "should return 1 when there is one person with no connections".
  • write a second test to check that the count of people with no connections is 0 when there are no people with no connections.
  • move our mock data into a mocks directory to clean up the test file and allow for reuse in other tests.
  • move our MockSocialNetworkGraphService class into a mocks directory as we'll reuse it in other tests.

CountPeopleWithNoConnections Use Case Tests

Our test file is now clean, readable, and easy to understand even with multiple tests.

Path: src/tests/core/domain/use-cases/CountPeopleWithNoConnections.spec.ts

import { CountPeopleWithNoConnections } from '../../../../src/core/domain/use-cases/CountPeopleWithNoConnections';
import { MockSocialNetworkGraphService } from '../mocks/MockSocialNetworkGraphService';
import {
  mockGraphAllPeopleConnected,
  mockGraphOnePersonWithNoConnections,
} from '../mocks/graphJsonResponse';

describe('CountPeopleWithNoConnections', () => {
  let mockSocialNetworkGraphService: MockSocialNetworkGraphService;
  let countPeopleWithNoConnections: CountPeopleWithNoConnections;

  beforeEach(() => {
    mockSocialNetworkGraphService = new MockSocialNetworkGraphService();
    countPeopleWithNoConnections = new CountPeopleWithNoConnections(mockSocialNetworkGraphService);
  });

  it('should return 1 when there is one person with no connections', () => {
    const count = countPeopleWithNoConnections.execute(mockGraphOnePersonWithNoConnections);

    expect(count).toBe(1);
  });

  it('should return 0 when there are no people with no connections', () => {
    const count = countPeopleWithNoConnections.execute(mockGraphAllPeopleConnected);

    expect(count).toBe(0);
  });
});

Mock Data & Service

Our mock graph response data and MockSocialNetworkGraphService implementation are now in a dedicated mocks directory for reuse in other tests.

Path: src/tests/core/domain/mocks/graphResponseData.ts

import { SocialNetworkGraph } from '../../../../src/core/domain/interfaces/SocialNetworkGraph';

export const mockGraphOnePersonWithNoConnections: SocialNetworkGraph = {
  name: 'facebook',
  people: [
    { name: 'John' },
    { name: 'Harry' }, // Has no connections
    { name: 'Peter' },
    { name: 'George' },
    { name: 'Anna' },
  ],
  relationships: [
    { type: 'HasConnection', startNode: 'John', endNode: 'Peter' },
    { type: 'HasConnection', startNode: 'John', endNode: 'George' },
    { type: 'HasConnection', startNode: 'Peter', endNode: 'George' },
    { type: 'HasConnection', startNode: 'Peter', endNode: 'Anna' },
  ],
};

export const mockGraphAllPeopleConnected: SocialNetworkGraph = {
  name: 'facebook',
  people: [
    { name: 'John' },
    { name: 'Harry' },
    { name: 'Peter' },
    { name: 'George' },
    { name: 'Anna' },
  ],
  relationships: [
    { type: 'HasConnection', startNode: 'John', endNode: 'Peter' },
    { type: 'HasConnection', startNode: 'John', endNode: 'George' },
    { type: 'HasConnection', startNode: 'Peter', endNode: 'George' },
    { type: 'HasConnection', startNode: 'Peter', endNode: 'Anna' },
    { type: 'HasConnection', startNode: 'Harry', endNode: 'Anna' },
  ],
};

Code: src/tests/core/domain/mocks/MockSocialNetworkGraphService.ts

import { SocialNetworkGraph } from '../../../../src/core/domain/interfaces/SocialNetworkGraph';
import { SocialNetworkGraphService } from '../../../../src/core/domain/services/SocialNetworkGraphService';

export class MockSocialNetworkGraphService implements SocialNetworkGraphService {
  countPeopleWithNoConnections(graph: SocialNetworkGraph): number {
    const connectedPeople = new Set<string>();

    for (const relationship of graph.relationships) {
      const { type, startNode, endNode } = relationship;
      if (type === 'HasConnection') {
        connectedPeople.add(startNode);
        connectedPeople.add(endNode);
      }
    }

    return graph.people.filter((person) => !connectedPeople.has(person.name)).length;
  }
}

Directory Structure

We have just noticed we have an uneccessary nested directory structure in core/domain. core and domain represent the same layer in clean architecture, so we can simply use one, let's go with domain and remove core from our directory structure in both the src and tests directories.

❯ tree --gitignore --dirsfirst
.
├── backend
│   ├── src
│   │   └── domain
│   │       ├── entities
│   │       ├── interfaces
│   │       │   └── SocialNetworkGraph.ts
│   │       ├── services
│   │       │   └── SocialNetworkGraphService.ts
│   │       └── use-cases
│   │           └── CountPeopleWithNoConnections.ts
│   ├── tests
│   │   └── domain
│   │       ├── mocks
│   │       │   ├── MockSocialNetworkGraphService.ts
│   │       │   └── graphResponseData.ts
│   │       └── use-cases
│   │           └── CountPeopleWithNoConnections.spec.ts
│   ├── jest.config.json
│   ├── package-lock.json
│   ├── package.json
│   └── tsconfig.json
├── frontend
├── README.md
└── package.json

See https://formulae.brew.sh/formula/tree for more information on the tree command.

Step 5: Red Phase - Write a failing test for the CountDegreesOfSeperation Use Case

Let's get our 2nd test case in place by writing a failing test for the CountConnectionsByDegrees use case, let's just make the use case name more explicit: CountDegreesOfSeperation. This is much more descriptive and makes it clear what the test aims to achieve.

We know from the code challenge that Given a person name Peter, we need to Return the count of connections of 1 degree + the count of connections of 2 degrees of separation.

1 degree 2 degrees
John 2 1
Peter 3 0
George 2 1
Harry 0 0
Anna 1 2

We also need to take into account both a facebook and twitter social network graph, but let's start with a single graph for simplicity, the facebook graph.

As our previous CountPeopleWithNoConnections test was refactored, we can follow the same pattern to create our new test.

Code: src/tests/core/domain/use-cases/CountDegreesOfSeperation.spec.ts

import { CountDegreesOfSeperation } from '../../../src/domain/use-cases/CountDegreesOfSeperation';
import { MockSocialNetworkGraphService } from '../mocks/MockSocialNetworkGraphService';
import { mockGraphAllPeopleConnected } from '../mocks/graphResponseData';

describe('CountDegreesOfSeperation', () => {
  let mockSocialNetworkGraphService: MockSocialNetworkGraphService;
  let countDegreesOfSeperation: CountDegreesOfSeperation;

  beforeEach(() => {
    mockSocialNetworkGraphService = new MockSocialNetworkGraphService();
    countDegreesOfSeperation = new CountDegreesOfSeperation(mockSocialNetworkGraphService);
  });

  it('should return 1 when there is one person with no connections', () => {
    const count = countDegreesOfSeperation.execute(mockGraphAllPeopleConnected);

    const degreesOfSeparationCount = { 1: 1, 2: 2 };
    expect(count).toBe(expected);
  });
});

Naturally, the test will fail as we have not yet implemented the CountDegreesOfSeperation use case.

We also get a typescript error in the file - Property 'countDegreesOfSeperation' does not exist on type 'SocialNetworkGraphService'.ts(2339) - and of course our test will fail as the countDegreesOfSeperation method is not yet implemented on the MockSocialNetworkGraphService class.

 FAIL  tests/domain/use-cases/CountConnectionsByDegressOfSeperation.spec.ts
  ● Test suite failed to run

    src/domain/use-cases/CountConnectionsByDegressOfSeperation.ts:12:44 - error TS2339: Property 'countDegreesOfSeperation' does not exist on type 'SocialNetworkGraphService'.

    12     return this.#socialNetworkGraphService.countDegreesOfSeperation(graph);
                                                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

 PASS  tests/domain/use-cases/CountPeopleWithNoConnections.spec.ts
Running coverage on untested files...Failed to collect coverage from /Users/johnosullivan/dev/johno/success-response/social-network-presence/backend/src/domain/use-cases/CountConnectionsByDegressOfSeperation.ts
ERROR: src/domain/use-cases/CountConnectionsByDegressOfSeperation.ts:12:44 - error TS2339: Property 'countDegreesOfSeperation' does not exist on type 'SocialNetworkGraphService'.

12     return this.#socialNetworkGraphService.countDegreesOfSeperation(graph);
                                              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
STACK:
----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |       0 |        0 |       0 |       0 |
----------|---------|----------|---------|---------|-------------------

Test Suites: 1 failed, 1 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.618 s
Ran all test suites.

Step 6: Green Phase - Implement the CountDegreesOfSeperation Use Case

Let's add the CountDegreesOfSeperation use case and the countDegreesOfSeperation method to the MockSocialNetworkGraphService class:

Code: src/domain/use-cases/CountDegreesOfSeperation.ts

import { SocialNetworkGraph } from '../interfaces/SocialNetworkGraph';
import { DegreesOfSeparationCount } from '../interfaces/DegreesOfSeparationCount';
import { SocialNetworkGraphService } from '../services/SocialNetworkGraphService';

export class CountDegreesOfSeparation {
  #socialNetworkGraphService: SocialNetworkGraphService;

  constructor(socialNetworkGraphService: SocialNetworkGraphService) {
    this.#socialNetworkGraphService = socialNetworkGraphService;
  }

  execute(person: string, socialNetworkGraph: SocialNetworkGraph): DegreesOfSeparationCount {
    return this.#socialNetworkGraphService.countDegreesOfSeparation(person, socialNetworkGraph);
  }
}

Code: src/tests/domain/mocks/MockSocialNetworkGraphService.ts

We hardcode the expected values in the test, but we need to implement the logic to calculate the degrees of separation in the countDegreesOfSeparation method.

import { SocialNetworkGraph } from '../../../src/domain/interfaces/SocialNetworkGraph';
import { SocialNetworkGraphService } from '../../../src/domain/services/SocialNetworkGraphService';

export class MockSocialNetworkGraphService implements SocialNetworkGraphService {
  countPeopleWithNoConnections(graph: SocialNetworkGraph): number {
    const connectedPeople = new Set<string>();

    for (const relationship of graph.relationships) {
      const { type, startNode, endNode } = relationship;
      if (type === 'HasConnection') {
        connectedPeople.add(startNode);
        connectedPeople.add(endNode);
      }
    }

    return graph.people.filter((person) => !connectedPeople.has(person.name)).length;
  }

  countDegreesOfSeparation(graph: SocialNetworkGraph): DegreesOfSeparationCount {
    return { 1: 1, 2: 2 };
  }
}

Code: src/domain/services/SocialNetworkGraphService.ts

import { SocialNetworkGraph } from '../interfaces/SocialNetworkGraph';
import { DegreesOfSeparationCount } from '../interfaces/DegreesOfSeparationCount';

export interface SocialNetworkGraphService {
  countPeopleWithNoConnections(graph: SocialNetworkGraph): number;

  countDegreesOfSeparation(graph: SocialNetworkGraph): DegreesOfSeparationCount;
}

Code: src/domain/interfaces/DegreesOfSeparationCount.ts

The Degree type is a union of the possible values for the degrees of separation, i.e. 0 (starting point), 1, and 2.

type Degree = 0 | 1 | 2;

export interface DegreesOfSeparationCount {
  1: Degree;
  2: Degree;
}

We expect the test to pass now, it doesn't but only because we are using toBe instead of toStrictEqual, as we're comparing objects, not primitive values.

 PASS  tests/domain/use-cases/CountPeopleWithNoConnections.spec.ts
 FAIL  tests/domain/use-cases/CountConnectionsByDegressOfSeperation.spec.ts
  ● CountDegreesOfSeperation › should return 1 when there is one person with no connections

    expect(received).toBe(expected) // Object.is equality

    If it should pass with deep equality, replace "toBe" with "toStrictEqual"

    Expected: { 1: 1, 2: 2 }
    Received: serializes to the same string

      19 |
      20 |     const expected = { 1: 1, 2: 2 };
    > 21 |     expect(count).toBe(expected);
         |                   ^
      22 |   });
      23 | });
      24 |

      at Object.<anonymous> (tests/domain/use-cases/CountConnectionsByDegressOfSeperation.spec.ts:21:19)

We fix it and the tests pass but we're currently returning a hardcoded value - { 1: 1, 2: 2 } in the MockSocialNetworkGraphService class to fullfil the test. We need to implement the logic to calculate the number of connections by degrees of separation.

So let's just initialise and return a count object that we'll increment during the implementation of the method in the Green Phase.

Code: src/tests/domain/mocks/MockSocialNetworkGraphService.ts

...
export class MockSocialNetworkGraphService
  implements SocialNetworkGraphService
{
  ...

  countDegreesOfSeparation(graph: SocialNetworkGraph): DegreesOfSeparationCount {
    const count = { 1: 0, 2: 0 };

    return count;
  }
}

Impementing the countDegreesOfSeparation Method

The degree of separation measures how far one person is from another in terms of connections:

  • 1 degree of separation: Direct connections (e.g., John is directly connected to Peter).
  • 2 degrees of separation: Indirect connections through one intermediary (e.g., John → Peter → Anna).

Degrees of separation can help measure someone's social influence by analysing how many people they can reach directly or indirectly.

The response that we receive from the third party API is a representation of the social network graph:

{
  "sn": "facebook",
  "people": [
    { "name": "John" },
    { "name": "Harry" },
    { "name": "Peter" },
    { "name": "George" },
    { "name": "Anna" }
  ],
  "relationships": [
    { "type": "HasConnection", "startNode": "John", "endNode": "Peter" },
    { "type": "HasConnection", "startNode": "John", "endNode": "George" },
    { "type": "HasConnection", "startNode": "Peter", "endNode": "George" },
    { "type": "HasConnection", "startNode": "Peter", "endNode": "Anna" }
  ]
}

Graphs are represented as a collection of nodes (vertices) and edges (connections between nodes).

In the reponse above, the people array represents the nodes, and the relationships array represents the edges.

Graphs can be represented in a number of ways, but for our purposes, we will represent the graph as an adjacency list, i.e. an object where each key is a node and the value is an array of connected nodes.

const graph = {
  John: ['Peter', 'George'],
  Peter: ['George', 'Anna'],
  // ...
};

We will create the above graph object in order to efficiently carry out our calculation to count the degrees of separation.

Let's begin adding the logic to the countDegreesOfSeparation method in the MockSocialNetworkGraphService class.

Something that we have missed though is the person argument to the countDegreesOfSeparation method. Referring back to the first line of user story, Given a person name Peter, we need to return the count of connections of 1 degree + the count of connections of 2 degrees of separation for a specified person.

Let's add that in as we build out the logic in the countDegreesOfSeparation method of the MockSocialNetworkGraphService class.

Code: src/tests/domain/mocks/MockSocialNetworkGraphService.ts

First we will build the adjacency list from the graph representation response.

export class MockSocialNetworkGraphService
  implements SocialNetworkGraphService
{
  ...

  countDegreesOfSeparation(
    person: string,
    socialNetworkGraph: SocialNetworkGraph,
  ): DegreesOfSeparationCount {
    const count = { 1: 0, 2: 0 };
    const { people, relationships } = socialNetworkGraph;
    const graph: Graph = {};

    // Initialize the graph with empty arrays for each person
    for (const person of people) {
      graph[person.name] = [];
    }

    // Populate the graph with connections
    for (const relationship of relationships) {
      const { type, startNode, endNode } = relationship;
      if (type === "HasConnection") {
        graph[startNode].push(endNode);
        graph[endNode].push(startNode);
      }
    }

    console.dir({ graph }, { depth: null });

    return count;
  }
}

We can see from the console output of our failing test that the graph has been created successfully:

 PASS  tests/domain/use-cases/CountPeopleWithNoConnections.spec.ts
 FAIL  tests/domain/use-cases/CountDegressOfSeperation.spec.ts
  ● Console

    console.dir
      {
        graph: {
          John: [ 'Peter', 'George' ],
          Harry: [],
          Peter: [ 'John', 'George', 'Anna' ],
          George: [ 'John', 'Peter' ],
          Anna: [ 'Peter' ]
        }
      }

      at MockSocialNetworkGraphService.countDegreesOfSeparation (tests/domain/mocks/MockSocialNetworkGraphService.ts:47:13)

  ● CountConnectionsByDegreesOfSeparation › should return 2 connections at 1 degree and 1 connection at 2 degrees for John

    expect(received).toStrictEqual(expected) // deep equality

    ...

Next we need to implement the logic to calculate the degrees of separation for the specified person.

We essentially need to create a FIFO queue to perform a breadth-first search (BFS) of the graph to count the degrees of separation for the specified person.

BFS is the most appropriate algorithm for this task as it explores all nodes at the present depth prior to moving on to the nodes at the next depth level.

Other algorithms such as depth-first search (DFS) are not suitable as they explore nodes in a depth-first manner, potentially skipping nodes at lower depths.

Code: src/tests/domain/mocks/MockSocialNetworkGraphService.ts

export class MockSocialNetworkGraphService
  implements SocialNetworkGraphService
{
  ...

  countDegreesOfSeparation(
    person: string,
    socialNetworkGraph: SocialNetworkGraph,
  ): DegreesOfSeparationCount {
    const count: DegreesOfSeparationCount = { 1: 0, 2: 0 };
    const { people, relationships } = socialNetworkGraph;
    const graph: Graph = {};

    // Initialize the graph with empty arrays for each person
    for (const person of people) {
      graph[person.name] = [];
    }

    // Populate the graph with connections
    for (const relationship of relationships) {
      const { type, startNode, endNode } = relationship;
      if (type === "HasConnection") {
        graph[startNode].push(endNode);
        graph[endNode].push(startNode);
      }
    }

    // FIFO queue (Breadth-First Search)
    const visited = new Set();
    const queue: (string | number)[][] = [[person, 0]];

    while (queue.length) {
      const [current, degree] = queue.shift() as [string, number]; // 1

      if (visited.has(current)) {
        continue; // 2
      }

      visited.add(current); // 3

      if (degree > 0 && degree <= 2) {
        count[degree as 1 | 2]++; // 4
      }

      if (degree < 2) {
        (graph[current] || []).forEach((neighbor) => { // 5
          if (!visited.has(neighbor)) {
            queue.push([neighbor, degree + 1]);
          }
        });
      }
    }

    return count;
  }
}

How the FIFO Queue Works

We already had the count object initialised to store the count of connections by degrees of separation.

Now we have created:

  • A visited JavScript Set to store the nodes (people) that have already been visited.
    • Used to prevent us from visiting the same node multiple times.
  • A queue array to store the nodes (people) that are yet to be visited - it's like a todo list.
    • initialised with the starting node (person argument) and a degree of 0
    • degree is 0 because the person is the root node, all other connections are 1 degree of separation away, i.e. Peter's immediate connections would be considered 1 degree of separation away.
  • A while loop to iterate over the queue until it is empty.

Explanation of the while Loop:

As we interate over the queue we carry out the following actions:

  1. Dequeue the first element from the queue.
  2. If the node has already been visited, we skip this iteration of the while loop.
  3. If the node has not been visited, we add it to the visited Set object as we are now visiting it.
  4. If the degree value (degree of separation) is greater than 0 and less than or equal to 2, we increment the count for that degree of separation.
  5. If the degree value is less than 2, we get the neighbours of the current node
  • forEach neighbour, if it has not been visited, we enqueue it with an incremented degree of separation.

Example Walkthrough:

Let’s assume the graph looks like this:

{
  "John": ["Peter", "George"],
  "Peter": ["John", "Anna"],
  "George": ["John"],
  "Anna": ["Peter"]
}

Starting with John as the person argument:

Initial State

  • Queue: [["John", 0]]
  • Visited: {}

Nothing to enqueue as we're starting with the root node.

Iteration 1: Process "John"

  • Queue: []
    • "John" is dequeued
  • Current: 0
    • because "John" is the root node, and we're starting with the root node
  • Neighbours: ["Peter", "George"]
  • Visited: {"John"}
  • Count: {1: 0, 2: 0}

Enqueue John's neighbours:

  • Queue: [["Peter", 1], ["George", 1]]
    • Neighbours are enqueued with a degree of 1 as they are 1 degree of separation away from John

Iteration 2: Process "Peter"

  • Queue: [["George", 1]]
    • "Peter" is dequeued for processing, leaving "George" as next node to be processed by the while loop
  • Current: 1
    • because "Peter" is 1 degree of separation away from John, as set in the previous step
  • Neighbours: ["John", "Anna"]
  • Visited: {"John", "Peter"}
  • Count: {1: 1, 2: 0}
    • increment 1-degree count for "Peter"

Enqueue Peter's neighbours:

  • Queue: [["George", 1], ["Anna", 2]]
    • "John" was already visited, so he is not enqueued

Iteration 3: Process "George"

  • Queue: [["Anna", 2]]
    • "George" is dequeued for processing
    • Current: 1
      • because "George" is 1 degree of separation away from John, as set in the previous steps
  • Neighbours: ["John"]
  • Visited: {"John", "Peter", "George"}
  • Count: {1: 2, 2: 0}
    • increment 1-degree count for "George"

No new neighbours to enqueue as John has already been visited.

Iteration 4: Process "Anna"

  • Queue: []
    • "Anna" is dequeued for processing
  • Current: 2
    • because "Anna" is 2 degrees of separation away from John, as set in the previous steps
  • Neighbours: ["Peter"]
  • Visited: {"John", "Peter", "George", "Anna"}
  • Count: {1: 2, 2: 1}
    • increment 2-degree count for "Anna"

No new neighbours to enqueue as Peter has already been visited.

Final Results

  • 1-Degree Connections: "Peter", "George"
  • 2-Degree Connections: "Anna"
  • Counts: {1: 2, 2: 1}

And our test passes:

 PASS  tests/domain/use-cases/CountPeopleWithNoConnections.spec.ts
 PASS  tests/domain/use-cases/CountDegressOfSeperation.spec.ts
-----------------------------|---------|----------|---------|---------|-------------------
File                         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------------------|---------|----------|---------|---------|-------------------
All files                    |     100 |      100 |     100 |     100 |
 CountDegressOfSeperation.ts |     100 |      100 |     100 |     100 |
-----------------------------|---------|----------|---------|---------|-------------------

Test Suites: 2 passed, 2 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        1.854 s
Ran all test suites related to changed files.

Step 7: Refactor Phase - Improve Code Quality

First up let's add a few more tests for each of the people in the current mock response data.

Code: tests/domain/use-cases/CountDegressOfSeperation.spec.ts

We uncomment a test from the previous step and fix it but immediately find we have made an error in our DegressOfSeparationCount type:

 PASS  tests/domain/use-cases/CountPeopleWithNoConnections.spec.ts
 FAIL  tests/domain/use-cases/CountDegressOfSeperation.spec.ts
  ● Test suite failed to run

    tests/domain/use-cases/CountDegressOfSeperation.spec.ts:35:61 - error TS2322: Type '3' is not assignable to type 'Degree'.

    35     const degreesOfSeparation: DegreesOfSeparationCount = { 1: 3, 2: 0 };
                                                                   ~

      src/domain/interfaces/DegreesOfSeparationCount.ts:3:3
        3   1: Degree;
            ~
        The expected type comes from property '1' which is declared here on type 'DegreesOfSeparationCount'

The actual counts should be number not a union type so let's fix that.

Code: src/domain/interfaces/DegreesOfSeparationCount.ts

export interface DegreesOfSeparationCount {
  1: number;
  2: number;
}

It worked, all tests pass, we just need to add the remaining tests:

Code: tests/domain/use-cases/CountDegressOfSeperation.spec.ts

describe("CountConnectionsByDegreesOfSeparation", () => {
  ...

  it("should return 2 connections at 1 degree and 1 connection at 2 degrees for John", () => {
    const person = "John";
    const count = countDegreesOfSeparation.execute(
      person,
      mockGraphOnePersonWithNoConnections,
    );

    const degreesOfSeparation: DegreesOfSeparationCount = { 1: 2, 2: 1 };
    expect(count).toStrictEqual(degreesOfSeparation);
  });

  it("should return 3 connections at 1 degree and 0 connections at 2 degrees for Peter", () => {
    const person = "Peter";
    const count = countDegreesOfSeparation.execute(
      person,
      mockGraphOnePersonWithNoConnections,
    );

    const degreesOfSeparation: DegreesOfSeparationCount = { 1: 3, 2: 0 };
    expect(count).toStrictEqual(degreesOfSeparation);
  });

  it("should return 2 connections at 1 degree and 1 connections at 2 degrees for George", () => {
    const person = "George";
    const count = countDegreesOfSeparation.execute(
      person,
      mockGraphOnePersonWithNoConnections,
    );

    const degreesOfSeparation: DegreesOfSeparationCount = { 1: 2, 2: 1 };
    expect(count).toStrictEqual(degreesOfSeparation);
  });

  it("should return 0 connections at both 1 degree and 2 degrees for Harry", () => {
    const person = "Harry";
    const count = countDegreesOfSeparation.execute(
      person,
      mockGraphOnePersonWithNoConnections,
    );

    const degreesOfSeparation: DegreesOfSeparationCount = { 1: 0, 2: 0 };
    expect(count).toStrictEqual(degreesOfSeparation);
  });

  it("should return 1 connections at 1 degree and 2 connections at 2 degrees for Anna", () => {
    const person = "Anna";
    const count = countDegreesOfSeparation.execute(
      person,
      mockGraphOnePersonWithNoConnections,
    );

    const degreesOfSeparation: DegreesOfSeparationCount = { 1: 1, 2: 2 };
    expect(count).toStrictEqual(degreesOfSeparation);
  });

And all tests pass. Now we can refactor the code to improve its readability and maintainability.

Code: src/domain/mocks/MockSocialNetworkGraphService.ts

First we could move the creation of the graph object to a separate private method named buildGraph inside the class.

export class MockSocialNetworkGraphService
  implements SocialNetworkGraphService
{
  ...

  #buildGraph(socialNetworkGraph: SocialNetworkGraph): Graph {
    const { people, relationships } = socialNetworkGraph;
    const graph: Graph = new Map<string, string[]>();

    people.forEach((person) => {
      graph.set(person.name, []);
    });

    relationships.forEach(({ type, startNode, endNode }) => {
      if (type === "HasConnection") {
        graph.get(startNode)?.push(endNode);
        graph.get(endNode)?.push(startNode);
      }
    });

    return graph;
  }

  ...
}

The remaining code in the countDegreesOfSeparation method could also be extract to two more private methods, calculateDegreeOfSeparation and addNeighborsToQueue:

export class MockSocialNetworkGraphService
  implements SocialNetworkGraphService
{
  ...

  #addNeighborsToQueue(
    current: string,
    degree: number,
    graph: Graph,
    visited: Set<string>,
    queue: [string, number][],
  ) {
    graph.get(current)?.forEach((neighbor) => {
      if (!visited.has(neighbor)) {
        queue.push([neighbor, degree + 1]);
      }
    });
  }

  #calculateDegreesOfSeparation(
    person: string,
    graph: Graph,
  ): DegreesOfSeparationCount {
    const count: DegreesOfSeparationCount = { 1: 0, 2: 0 };
    const visited = new Set<string>();
    const queue: [string, number][] = [[person, 0]];

    while (queue.length > 0) {
      const [current, degree] = queue.shift()!;

      if (visited.has(current)) continue;

      visited.add(current);

      if (degree > 0 && degree <= 2) {
        count[degree as 1 | 2]++;
      }

      if (degree < 2) {
        this.#addNeighborsToQueue(current, degree, graph, visited, queue);
      }
    }

    return count;
  }

  countDegreesOfSeparation(
    person: string,
    socialNetworkGraph: SocialNetworkGraph,
  ): DegreesOfSeparationCount {
    const graph = this.#buildGraph(socialNetworkGraph);
    return this.#calculateDegreesOfSeparation(person, graph);
  }
}

We have made the code more readable and used a Map object to store the graph as handles large datasets efficiently with O(1) average time complexity for lookups and inserts.

Fixing the MockSocialNetworkGraphService over-implementation

The the recent steps we created a MockSocialNetworkGraphService class that implements the SocialNetworkGraphService interface and fully implemented the methods it required.

We didn't actually need to do that, we could (and probably should) have mocked the responses needed for the SocialNetworkGraphService interface to fulfill each use case but I was trying to speed up the process of getting the tests to pass whilst implementing the code required for the SocialNetworkGraphService.

We can adjust the test code very quickly so that is relies purely on mocking the expected response from the countDegreesOfSeparation method of the SocialNetworkGraphService interface.

Code: tests/domain/use-cases/CountDegressOfSeperation.spec.ts

import { jest } from '@jest/globals';
import { MockSocialNetworkGraphService } from '../mocks/MockSocialNetworkGraphService';
import { mockGraphOnePersonWithNoConnections } from '../mocks/graphResponseData';
import { CountDegreesOfSeparation } from '../../../src/domain/use-cases/CountDegressOfSeperation';
import { DegreesOfSeparationCount } from '../../../src/domain/interfaces/DegreesOfSeparationCount';

describe('CountConnectionsByDegreesOfSeparation', () => {
  let mockSocialNetworkGraphService: MockSocialNetworkGraphService;
  let countDegreesOfSeparation: CountDegreesOfSeparation;

  beforeEach(() => {
    jest.clearAllMocks();

    mockSocialNetworkGraphService = new MockSocialNetworkGraphService();
    countDegreesOfSeparation = new CountDegreesOfSeparation(mockSocialNetworkGraphService);
  });

  it('should return 2 connections at 1 degree and 1 connection at 2 degrees for John', () => {
    const person = 'John';
    const expectedCount: DegreesOfSeparationCount = { 1: 2, 2: 1 };

    jest.spyOn(mockSocialNetworkGraphService, 'countDegreesOfSeparation').mockImplementation(() => {
      return expectedCount;
    });

    const count = countDegreesOfSeparation.execute(person, mockGraphOnePersonWithNoConnections);

    expect(count).toStrictEqual(expectedCount);
  });

  it('should return 3 connections at 1 degree and 0 connections at 2 degrees for Peter', () => {
    const person = 'Peter';
    const expectedCount: DegreesOfSeparationCount = { 1: 3, 2: 0 };

    jest.spyOn(mockSocialNetworkGraphService, 'countDegreesOfSeparation').mockImplementation(() => {
      return expectedCount;
    });

    const count = countDegreesOfSeparation.execute(person, mockGraphOnePersonWithNoConnections);

    expect(count).toStrictEqual(expectedCount);
  });

  it('should return 2 connections at 1 degree and 1 connections at 2 degrees for George', () => {
    const person = 'George';
    const expectedCount: DegreesOfSeparationCount = { 1: 2, 2: 1 };

    jest.spyOn(mockSocialNetworkGraphService, 'countDegreesOfSeparation').mockImplementation(() => {
      return expectedCount;
    });

    const count = countDegreesOfSeparation.execute(person, mockGraphOnePersonWithNoConnections);

    expect(count).toStrictEqual(expectedCount);
  });

  it('should return 0 connections at both 1 degree and 2 degrees for Harry', () => {
    const person = 'Harry';
    const expectedCount: DegreesOfSeparationCount = { 1: 0, 2: 0 };

    jest.spyOn(mockSocialNetworkGraphService, 'countDegreesOfSeparation').mockImplementation(() => {
      return expectedCount;
    });

    const count = countDegreesOfSeparation.execute(person, mockGraphOnePersonWithNoConnections);

    expect(count).toStrictEqual(expectedCount);
  });

  it('should return 1 connections at 1 degree and 2 connections at 2 degrees for Anna', () => {
    const person = 'Anna';
    const expectedCount: DegreesOfSeparationCount = { 1: 1, 2: 2 };

    jest.spyOn(mockSocialNetworkGraphService, 'countDegreesOfSeparation').mockImplementation(() => {
      return expectedCount;
    });

    const count = countDegreesOfSeparation.execute(person, mockGraphOnePersonWithNoConnections);

    expect(count).toStrictEqual(expectedCount);
  });
});

We can then implement a test file for the SocialNetworkGraphServiceImpl.ts file:

Code: tests/domain/services/SocialNetworkGraphServiceImpl.spec.ts

import { DegreesOfSeparationCount } from '../../../src/domain/interfaces/DegreesOfSeparationCount';
import { SocialNetworkGraphServiceImpl } from '../../../src/infrastructure/services/SocialNetworkGraphServiceImpl';
import { mockGraphOnePersonWithNoConnections } from '../../domain/mocks/graphResponseData';

describe('SocialNetworkGraphService', () => {
  it('countPeopleWithNoConnections should return 1 for John', () => {
    const socialNetworkGraphService = new SocialNetworkGraphServiceImpl();

    const count = socialNetworkGraphService.countPeopleWithNoConnections(
      mockGraphOnePersonWithNoConnections,
    );

    expect(count).toBe(1);
  });

  it('countDegreesOfSeparation should 2 connections at 1 degree and 1 connection at 2 degrees for John', () => {
    const person = 'John';
    const expectedCount: DegreesOfSeparationCount = { 1: 2, 2: 1 };

    const socialNetworkGraphService = new SocialNetworkGraphServiceImpl();

    const count = socialNetworkGraphService.countDegreesOfSeparation(
      person,
      mockGraphOnePersonWithNoConnections,
    );

    expect(count).toStrictEqual(expectedCount);
  });

  it('countDegreesOfSeparation should 3 connections at 1 degree and 0 connections at 2 degrees for Peter', () => {
    const person = 'Peter';
    const expectedCount: DegreesOfSeparationCount = { 1: 3, 2: 0 };

    const socialNetworkGraphService = new SocialNetworkGraphServiceImpl();

    const count = socialNetworkGraphService.countDegreesOfSeparation(
      person,
      mockGraphOnePersonWithNoConnections,
    );

    expect(count).toStrictEqual(expectedCount);
  });

  it('countDegreesOfSeparation should 2 connections at 1 degree and 1 connections at 2 degrees for George', () => {
    const person = 'George';
    const expectedCount: DegreesOfSeparationCount = { 1: 2, 2: 1 };

    const socialNetworkGraphService = new SocialNetworkGraphServiceImpl();

    const count = socialNetworkGraphService.countDegreesOfSeparation(
      person,
      mockGraphOnePersonWithNoConnections,
    );

    expect(count).toStrictEqual(expectedCount);
  });

  it('countDegreesOfSeparation should 0 connections at both 1 degree and 2 degrees for Harry', () => {
    const person = 'Harry';
    const expectedCount: DegreesOfSeparationCount = { 1: 0, 2: 0 };

    const socialNetworkGraphService = new SocialNetworkGraphServiceImpl();

    const count = socialNetworkGraphService.countDegreesOfSeparation(
      person,
      mockGraphOnePersonWithNoConnections,
    );

    expect(count).toStrictEqual(expectedCount);
  });

  it('countDegreesOfSeparation should 1 connections at 1 degree and 2 connections at 2 degrees for Anna', () => {
    const person = 'Anna';
    const expectedCount: DegreesOfSeparationCount = { 1: 1, 2: 2 };

    const socialNetworkGraphService = new SocialNetworkGraphServiceImpl();

    const count = socialNetworkGraphService.countDegreesOfSeparation(
      person,
      mockGraphOnePersonWithNoConnections,
    );

    expect(count).toStrictEqual(expectedCount);
  });
});

And the actual implementation of the SocialNetworkGraphService.ts interface in the SocialNetworkGraphServiceImpl.ts file:

Code: src/infrastructure/services/SocialNetworkGraphServiceImpl.ts

import { SocialNetworkGraph } from '../../domain/interfaces/SocialNetworkGraph';
import { SocialNetworkGraphService } from '../../domain/services/SocialNetworkGraphService';
import { DegreesOfSeparationCount } from '../../domain/interfaces/DegreesOfSeparationCount';

type Graph = Map<string, string[]>;

export class SocialNetworkGraphServiceImpl implements SocialNetworkGraphService {
  public countPeopleWithNoConnections(graph: SocialNetworkGraph): number {
    const connectedPeople = new Set<string>();

    for (const relationship of graph.relationships) {
      const { type, startNode, endNode } = relationship;
      if (type === 'HasConnection') {
        connectedPeople.add(startNode);
        connectedPeople.add(endNode);
      }
    }

    return graph.people.filter((person) => !connectedPeople.has(person.name)).length;
  }

  #buildGraph(socialNetworkGraph: SocialNetworkGraph): Graph {
    const { people, relationships } = socialNetworkGraph;
    const graph: Graph = new Map<string, string[]>();

    people.forEach((person) => {
      graph.set(person.name, []);
    });

    relationships.forEach(({ type, startNode, endNode }) => {
      if (type === 'HasConnection') {
        graph.get(startNode)?.push(endNode);
        graph.get(endNode)?.push(startNode);
      }
    });

    return graph;
  }

  #addNeighborsToQueue(
    current: string,
    degree: number,
    graph: Graph,
    visited: Set<string>,
    queue: [string, number][],
  ) {
    graph.get(current)?.forEach((neighbor) => {
      if (!visited.has(neighbor)) {
        queue.push([neighbor, degree + 1]);
      }
    });
  }

  #calculateDegreesOfSeparation(person: string, graph: Graph): DegreesOfSeparationCount {
    const count: DegreesOfSeparationCount = { 1: 0, 2: 0 };
    const visited = new Set<string>();
    const queue: [string, number][] = [[person, 0]];

    while (queue.length > 0) {
      const [current, degree] = queue.shift()!;

      if (visited.has(current)) continue;

      visited.add(current);

      if (degree > 0 && degree <= 2) {
        count[degree as 1 | 2]++;
      }

      if (degree < 2) {
        this.#addNeighborsToQueue(current, degree, graph, visited, queue);
      }
    }

    return count;
  }

  countDegreesOfSeparation(
    person: string,
    socialNetworkGraph: SocialNetworkGraph,
  ): DegreesOfSeparationCount {
    const graph = this.#buildGraph(socialNetworkGraph);
    return this.#calculateDegreesOfSeparation(person, graph);
  }
}

And we can remove the full implemenation of each method from the MockSocialNetworkGraphService class:

Code: tests/domain/mocks/MockSocialNetworkGraphService.ts

import { DegreesOfSeparationCount } from '../../../src/domain/interfaces/DegreesOfSeparationCount';
import { SocialNetworkGraphService } from '../../../src/domain/services/SocialNetworkGraphService';
import { SocialNetworkGraph } from '../../../src/domain/interfaces/SocialNetworkGraph';

export class MockSocialNetworkGraphService implements SocialNetworkGraphService {
  public countPeopleWithNoConnections(graph: SocialNetworkGraph): number {
    throw new Error('countPeopleWithNoConnections method not implemented.');
  }

  public countDegreesOfSeparation(
    person: string,
    socialNetworkGraph: SocialNetworkGraph,
  ): DegreesOfSeparationCount {
    throw new Error('countDegreesOfSeparation method not implemented.');
  }
}

We do not need to add private methods as they should not exist in the interface. We could however leave notes in the code to indicate the private methods exist for other developers to see.

Step 8: Red Phase - Write failing test for our Express Router

First we'll update our test script to output more verbose logs:

  "scripts": {
    "test": "jest --watchAll --verbose"
  },

This gives us a lot more detail:

 PASS  tests/domain/use-cases/CountPeopleWithNoConnections.spec.ts
  CountPeopleWithNoConnections
    ✓ should return 1 when there is one person with no connections (3 ms)
    ✓ should return 0 when there are no people with no connections

 PASS  tests/infrastructure/services/SocialNetworkGraphService.spec.ts
  SocialNetworkGraphService
    ✓ countPeopleWithNoConnections should return 1 for John (2 ms)
    ✓ countDegreesOfSeparation should 2 connections at 1 degree and 1 connection at 2 degrees for John (1 ms)
    ✓ countDegreesOfSeparation should 3 connections at 1 degree and 0 connections at 2 degrees for Peter (1 ms)
    ✓ countDegreesOfSeparation should 2 connections at 1 degree and 1 connections at 2 degrees for George
    ✓ countDegreesOfSeparation should 0 connections at both 1 degree and 2 degrees for Harry
    ✓ countDegreesOfSeparation should 1 connections at 1 degree and 2 connections at 2 degrees for Anna

 PASS  tests/domain/use-cases/CountDegressOfSeperation.spec.ts
  CountConnectionsByDegreesOfSeparation
    ✓ should return 2 connections at 1 degree and 1 connection at 2 degrees for John (3 ms)
    ✓ should return 3 connections at 1 degree and 0 connections at 2 degrees for Peter (1 ms)
    ✓ should return 2 connections at 1 degree and 1 connections at 2 degrees for George
    ✓ should return 0 connections at both 1 degree and 2 degrees for Harry
    ✓ should return 1 connections at 1 degree and 2 connections at 2 degrees for Anna

-----------------------------------|---------|----------|---------|---------|-------------------
File                               | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------------------------|---------|----------|---------|---------|-------------------
All files                          |     100 |       85 |     100 |     100 |
 domain/use-cases                  |     100 |      100 |     100 |     100 |
  CountDegressOfSeperation.ts      |     100 |      100 |     100 |     100 |
  CountPeopleWithNoConnections.ts  |     100 |      100 |     100 |     100 |
 infrastructure/services           |     100 |       85 |     100 |     100 |
  SocialNetworkGraphServiceImpl.ts |     100 |       85 |     100 |     100 | 33-48
-----------------------------------|---------|----------|---------|---------|-------------------
Test Suites: 3 passed, 3 total
Tests:       13 passed, 13 total
Snapshots:   0 total
Time:        1.897 s
Ran all test suites.

Next can now write a failing test for our Express Router.

Let's install the dependencies for the test:

npm i express
npm i -D @types/express supertest @types/supertest

We need to add esModuleInterop to the tsconfig.json file:

{
  "compilerOptions": {
    "target": "ES6",
    "module": "commonjs",
    "strict": true,
    "outDir": "./dist",
    "esModuleInterop": true
  },
  "include": ["src/**/*.ts", "tests/**/*.ts"],
  "exclude": ["node_modules"]
}

So that we can use ES6 modules in our server.ts file.

import express from 'express';

const server = express();

server.use(express.json());

export default server;

Code: tests/api/routes/SocialNetworkGraph.spec.ts