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
        |- singleton.ts
     |- deployment
        |- Dockerfile
     |- domain
        |- entities
        |- 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
     |- entities
        |- 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
     |- entities
        |- contracts
           |- 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

Service where we implement the 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=mongo --manager=mongoose
Enter fullscreen mode Exit fullscreen mode

This command creates the structure in the infrastructure layer, the mongoose-instance-ts file with the singleton to make the connection to Mongo, and updates the singleton.ts file adding the function that is then iterated in the index.ts to connect to the database.

src
  |- application
     |- config
        |- mongoose-instance.ts
     |- singleton.ts
  |- 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

The scaffold creates the class with the Singleton Pattern to handle the database connection.

import { connect, set } from "mongoose";
import { Logger } from "@tsclean/core";
import { MONGODB_URI } from "@/application/config/environment";

/** Class that manages the configuration and connection to a MongoDB database */
export class MongoConfiguration {
    /** Private logger instance for logging purposes */
    private logger: Logger;

    /** Private static instance variable to implement the Singleton pattern */
    private static instance: MongoConfiguration;

    /** Private constructor to ensure that only one instance is created */
    private constructor() {
        /** Initialize the logger with the class name */
        this.logger = new Logger(MongoConfiguration.name);
    }

    /** Method to get the instance of the class, following the Singleton pattern */
    public static getInstance(): MongoConfiguration {
        if (!this.instance) {
            this.instance = new MongoConfiguration();
        }
        return this.instance;
    }

    /** Asynchronous method to manage the MongoDB database connection */
    public async managerConnectionMongo(): Promise<void> {
        /** Set MongoDB configuration option to enforce strict queries */
        set("strictQuery", true);

        try {
            /** Attempt to connect to the MongoDB database using the provided URI */
            await connect(MONGODB_URI);
            /** Log a success message if the connection is successful */
            this.logger.log(`Connection successfully to database of Mongo: ${MONGODB_URI}`);
        } catch (error) {
            /** Log an error message if the connection fails */
            this.logger.error("Failed to connect to MongoDB", error);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Then the scaffold adds an asynchronous function to the singletonInitializers array found in the singleton.ts file. This array is initialized in the index.ts file to create the connection instances.

import { MongoConfiguration } from "@/application/config/mongoose-instance";

/**
   * This array has all the singleton instances of the application
   */
export const singletonInitializers: Array<() => Promise<void>> = [
    async () => {
        const mongooseConfig = MongoConfiguration.getInstance();
        await mongooseConfig.managerConnectionMongo();
    },
];
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 = [
    {
        useClass: UserMongooseRepositoryAdapter,
        provide: ADD_USER_REPOSITORY
    }
]

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

index.ts

import "module-alias/register";

import helmet from 'helmet';
import { StartProjectInit } from "@tsclean/core";

import { AppContainer } from "@/application/app";
import { PORT } from "@/application/config/environment";
import { singletonInitializers } from "@/application/singleton";

async function init(): Promise<void> {
  /** Iterate the singleton functions */
  for (const initFn of singletonInitializers) {
    await initFn();
  }

  const app = await StartProjectInit.create(AppContainer)
  app.use(helmet());
  await app.listen(PORT, () => console.log(`Running on port: ${PORT}`))
}

void init().catch();
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.

Top comments (5)

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

It is correct.

Collapse
 
realianx profile image
Ian R.

Thank you for your guide John! Is it a Clean Architecture or an Hexagonal Architecture ? I'm confused with these two notions.

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 • Edited

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.