Ref
- Introduction to Clean Architecture for TypeScript: PART1
- Introduction to Clean Architecture for TypeScript: PART2
- Introduction to Clean Architecture for TypeScript: PART3
- Introduction to Clean Architecture for TypeScript: PART4
Abstract
Clean Architecture deals with concentric architecture with the business domain at the core, but
One of the key principles is the dependency constraint, which states that we should only depend on the inside from the outside.
However, in a straightforward implementation, the processing flow and dependencies would be in the same direction.
Of course, the Clean Architecture also requires processing from the inside to the outside, so a straightforward implementation will result in dependencies from the inside to the outside.
In other words, in order to observe the dependency principle, the flow of processing and the direction of dependency must be reversed.
The technique for solving this problem is called dependency inversion.
As the name suggests, this technique is used to satisfy the Dependency Inversion Principle (DIP).
So how do we reverse the dependency?
In this chapter, we introduce a technique called Dependency Injection (DI), which is commonly used to reverse dependencies.
DI library for TypeScript
Dependency Injection is a technique that has been used for a long time in statically typed languages such as Java.
You can build your own mechanism to realize DI, or you can use existing DI libraries (DI frameworks).
For example, in Java, there are many DI libraries such as Spring Framework and google/guice.
These DI libraries allow you to use DI for developing various applications.
How about in the context of JavaScript and TypeScript?
It may not be common to use DI in web frontend development.
Nevertheless, there are DI libraries available for JavaScript and TypeScript.
For example, AngularJS and Angular have a DI framework built in.
There are also libraries that provide DI functionality on its own, such as InversifyJS and TSyringe.
Both InversifyJS and TSyringe are DI libraries for use with TypeScript.
InversifyJS is a library with over 5000 Github stars and is used in over 18,000 repositories.
On the other hand, there doesn't seem to be much active development going on these days.
TSyringe is a DI library that is mainly developed by Microsoft. As of September 2020, it has about 1,400 stars on Github, and it seems to be under continuous development.
In this chapter, we will first introduce a simple method to perform DI without using libraries.
This simplified method uses a simple example to show what DI is trying to accomplish and what problems it has.
We will then explain how TSyringe can be used to solve these problems.
Simple DI without libraries
In this section, we will use a simple example to illustrate DI without using libraries.
The goal is to demonstrate an understanding of the overview of DI and the issues involved in DI.
However, as will be explained later, there are some practical problems with the method presented in this section.
In the next section, we will confirm that these problems are solved by DI using the library.
In this section, we will deal with a simple subject as follows.
import Printer from './Printer';
class Document {
constructor(private content: string) {}
output() {
const printer = new Printer();
printer.print(this.content);
}
}
export default class Printer {
constructor() {}
print(content: string) {
console.log('Print:', content);
}
}
Document
will output its own content using the output
method.
In the code above, the output
method uses the class Printer
internally.
Printer
prints out the received string by the print
method.
However, in order to simplify the implementation, we only use console.log
for the output.
The codes to use Document
are as follows.
import Document from './Document';
const document = new Document('this is sample text');
document.output() // "Print: this is sample text"
Looking at the dependencies, we see a dependency from Document
to Printer
.
Now let's assume that Email
is added as an output of Document
.
An example codes for this would look like the following
import Email from './Email';
import Printer from './Printer';
export default class Document {
constructor(private content: string, private method: Printer | Email) {}
output() {
if (this.method instanceof Printer) {
this.method.print(this.content);
} else {
this.method.send(this.content);
}
}
}
export default class Email {
constructor() {}
send(content: string) {
console.log('Email:', content);
}
}
New Email
has been added, and Document
now receives the output method in the constructor.
The dependency in this implementation is still the same as before, from Document
to Printer
.
In addition, Document
now also depends on Email
.
The code for the user side looks like this.
import Document from './Document';
import Printer from './Printer';
import Email from './Email';
const printerDocument = new Document('this is sample text', new Printer());
printerDocument.output(); // "Print: this is sample text"
const emailDocument = new Document('this is sample text', new Email());
emailDocument.output(); // "Email: this is sample text"
As you may have already noticed, this implementation violates the DIP.
This is because Document
depends on the concrete implementations Printer
and Email
.
This means that if Printer
or Email
is changed, Document
will also be affected.
In a simple example, if you rename the print
method of Printer
, you will need to modify the output
method of Document
too.
Also, if you want to add more output methods for Document
, as you did for Email
, you need to modify Document
.
This is a violation of the Open-Closed Principle (OCP): "open to extensions".
To solve the above problems, we will use DI to reverse the dependencies.
Reverse the dependency in the following steps.
- Create
Document
interface onDocumentOutputMethod
side -
Printer
andEmail
implement theDocumentOutputMethod
interface -
Document
implements the process using the interface - Pass an instance of the class that implements the
DocumentOutputMethod
interface to the constructor ofDocument
in the user side codes
This is called Dependency Injection (DI) because the instance to be depended on in step 4 is injected from the outside.
Let's take a look at an example implementation.
export interface DocumentOutputMethod {
output: (content: string) => void;
}
export default class Document {
constructor(private content: string, private method: DocumentOutputMethod) {}
output() {
this.method.output(this.content);
}
}
Added the DocumentOutputMethod
interface under the management of Document
.
And Document
uses this DocumentOutputMethod
to implement the output
method.
By doing this, you can see that it no longer depends on specific implementations such as Printer
or Email
.
import { DocumentOutputMethod } from './Document';
export default class Printer implements DocumentOutputMethod {
constructor() {}
print(content: string) {
console.log('Print:', content);
}
output(content: string) {
this.print(content);
}
}
import { DocumentOutputMethod } from './Document';
export default class Email implements DocumentOutputMethod {
constructor() {}
send(content: string) {
console.log('Email:', content);
}
output(content: string) {
this.send(content);
}
}
Printer
and Email
implement the DocumentOutputMethod
interface.
Added output
method to satisfy the DocumentOutputMethod
interface.
This means that Printer
and Email
now depend on Document
.
The above fix reversed the dependency.
import Document from './Document';
import Printer from './Printer';
import Email from './Email';
const printerDocument = new Document(
'this is sample text',
new Printer()
);
printerDocument.output();
const emailDocument = new Document('this is sample text', new Email());
emailDocument.output()
Finally, in the code of the user side, the constructor of Document
is passed an instance of a class that implements the DocumentOutputMethod
interface.
Now you can do DI without using the library.
Problems with this method
Actually, there is a problem with this method.
That is, when you instantiate Document
, you need to instantiate and pass in Printer
and Email
.
This is another way of saying that any code that uses Document
will always depend on Printer
and Email
.
In the above example, main.ts
is affected by this, but
If you use Document
in multiple locations, each location will depend on Printer
and Email
.
After removing the dependency on the concrete implementation from Document
, this just shifts the dependency elsewhere.
To solve this problem, the dependency on concrete code should be gathered in as few places as possible, as described in Introduction to Clean Architecture for TypeScript: PART4.
So how do we aggregate our dependency on concrete code?
This can be solved by using an object called a DI container, which has a correspondence table of dependencies.
The DI container holds a list of correspondences between the class to be injected (Document
in this case) and the class to inject (Printer
or Email
).
When instantiating the class to be injected, it automatically selects the class to inject from the correspondence table of the DI container, instantiates it, and passes it on.
Then, the description of the correspondence of the DI containers is integrated into the main component or configuration files.
In this way, the class to be injected (Document
) can be used in various places while consolidating the dependencies on concrete code.
It is of course possible to implement a DI container without any libraries.
However, there are a number of technical issues that need to be resolved, and it is quite difficult to implement them on your own.
This is a brief bullet list of issues that need to be resolved.
If you are interested, please check out the details.
- Need to use decorator
- Metadata needs to be retrieved by reflection
- TypeScript interface information is removed at compile time and cannot be mapped in the DI container.
These points have been resolved in the existing DI library.
In the next section, we will show an example of a DI that solves this problem by using TSyringe.
TSyringe
TSyringe is a DI library developed under the leadership of Microsoft.
As the name suggests, TSyringe allows you to introduce DI into your TypeScript development.
TypeScript Decorators
TSyringe uses an experimental feature in TypeScript called Decorators.
Decorators are in the Stage 2 Draft stage of JavaScript as of March 2021.
As such, there is room for change as a specification.
Similarly, TypeScript has been implemented it as an experimental feature, and its specifications are subject to change in the future.
So why does TSyringe use Decorators?
This is because the nature of the DI library is such that it works well with decorators (as a general language feature).
The DI library needs to know in some way the class to which it is injecting dependencies.
Before decorators were used, the injection target was set by a configuration file.
In this way, it is not possible to know what the DI settings are in the file where the class is declared.
There is also the cost of writing and managing configuration files.
By using decorators, these problems can be solved.
The following is an example of using TypeScript Decorators (not related to TSyringe).
function methodDecorator(target: any, props: string, descriptor: PropertyDescriptor) {
console.log('This is method decorator f()');
}
function classDecorator(constructor: Function) {
console.log('This is class decorator f()');
}
@classDecorator
class SampleClass {
@methodDecorator
hoge() {
return null;
}
}
In order to use Decorators in TypeScript, you need to add the following setting in the compiler options.
{
"compilerOptions": {
"experimentalDecorators": true
}
}
Installation and initial setup
This section describes the setup for using TSyringe.
The information here is based on official documentation.
First, install the package.
Install the package tsyringe
by
$ npm install --save tsyringe
or,
$ yarn add tsyringe
TSyringe uses an experimental feature called Decorators in TypeScript, so we will rewrite tsconfig.js
as follows
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
We can use Decorators by enabling experimentalDecorators
.
You can also enable emitDecoratorMetadata
to allow the decorator to handle a lot of metadata.
To enable the Reflect API to handle metadata, you need to select one of the following libraries to use as a polyfill.
- reflect-metadata
- core-js (core-js/es7/reflect)
- reflection
In this example, we install reflect-metadata
as shown below,
$ npm install --save reflect-metadata
or,
$ yarn add reflect-metadata
reflect-metadata
needs to be import
only once before using DI.
It is a good idea to do import
at the top level as much as possible.
import "reflect-metadata";
Now you are ready to use TSyringe.
Next, let's look at how to actually do DI.
Define the class where you want to do DI
TSyringe provides a simple API for doing DI.
The implementation code differs depending on whether interface
is used for the type of the object to be injected or not.
Here is an example of how to use interface
.
For more information, please refer to the official documentation.
First, define the type to be injected with interface
.
// apiInterface.ts
export interface User {
id: number;
name: string;
}
export interface UserApiInterface {
fetchUser: (args: { id: number }) => User;
}
UserApiInterface
defines an API for retrieving user information.
It is assumed that the class that implements this interface includes processes such as communicating with the API server and connecting directly to the DB.
In the following, as a mock, we will implement a class that receives a user ID and returns static information.
// apiImpl.ts
import { UserApiInterface, User } from './apiInterface';
export class UserApiImpl implements UserApiInterface {
fetchUser({ id }: { id: number }): User {
return {
id,
name: `This is fetchUse of UserApi: ${id}`.
};
}
}
Then, define the class in which UserApiInterface
should be injected.
// api.ts
import { injectable, inject } from 'tsyringe';
import { UserApiInterface } from './apiInterface';
@injectable()
export class UserApi {
constructor(
@inject('UserApiInterface') private api: UserApiInterface
) {}
fetchUser(args: { id: number }) {
return this.api.fetchUser(args);
}
}
UserApi
receives an object of type UserApiInterface
in constructor
as an argument and keeps it as a private member.
Then, in UserApi.fetchUser()
, it calls api.fetchUser()
, which is implemented in api
.
In this way, UserApi
can change its behavior depending on the object passed in constructor
.
By writing @inject('UserApiInterface')
in constructor
, we pass the interface metadata to the DI library side.
Injecting an object
Now, inject the object into UserApi
.
// main.ts
import { container } from 'tsyringe';
import { UseApiImpl } from './apiImpl';
import { UserApi } from './api';
container.register('UserApiInterface', {
useClass: UserApiImpl
});
const userApi = container.resolve(UserApi);
container
is TSyringe's DI container, which holds the information to perform DI.
This container
's register()
method sets the object to be injected.
In this example, we are configuring UserApiInterface
to inject UserApiImpl
.
And finally, container.resolve(UseApi)
instantiates UserApi
.
At this time, container
instantiates UserApiImpl
and passes it to UserApi
.
In other words, TSyringe is a mechanism to create instances while resolving dependencies (which registered by register()
) by resolve()
.
This is the basic usage of TSyringe.
This DI mechanism can be used to reverse the dependency.
Dependency Inversion with DI Library
In the previous section, we presented an example of DI using TSyringe.
As many readers may have noticed from the implementation examples, using a DI library like TSyringe does not automatically reverse the dependencies.
The DI library does this by externally selecting and injecting the objects that a class depends on.
Therefore, it is possible to reverse the dependency by using it properly, but on the other hand, it is not possible to change the direction of the dependency if it is not used properly.
This section describes how to use the DI library to reverse the dependencies.
Design modules
First, let's design modules that will be the subject of our example.
In this section, we will consider the User
model, which is the domain model, and Database
, which is responsible for persisting the model.
User
corresponds to the business logic and is located at the center of the concentric circles diagram.
And since Dataset
is a concrete module, it is located outside the concentric diagram.
Therefore, in accordance with Clean Architecture, the dependency goes from Database
to User
.
This is represented in the diagram as the above image.
Database
can know about User
, but conversely, User
cannot know about Database
.
For the sake of illustration, let's assume that one file corresponds to one module, and create user.ts
and database.ts
.
First, let's define each one without considering the correct dependencies.
import Database from './database';
export default class User {
private _id: number | null;
private _name: string;
constructor(private database: Database) {
this._id = null;
this._name = '';
}
set id(value: number) {
this._id = value;
}
set name(value: string) {
this._name = value;
}
get name() {
return this._name;
}
save() {
if (typeof this._id === 'number') {
this.database.save(this);
}
}
}
User
can be set to id
and name
.
It persists itself via Database
by User.save()
.
import User from './user';
export default class Database {
save(user: User) {
console.log(`${user.name} has been saved`);
}
}
Database
will receive the User
model and persist it.
However, for the sake of simplicity, we will only display the received User
's name
in the console.
Let's take a look at the dependency between User
and Database
defined so far.
Database
is import
the type information of User
.
User
also uses Database
as import
, but
This is because User
uses Database
for persistence.
If you look at these dependencies, you can see that they violate the dependency rules.
Now, let's fix the dependency to the correct orientation.
Reversing dependencies between modules
First, define DatabaseInterface
in user.ts
.
export default class User {
private _id: number | null;
private _name: string;
constructor(private database: DatabaseInterface) {
this._id = null;
this._name = '';
}
set id(value: number) {
this._id = value;
}
set name(value: string) {
this._name = value;
}
get name() {
return this._name;
}
save() {
if (typeof this._id === 'number') {
this.database.save(this)
}
}
}
export interface DatabaseInterface {
save: (user: User) => void;
}
Change the constructor
argument of User
to DatabaseInterface
.
This will remove import
from user.ts
to database.ts
.
User
is no longer dependent on Database
.
Next, modify Database
so that it implements DatabaseInterface
.
import User, { DatabaseInterface } from './user';
export default class Database implements DatabaseInterface {
save(user: User) {
console.log(`${user.name} has been saved`);
}
}
Database
is importing User
, which turns out to be fine according to the dependency rules we were aiming for.
Now we have satisfied the dependency rule.
The last step is to configure TSyringe.
import { injectable, inject } from 'tsyringe';
@injectable()
export default class User {
private _id: number | null = null;
private _name: string = '';
constructor(
@inject('DatabaseInterface')
private database: DatabaseInterface
) {}
set id(value: number) {
this._id = value;
}
set name(value: string) {
this._name = value;
}
get name() {
return this._name;
}
save() {
if (typeof this._id === 'number') {
this.database.save(this);
}
}
}
$export interface DatabaseInterface {
save: (user: User) => void;
}
Added User
with @injectable()
and added database
argument of constructor
with the decorator @inject('DatabaseInterface')
.
Now you can inject Database
into User
by container
.
Let's take a look at the code that uses these modules.
import { container } from 'tsyringe';
import User from './di/user';
import Database from './di/database';
container.register('DatabaseInterface', {
useClass: Database
});
export const user = container.resolve(User);
user.id = 0;
user.name = 'test user';
user.save(); // 'test user saved' will be displayed in the console
This reversed the dependency and helped us to follow the rules.
The important thing here is to define DatabaseInterface
, on which User
originally depends, in the User
module.
In this way, User
can manage DatabaseInterface
within the scope of its own responsibilities without directly relying on Database
.
In other words, if you define DatabaseInterface
in the Database
module, it will lose its meaning.
Now we know that with proper use of interfaces and DI, we can reverse the dependencies between modules.
Aggregating dependencies on concrete code
As described in the previous section, DI without libraries has a problem that it cannot aggregate dependencies on concrete code.
In the example in this section using TSyringe, we can aggregate container.register()
into app.ts
.
And by using container.resolve(User)
, the code on the side using User
no longer depends on Database
.
In this way, we can see that the problem that the simple DI without the library has been solved.
Ads
This article is an excerpt from the first chapter of the book "React Clean Architecture". Some wording has been changed. You can buy the kindle version of this book from amazon.
Top comments (0)