DEV Community

Cover image for Step up your developper game SOLID-ifying your codebase
Stanislas Bernard
Stanislas Bernard

Posted on

Step up your developper game SOLID-ifying your codebase

So recently a friend of mine asked me for help on a NestJs project. He was facing a strong coupling between the business services and TypeORM-related code.

// subscription.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from 'entities/user.entity';
import { Company } from 'entities/company.entity';
import { Job } from 'entities/job.entity';

@Injectable()
export class SubscriptionService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    @InjectRepository(Company)
    private readonly companyRepository: Repository<Company>,
    @InjectRepository(Job)
    private readonly jobRepository: Repository<Job>,
  ) {}

  // business logic happening here
}
Enter fullscreen mode Exit fullscreen mode

Theses services grew up to have more and more Typeorm repositories injected with the @InjectRepository annotation, and were eventually really hard to test with all the mocks involved.

// subscription.service.test.ts

import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { mock } from 'jest-mock-extended';
import { Repository } from 'typeorm';
// [...]

describe('The subscription service', () => {
  let subscriptionService: SubscriptionService;
  const userRepositoryMock = mock<Repository<User>>();
  const companyRepositoryMock = mock<Repository<Company>>();
  const jobRepositoryMock = mock<Repository<Job>>();

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
        controllers: [],
        providers: [
          {
            provide: getRepositoryToken(User),
            useValue: userRepositoryMock,
          },
          {
            provide: getRepositoryToken(Company),
            useValue: companyRepositoryMock,
          },
          {
            provide: getRepositoryToken(Job),
            useValue: jobRepositoryMock,
          },
          // other TypeORM related injections
        ],
      }).compile();

    subscriptionService = moduleRef.get<SubscriptionService>(SubscriptionService);
  });

  // Tests on the subscriptionService and the dependencies mocks
};
Enter fullscreen mode Exit fullscreen mode

Instantiating the SubscriptionService is quite cumbersome, as we need to spawn a whole NestJs module to do so.

A more naive approach would be to instantiate the SubscriptionService with something like new SubscriptionService(/* mocked dependencies */), but note that even if some wonderful packages such as jest-mock-extended help mocking dependencies, Typescript will struggle accepting TypeORM repositories mocks as replacement for the mocked services.

Using such repository mocks involves too much abstraction Typescript can't handle and yields an Expression produces a union type that is too complex to represent error.

SOLID principles to the rescue

After a quick discussion about what part of the business this SubscriptionService was addressing, we came to the conclusion that at least the following SOLID principle was broken here:

  • Liskov substitution principle: the SubscriptionService knows too much about the entity repositories it relies on. It should not be aware of TypeORM being used to handle database access. Should I change my data access logic, my business services must not suffer from any code change

Separation of concerns

Decoupling data access from business logic

Having a clear idea of what the problem was, I started thinking about what should be improved with the design. The SubscriptionService must be decoupled from my ORM: let's draw the separating line with a few interfaces:

// user.repository.ts

import { User } from 'entities/user.entity';

export interface UserRepository {
  findOne(/* ... */): Promise<User>;

  // Other user fetching methods
}
Enter fullscreen mode Exit fullscreen mode

Company and job repositories
// company.repository.ts

import { Company } from 'entities/company.entity';

export interface CompanyRepository {
  findOne(/* ... */): Promise<Company>;

  // Other user fetching methods
}
Enter fullscreen mode Exit fullscreen mode
// job.repository.ts

import { Job } from 'entities/job.entity';

export interface JobRepository {
  findOne(/* ... */): Promise<Job>;

  // Other user fetching methods
}
Enter fullscreen mode Exit fullscreen mode

We can now update our SubscriptionService to make use of these repositories:

// subscription.service.ts

import { Injectable } from '@nestjs/common';
import { UserRepository } from 'repositories/user.repository';
import { CompanyRepository } from 'repositories/company.repository';
import { JobRepository } from 'repositories/job.repository';

@Injectable()
export class SubscriptionService {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly companyRepository: CompanyRepository,
    private readonly jobRepository: JobRepository,
  ) {}

  // business logic happening here
}
Enter fullscreen mode Exit fullscreen mode

Testing the service is now way simpler, we don't need any NestJs utility to instantiate the system under test— SubscriptionService—:

// subscription.service.test.ts

import { mock } from 'jest-mock-extended';
import { UserRepository } from 'repositories/user.repository';
import { CompanyRepository } from 'repositories/company.repository';
import { JobRepository } from 'repositories/job.repository';

describe('The subscription service', () => {
  const userRepositoryMock = mock<UserRepository>();
  const companyRepositoryMock = mock<CompanyRepository>();
  const jobRepositoryMock = mock<JobRepository>();
  const subscriptionService = new SubscriptionService(
    userRepositoryMock,
    companyRepositoryMock,
    jobRepositoryMock,
  );

  // Tests on the subscriptionService and the dependencies mocks
};
Enter fullscreen mode Exit fullscreen mode

Implementing the newly created interfaces

In order to make our application work again, we need to use the TypeORM repositories to implement the interfaces we just defined. We can create a concrete implementation for UserRepository as follows:

// user.typeorm-repository.ts

import { UserRepository } from 'repositories/user.respository';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from 'entities/user.entity';

export class UserTypeormRepository implements UserRepository {
  constructor(
    @InjectRepository(User)
    private readonly userORMRepository: Repository<User>,
  ) {}

  findOne(/* ... */): Promise<User> {
    return this.userORMRepository.findOne(/* ... */);
  }

  // Other method implementations
}
Enter fullscreen mode Exit fullscreen mode

Same goes for the CompanyRepository and JobRepository implementations, I will not go into unnecessary details.

Using NestJs service container to put everything together

Now we have on one side our SubscriptionService using repository interfaces, and concrete interface implementations on the other side.

A typical way to plug the two together is to use a service container, that will take care of injecting concrete implementations where interfaces are required.

Let's use NestJs service container to do so!

Since Typescript interfaces do not exist in Javascript, we need to help NestJs match an interface with its implementation using what NestJs calls providers.

We will use simple strings defined along our repositories as unique keys to help dependency injection:

// user.repository.ts

import { User } from 'entities/user.entity';

export interface UserRepository {
  // ...
}

export const userRepositoryToken = Symbol('UserRepository');
Enter fullscreen mode Exit fullscreen mode

Note that we use a Symbol here to ensure the token is unique in the service container.

We can now register the concrete implementation into NestJs service container:

// user.module.ts

import { Module } from '@nestjs/common';
import { userRepositoryToken } from 'repositories/user.repository';
import { UserTypeormRepository } from 'infrastructure/typeorm/user.typeorm-repository';

@Module({
  controllers: [/* .. */],
  providers: [
    {
      provide: userRepositoryToken,
      useClass: UserTypeormRepository,
    },
    // other providers
  ],
})
export class UserModule {}
Enter fullscreen mode Exit fullscreen mode

Same goes for CompanyModule and JobModule.

Injecting the repositories

Concrete implementations of the repository interfaces being available in the service container, we can now fix SubscriptionService dependency injection:

// subscription.service.ts

import { Inject, Injectable } from '@nestjs/common';
import { UserRepository, userRepositoryToken } from 'repositories/user.repository';
import { CompanyRepository, companyRepositoryToken } from 'repositories/company.repository';
import { JobRepository, jobRepositoryToken } from 'repositories/job.repository';

@Injectable()
export class SubscriptionService {
  constructor(
    @Inject(userRepositoryToken)
    private readonly userRepository: UserRepository,
    @Inject(companyRepositoryToken)
    private readonly companyRepository: CompanyRepository,
    @Inject(jobRepositoryToken)
    private readonly jobRepository: JobRepository,
  ) {}

  // business logic happening here
}
Enter fullscreen mode Exit fullscreen mode

Wrapping it up

Using a clear interface to separate technical concerns from business logic is a first step towards making the codebase easy to maintain and evolve.

Unit testing is simplified, it is quicker and safer to refactor the SubscriptionService.

We can now move on to other SOLID principles, if we were to realize that our SubscriptionService does not respect the single responsibility principle, it would not cost much to split it into smaller services.

Discussion (0)