DEV Community

John Piedrahita
John Piedrahita

Posted on • Updated on

Clean Architecture with Nodejs, TypeScript and Mongo.

In this case we will use the package @tsclean/scaffold, which generates a structure based on Clean Architecture, it also has a CLI that helps us generating code to create the different components of the application. This package includes the (Dependency Injection, DI), design pattern in object oriented programming (OOP) and the design principle Inversion of Control, IoC), who resolves dependencies on application startup.

This time we are going to create a simple use case, but that contextualizes how the package works.

Clean architecture

Use case for saving an entity in a database.
  • We install the package globally on our pc.
npm i -g @tsclean/scaffold
Enter fullscreen mode Exit fullscreen mode
  • We create the project.
scaffold create:project --name=app
Enter fullscreen mode Exit fullscreen mode

This command generates the following project structure and installs the dependencies to make the application work.

app
  |- node_modules
  |- src
     |- application
        |- config
            |- environment.ts
        |- app.ts
     |- domain
        |- models
        |- use-cases
           |- impl
     |- infrastructure
        |- driven-adapters
           |- adapters
           |- providers
        |- entry-points
           |- api
     |- index.ts
  |- tests
     |- domain
     |- infrastructure
  .env
  .env.example
  .gitignore
  package.json
  READMED.md
  tsconfig-build.json
  tsconfig.json
Enter fullscreen mode Exit fullscreen mode
  • We create the entity with the corresponding attributes, in this case we are going to store a user.
scaffold create:entity --name=user
Enter fullscreen mode Exit fullscreen mode

This command will create the following structure in the domain layer.

src
  |- domain
     |- models
        |- user.ts
Enter fullscreen mode Exit fullscreen mode
export type UserModel = {
  id: string | number;
  name: string;
  email: string;
}

export type AddUserParams = Omit<UserModel, 'id'>
Enter fullscreen mode Exit fullscreen mode
  • Now we create the interface that will communicate the domain layer with the infrastructure layer. This interface will contain the use case.
scaffold create:interface --name=add-user --path=models
Enter fullscreen mode Exit fullscreen mode

This command will create the following structure in the domain layer.

src
  |- domain
     |- models
        |- gateways
           |- add-user-repository.ts
Enter fullscreen mode Exit fullscreen mode
import {UserModel, AddUserParams} from "@/domain/models/user";

export const ADD_USER_REPOSITORY = "ADD_USER_REPOSITORY"

export interface IAddUserRepository {
    addUserRepository: (data: AddUserParams) => Promise<UserModel>;
}
Enter fullscreen mode Exit fullscreen mode
  • Now we create the service that is going to have all the logic to store the user.
scaffold create:service --name=add-user
Enter fullscreen mode Exit fullscreen mode

This command will create the following structure in the domain layer.

src
  |- domain
     |- use-cases
        |- impl
           |- add-user-service-impl.ts
        | - add-user-service.ts
Enter fullscreen mode Exit fullscreen mode

Interface to communicate the service with external layers.

import {UserModel, AddUserParams} from "@/domain/models/user";

export const ADD_USER_SERVICE = "ADD_USER_SERVICE"

export interface IAddUserService {
  addUserService: (data: AddUserParams) => Promise<UserModel>
}
Enter fullscreen mode Exit fullscreen mode

Servicio donde implementamos la interface.

import {Service} from "@tsclean/core";
import {UserModel} from "@/domain/models/user";
import {AddUserParams} from "@/domain/models/user";
import {IAddUserService} from "@/domain/use-cases/add-user-service";
import {ADD_USER_REPOSITORY, IAddUserRepository} from "@/domain/models/gateways/add-user-repository";

@Service()
export class AddUserServiceImpl implements IAddUserService {
    constructor(
        @Adapter(ADD_USER_REPOSITORY) private readonly addUserRepository: IAddUserRepository
    ) {
    }

    async addUserService(data: AddUserParams): Promise<UserModel> {
        return await this.addUserRepository.addUserRepository(data);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Now we create the ORM adapter, in this case it is mongoose, but sequelize is also enabled.
scaffold create:adapter-orm --name=user --orm=mongoose
Enter fullscreen mode Exit fullscreen mode

This command creates the following structure in the infrasctructure layer and updates the index.ts file with the configuration to connect to Mongo.

src
  |- infrasctructure
     |- driven-adapters
        |- adapters
           |- orm
              |- mongoose
                 |- models
                    |- user.ts
                 |- user-mongoose-repository-adapter.ts
        |- providers
           |- index.ts
Enter fullscreen mode Exit fullscreen mode

We create the model with the attributes of the entity that is in the domain, ensuring that the entries are the same.

import { model, Schema } from "mongoose";
import { UserModel } from '@/domain/models/user';

const schema = new Schema<UserModel>({
    id:  { type: String },
    name: { type: String, required: true },
    email:  { type: String, required: true }
});

export const UserModelSchema = model<UserModel>('users', schema);
Enter fullscreen mode Exit fullscreen mode

At this point the implementation of the interface in the adapter helps us with the communication between the layers, so they are decoupled, we apply the SOLID principle of Dependency Inversion Principle (DIP).

import {UserModel, AddUserParams} from "@/domain/models/user";
import {IAddUserRepository} from "@/domain/models/gateways/add-user-repository";
import {UserModelSchema} from "@/infrastructure/driven-adapters/adapters/orm/mongoose/models/user";

export class UserMongooseRepositoryAdapter implements IAddUserRepository {

    async addUserRepository(data: AddUserParams): Promise<UserModel> {
        return await UserModelSchema.create(data);
    }
}
Enter fullscreen mode Exit fullscreen mode

This class injects the dependencies, which are resolved at runtime.

import {ADD_USER_REPOSITORY} from "@/domain/models/gateways/add-user-repository";
import {ADD_USER_SERVICE} from "@/domain/use-cases/add-user-service";

import {AddUserServiceImpl} from "@/domain/use-cases/impl/add-user-service-impl";
import {UserMongooseRepositoryAdapter} from "@/infrastructure/driven-adapters/adapters/orm/mongoose/user-mongoose-repository-adapter";

export const providers = [
    {
        classAdapter: UserMongooseRepositoryAdapter,
        key: ADD_USER_REPOSITORY
    }
]

export const services = [
    {
        classAdapter: AddUserServiceImpl,
        key: ADD_USER_SERVICE
    }
]
Enter fullscreen mode Exit fullscreen mode

index.ts updated for mongoose.

import 'module-alias/register'

import helmet from 'helmet';
import { connect } from 'mongoose';
import { StartProjectServer } from "@tsclean/core";

import { AppContainer } from "@/application/app";
import {MONGODB_URI, PORT} from "@/application/config/environment";

async function run(): Promise<void> {
  await connect(MONGODB_URI);
  console.log('DB Mongo connected')
  const app = await StartProjectServer.create(AppContainer);
   app.use(helmet());
   await app.listen(PORT, () => console.log('Running on port: ' + PORT))
}

run();
Enter fullscreen mode Exit fullscreen mode

After this you must include in the .env file the mongo url to which you are going to connect.

At this point you can run the application on port 9000 and it should work.

  • Now we create the controller, entry point to the application. If you name the controller as in the service, it creates code where you inject the dependency and create the corresponding route. When creating the service the name that we gave him was add-user, we must use that same name for the controller.
scaffold create:controller --name=add-user
Enter fullscreen mode Exit fullscreen mode

This command will create the following structure in the infrasctructure layer.

src
  |- infrasctructure
     |- entry-points
        |- api
           |- add-user-controller.ts
           |- index.ts
Enter fullscreen mode Exit fullscreen mode
import {Mapping, Body, Post} from "@tsclean/core";
import {UserModel, AddUserParams} from "@/domain/models/user";
import {ADD_USER_SERVICE, IAddUserService} from "@/domain/use-cases/add-user-service";

@Mapping('api/v1/add-user')
export class AddUserController {

    constructor(
         @Adapter(ADD_USER_SERVICE) private readonly addUserService: IAddUserService
    ) {
    }

    @Post()
    async addUserController(@Body() data: AddUserParams): Promise<UserModel> {
        return await this.addUserService.addUserService(data);
    }
}
Enter fullscreen mode Exit fullscreen mode
import {AddUserController} from "@/infrastructure/entry-points/api/add-user-controller";

export const controllers = [
    AddUserController
]
Enter fullscreen mode Exit fullscreen mode

All the components are ready for the use case to be ready, they just need to be put together.

In the app.ts file that is in the application layer we must make this configuration.

import {Container} from "@tsclean/core";
import {controllers} from "@/infrastructure/entry-points/api";
import {adapters, services} from "@/infrastructure/driven-adapters/providers";

@Container({
    controllers: [...controllers],
    providers: [...services, ...adapters]
})

export class AppContainer {}
Enter fullscreen mode Exit fullscreen mode

Congratulations, everything is ready, you can run the application in postman url http://localhost:9000/api/v1/add-user.

Final notes

This use case is basic, because it lacks some important elements, among them the validations:

  • The email must be unique.
  • Must have the correct format.
  • Character length must be validated.

The developer can include the validator of his choice or do it with his own script. If you do it with an external library you must create an adapter for this purpose.

Discussion (4)

Collapse
raademar profile image
Mattias Rådemar

The correct syntax for the providers/index.ts files is like below. According to the example api from the developers at github.com/tsclean/api-example/blo...

export const adapters = [
{
useClass: UserMongooseRepositoryAdapter,
provide: ADD_USER_REPOSITORY,
},
];

export const services = [
{
useClass: AddUserServiceImpl,
provide: ADD_USER_SERVICE,
},
];

Collapse
japhernandez profile image
John Piedrahita Author

It is correct.

Collapse
dgorodriguez_ profile image
Diego Rodriguez

When trying to create the orm adapter, I get the error "🚫 You must first create the candidate entity to be imported to the ORM adapter.", I already created the userModel entity with the properties.
What can be?

Collapse
japhernandez profile image
John Piedrahita Author • Edited on

How are you creating the adapter and for which ORM?

scaffold create:entity --name=user
scaffold create:adapter-orm --name=user

By convention you must use the same name of the entity.