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
}
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
};
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
}
Company and job repositories
// company.repository.ts
import { Company } from 'entities/company.entity';
export interface CompanyRepository {
findOne(/* ... */): Promise<Company>;
// Other user fetching methods
}
// job.repository.ts
import { Job } from 'entities/job.entity';
export interface JobRepository {
findOne(/* ... */): Promise<Job>;
// Other user fetching methods
}
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
}
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
};
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
}
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');
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 {}
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
}
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.
Top comments (0)