Introduction
At LEOCODE, we are developing multiple projects using Node.js with NestJS as our backend framework of choice. You can read more about why we chose it here. As with every tool, firstly, you need to learn how to use it. What’s even more important though is to learn how to harness its good features and diminish its bad ones to accomplish your particular goals.
We often structure our codebases using a Hexagonal Architecture pattern which emphasizes business logic isolation by defining clear boundaries and testability. In this short article, I will show you how we utilize NestJS (or more precisely its modules feature) to better express these concepts in the code and help developers to better understand them and play by their rules.
What is a module in NestJS?
For those of you who don’t know what the module is, I will briefly introduce it. You can read more about them in the official NestJS documentation. From a technical perspective, the sole purpose of a module is to configure the dependency injection mechanism of NestJS. I have defined one below – in the following code examples I will sometimes skip the import definitions so as not to clutter the examples, but in a real working code, they should be there:
import { Module } from '@nestjs/common';
@Module({
imports: [OtherModule],
providers: [MyService],
controllers: [MyController],
exports: [MyService],
})
class MyModule {}
Let’s take a closer look at these properties which are divided into two groups: one responsible for the internal DI configuration and the other for specifying how our module communicates with the world.
In the first group we have:
- Providers – In this array, we can define what will be injected in places as they are indicated by injection tokens. The injection token can be, for example, a class definition, so in most cases you will see an array of classes here.
- Controllers – NestJS treats controllers a little bit different than any other providers so they have their own definitions. However, they can also be injected like any other providers.
In the second group we have:
- Imports – We define a list of modules that we want to use in our module. We will be able to inject providers they exported into our own providers.
- Exports – We define a list of providers that we want to make accessible for other modules.
The second group is more interesting from our perspective. Using those import and export mechanisms, we are able to replicate the behavior known from languages such as Java package-private access level. In addition, I want to note that I will be using the word “module” to describe the set of classes or functions that together provide some functionality. For example, we can have a module responsible for user account management which consists of classes such as LoginService, RegistrationService and so on. Of course, we can divide user account management into several modules…but I think that you get the idea.
Why limit access to code?
In Java, classes with package-private access level can only be used by other classes from the same package. Only classes that are explicitly published can be imported and used by the classes from other packages. Why, however, may we want to limit access to some of the code? There are a couple of reasons but they all boil down to this: change management. Changes in the software are inevitable due to the way a company operates and how it affects their business requirements.
The technical debt must be paid and the tools and technologies we use change; so must the code we write. In a well-designed system, most of the changes are local which basically means that changing something in one “place” does not require a change in the other. The “place” could be a method of a class, a class itself, a module, a microservice, a system and so on. It depends on the level of abstraction that we have. Local change is much easier to make than a global one. That’s all well and good, but how can we ensure that the change is local? We need to set a boundary between the components of our system so that the change doesn’t cross it. Again, that boundary can be many things depending on the context, but let’s focus on the modules.
In real applications, most modules are meant to provide some sort of services to other modules. These other modules are typically called clients. To make changes local, we need to ensure that the clients are not depending on the implementation details of that module. We only need to expose some carefully picked pieces of our module to the outer world. The ones that don’t change that often are known as stable. Then, as long as we don’t change the published piece, we could even completely rewrite a module and our client wouldn’t even notice. This “published piece” defines the API of the module – its boundary. Other modules that need the functionality provided by our module must utilize its API and cannot depend on its implementation details.
Look at the example below to see how we can use NestJS modules and facade design pattern to achieve that in a language that natively doesn’t support any module access level modifiers:
---- file: A.module.ts
@Module({
providers: [
InternalServiceOfA,
FacadeOfA,
],
exports: [
FacadeOfA,
]
})
export class ModuleA {}
---- file: B.module.ts
import { ModuleA } from './A.module';
@Module({
imports: [
ModuleA
],
providers: [
InternalServiceOfB,
],
})
export class ModuleB {}
We defined two modules. ModuleB depends on ModuleA. Now we can access the API of the ModuleA by injecting its facade into service in the ModuleB:
---- file: InternalServiceOfB.ts
@Injectable()
export class InternalServiceOfB {
constructor(
//this is ok, because we exported that from ModuleA
private facadeOfA: FacadeOfA,
){}
}
However, trying to inject any of the internal providers will fail:
---- file: InternalServiceOfB.ts
@Injectable()
export class InternalServiceOfB {
constructor( // this will throw - that provider was not exported
private internalServiceOfA: InternalServiceOfA,
) {}
}
Regarding the facade, its just a normal service that can look like this:
---- file: FacadeOfA.ts@Injectable()export class FacadeOfA {constructor(private internalServiceOfA: InternalServiceOfA, ) {}public doSomething() {return this.internalServiceOfA.internalDoSomething(); }}
This is a regular internal provider so it has access to all other internal module providers. This way, it can act as a proxy for module behaviors, proxying only those which are intended for a broader audience. Some of you may think that creating methods that serve only as a proxy is creating useless boilerplate – I understand and generally agree with that, but in this particular case, constructing the facade this way has some advantages:
- We have the whole API described in one place. Inside our module, we can have multiple different services doing the actual work but from the perspective of the client, there is only one entry point.
- Changes inside our module don’t affect the clients – as long as they don’t change the API. Additionally, when they do, the facade can act as a translator to let older clients use the new API without changing their implementation.
- The facade can be an interface that has many implementations. It is particularly useful when we structure our system as a modular monolith and want to separate one of its modules into a standalone microservice. Then, clients of that module only need to implement the facade to use, for example, HTTP instead of in-process method calls to talk with it.
What about the tests?
Earlier, I wrote that any client that wants to use our module must utilize its API, but what about the tests? I treat most tests (also unit tests) as a client code so they are also testing the code in a module using its facade. This stands in contrast with many other approaches when you write tests against every class separately (this is also an approach suggested in official NestJS documentation). Some of them even advise testing private methods of a class. In a moment I will explain why I think that these approaches are wrong in most cases, but I want to start with describing what role, in my opinion, the tests should fulfil.
The tests should give me, a developer, the feeling of safety. That is their main role. If you feel confident and safe making a change to a code and then putting it into production straight away (while being sure that it will not break something), then you probably don’t need tests. Many other developers don’t feel that way, however, especially when working on a complex system that has a big user base – me included. We need tests which will give us some level of confidence, that the change that we have to make at some point does not break the expected behavior of our module. As long as we don’t change the behavior, we also don’t want to change the tests. In this approach, the unit of behavior is the whole module, so unit tests are written against the module – its API – not the particular classes and functions that form it. If we do that properly, we can rewrite the whole module from scratch (for example, to address server performance issues or bad design decisions) and not even touch test code, which will act as a safety belt assuring that we don’t introduce a critical change during refactoring. This style of testing naturally leans towards almost never using mocks.
How can we do that in a real project where the module needs to talk to a database? We don’t want to connect to the real database in unit tests, right? Of course not – we want unit tests to be very fast so that we can run them frequently and have instant feedback whether we are breaking something or not. We can utilize a principle known as dependency inversion and program against an abstract interface. We can implement that interface differently for different usage cases. In unit tests we can use an implementation that utilizes hash tables to mimic database behavior. Additionally, in production we use an implementation with a real database driver. Here is how we can once again use the NestJS module to help with these difficulties.
Firstly, we can define two different modules:
/---- file: A.module.ts@Module({ providers: [ InternalServiceOfA, { provide: DatabaseRespoitoryInterface, useClass: RealDatabaseRepositoryImplementation, } ], exports: [ FacadeOfA, ]})export class ModuleA {}@Module({ providers: [ InternalServiceOfA, { provide: DatabaseRespoitoryInterface, useClass: InMemoryRepositoryImplementation, } ], exports: [ FacadeOfA, ]})export class TestModuleA {}
In production code, we will use the ModuleA class, but in the test, we will use the TestModuleA which is configured with in-memory implementation of the repository interface:
---- file: ModuleA.spec.ts
import { Test } from '@nestjs/testing';
const moduleRef = await Test.createTestingModule({
imports: [TestModuleA],
}).compile();
facade = moduleRef.get(FacadeOfA);
Summary
In this short article, I showed you how you can utilize the mechanism of NestJS modules to better handle change isolation, thus writing code that is easier to maintain and evolve. This is by no means a thorough explanation. My goal was to briefly describe the problem and our solution, but if you want to know more, I recommend watching this presentation by Jakub Nabrdalik about this approach to modularization and testing. At Leocode, we are using the techniques described above in multiple projects and we are very satisfied. Try it out for yourself and see if it works just as well for you as it does for us!
Author: Tymoteusz Dzienniak