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.
Use case for saving an entity in a database.
- We install the package globally on our pc. ```
npm i -g @tsclean/scaffold
* We create the project.
scaffold create:project --name=app
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
* We create the entity with the corresponding attributes, in this case we are going to store a user.
scaffold create:entity --name=user
This command will create the following structure in the domain layer.
src
|- domain
|- entities
|- user.ts
```typescript
export type UserModel = {
id: string | number;
name: string;
email: string;
}
export type AddUserParams = Omit<UserModel, 'id'>
- 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
This command will create the following structure in the domain layer.
src
|- domain
|- entities
|- contracts
|- add-user-repository.ts
import {UserModel, AddUserParams} from "@/domain/models/user";
export const ADD_USER_REPOSITORY = "ADD_USER_REPOSITORY"
export interface IAddUserRepository {
addUserRepository: (data: AddUserParams) => Promise<UserModel>;
}
- Now we create the service that is going to have all the logic to store the user.
scaffold create:service --name=add-user
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
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>
}
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);
}
}
- Now we create the ORM adapter, in this case it is
mongoose
, butsequelize
is also enabled.
scaffold create:adapter-orm --name=user --orm=mongo --manager=mongoose
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
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);
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);
}
}
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);
}
}
}
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();
},
];
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
}
]
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();
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
This command will create the following structure in the infrasctructure layer.
src
|- infrasctructure
|- entry-points
|- api
|- add-user-controller.ts
|- index.ts
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);
}
}
import {AddUserController} from "@/infrastructure/entry-points/api/add-user-controller";
export const controllers = [
AddUserController
]
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 {}
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)
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,
},
];
It is correct.
Thank you for your guide John! Is it a Clean Architecture or an Hexagonal Architecture ? I'm confused with these two notions.
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?
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.