├── src ├── entities │ ├── .gitkeep │ └── @shared │ │ ├── interfaces │ │ ├── value-object.interface.ts │ │ ├── aggregate-root.interface.ts │ │ └── validation-handler.interface.ts │ │ ├── identifier.base.ts │ │ ├── validator.base.ts │ │ ├── errors │ │ ├── domain.error.spec.ts │ │ ├── domain.error.ts │ │ ├── notification.error.ts │ │ └── notification.error.spec.ts │ │ ├── utils │ │ ├── append-yup-errors-in-validation-handler.util.ts │ │ └── append-yup-errors-in-validation-handler.util.spec.ts │ │ ├── entity.base.ts │ │ ├── notification.validation-handler.ts │ │ ├── entity.validator.ts │ │ ├── notification.validation-handler.spec.ts │ │ └── entity.base.spec.ts ├── usecases │ ├── .gitkeep │ └── @shared │ │ ├── unit-usecase.interface.ts │ │ ├── nullary-usecase.interface.ts │ │ ├── usecase.interface.ts │ │ └── errors │ │ └── application.error.ts ├── infrastructure │ ├── .gitkeep │ ├── ioc │ │ ├── prisma.module.ts │ │ ├── sentry.module.ts │ │ ├── heath.module.ts │ │ ├── jaeger.module.ts │ │ ├── cache.module.ts │ │ └── filters.module.ts │ ├── configs │ │ ├── redis.config.ts │ │ ├── application.config.ts │ │ └── jaeger.config.ts │ ├── services │ │ └── prisma.service.ts │ ├── telemetry │ │ └── jaeger.telemetry.ts │ ├── filters │ │ ├── error.filter.ts │ │ ├── http-exception.filter.ts │ │ ├── application-error.filter.ts │ │ └── notification-error.filter.ts │ ├── heath-indicators │ │ └── prisma.heath-indicator.ts │ └── interceptors │ │ ├── sentry.interceptor.ts │ │ └── jaeger.interceptor.ts ├── presentation │ ├── .gitkeep │ ├── view-models │ │ └── errors │ │ │ ├── domain-error.view-model.ts │ │ │ ├── base-error.view-model.ts │ │ │ ├── internal-server-error.view-model.ts │ │ │ ├── application-error.view-model.ts │ │ │ └── notification-error.view-model.ts │ └── controllers │ │ ├── health.controller.ts │ │ └── errors.controller.ts ├── app.module.ts └── main.ts ├── commitlint.config.js ├── .husky ├── pre-commit └── commit-msg ├── tests └── example.e2e.ts ├── jest-swc.config.js ├── .dockerignore ├── jest-e2e.config.js ├── .editorconfig ├── prisma └── schema.prisma ├── tsconfig.build.json ├── jest.config.js ├── .swcrc ├── tsconfig.json ├── .env.example ├── Dockerfile ├── .eslintrc.json ├── docker-compose.yml ├── docs ├── FILES_AND_DIRS.md └── COMMIT.md ├── package.json ├── .gitignore └── README.md /src/entities/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/usecases/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/infrastructure/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/presentation/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] } 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx eslint . --fix 5 | npm run test -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /src/usecases/@shared/unit-usecase.interface.ts: -------------------------------------------------------------------------------- 1 | export interface UnitUseCase { 2 | execute(): Promise 3 | } -------------------------------------------------------------------------------- /src/usecases/@shared/nullary-usecase.interface.ts: -------------------------------------------------------------------------------- 1 | export interface NullaryUseCase { 2 | execute(input: Input): Promise 3 | } -------------------------------------------------------------------------------- /src/usecases/@shared/usecase.interface.ts: -------------------------------------------------------------------------------- 1 | export interface UnitUseCase { 2 | execute(input: Input): Promise 3 | } -------------------------------------------------------------------------------- /tests/example.e2e.ts: -------------------------------------------------------------------------------- 1 | describe("Example", () => { 2 | 3 | it("should be able sum 1 + 1", () => { 4 | expect(1 + 1).toBe(2) 5 | }) 6 | 7 | }) -------------------------------------------------------------------------------- /src/entities/@shared/interfaces/value-object.interface.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 2 | export interface ValueObject { 3 | } -------------------------------------------------------------------------------- /src/entities/@shared/interfaces/aggregate-root.interface.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 2 | export interface AggregateRoot { 3 | } -------------------------------------------------------------------------------- /src/presentation/view-models/errors/domain-error.view-model.ts: -------------------------------------------------------------------------------- 1 | export class DomainErrorViewModel { 2 | constructor( 3 | readonly field: string, 4 | readonly message: string 5 | ) {} 6 | } -------------------------------------------------------------------------------- /jest-swc.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@swc/core').Config} 3 | */ 4 | module.exports = { 5 | jsc: { 6 | target: 'es2021', 7 | externalHelpers: true, 8 | }, 9 | sourceMaps: true, 10 | }; -------------------------------------------------------------------------------- /src/entities/@shared/identifier.base.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from "@/entities/@shared/interfaces/value-object.interface" 2 | 3 | export abstract class Identifier implements ValueObject { 4 | abstract get value(): T; 5 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .docker 2 | .husky 3 | coverage 4 | dist 5 | node_modules 6 | .editorconfig 7 | .eslintrc.json 8 | .gitattributes 9 | .gitignore 10 | .commitlint.config.js 11 | docker-compose.yml 12 | Dockerfile 13 | jest.config.js 14 | jest-e2e.config.js 15 | README.md -------------------------------------------------------------------------------- /src/infrastructure/ioc/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common" 2 | 3 | import { PrismaService } from "@/infrastructure/services/prisma.service" 4 | 5 | @Module({ 6 | providers: [PrismaService], 7 | exports: [PrismaService], 8 | }) 9 | export class PrismaModule {} -------------------------------------------------------------------------------- /src/presentation/view-models/errors/base-error.view-model.ts: -------------------------------------------------------------------------------- 1 | export class BaseErrorViewModel { 2 | constructor( 3 | readonly code: string, 4 | readonly message: string, 5 | readonly severity: "low" | "medium" | "high", 6 | readonly timestamp: string = new Date().toISOString() 7 | ) {} 8 | } -------------------------------------------------------------------------------- /src/entities/@shared/interfaces/validation-handler.interface.ts: -------------------------------------------------------------------------------- 1 | import { DomainError } from "@/entities/@shared/errors/domain.error" 2 | 3 | export interface ValidationHandler { 4 | get errors(): DomainError[] 5 | hasErrors(): boolean 6 | appendError(error: DomainError): void 7 | appendErrors(errors: DomainError[]): void 8 | clearErrors(): void 9 | } -------------------------------------------------------------------------------- /src/presentation/view-models/errors/internal-server-error.view-model.ts: -------------------------------------------------------------------------------- 1 | import { BaseErrorViewModel } from "@/presentation/view-models/errors/base-error.view-model" 2 | 3 | export class InternalServerError extends BaseErrorViewModel { 4 | constructor(message = "An unexpected error occurred") { 5 | super("internal_server_error", message, "high") 6 | } 7 | } -------------------------------------------------------------------------------- /src/infrastructure/configs/redis.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from "@nestjs/config" 2 | 3 | export const redisConfig = registerAs("redis", () => ({ 4 | socket: { 5 | host: process.env.REDIS_HOST, 6 | port: Number(process.env.REDIS_PORT) 7 | }, 8 | password: process.env.REDIS_PASSWORD, 9 | db: Number(process.env.REDIS_DB), 10 | ttl: 600 11 | })) -------------------------------------------------------------------------------- /src/infrastructure/configs/application.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from "@nestjs/config" 2 | 3 | export const applicationConfig = registerAs("application", () => ({ 4 | name: process.env.APP_NAME || "application", 5 | version: process.env.APP_VERSION || "0.0.0", 6 | environment: process.env.APP_ENV as "development" | "production" | "test" || "development", 7 | })) -------------------------------------------------------------------------------- /src/infrastructure/ioc/sentry.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common" 2 | import { APP_INTERCEPTOR } from "@nestjs/core" 3 | 4 | import { SentryInterceptor } from "@/infrastructure/interceptors/sentry.interceptor" 5 | 6 | @Module({ 7 | imports: [], 8 | providers: [{ 9 | provide: APP_INTERCEPTOR, 10 | useClass: SentryInterceptor, 11 | }], 12 | }) 13 | export class SentryModule {} -------------------------------------------------------------------------------- /jest-e2e.config.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @type {import("ts-jest").JestConfigWithTsJest} 4 | */ 5 | module.exports = { 6 | preset: "jest-playwright-preset", 7 | testMatch: ["/tests/**/*.e2e.{ts,js}"], 8 | moduleNameMapper: { 9 | "^@/(.*)$": "/$1", 10 | }, 11 | transform: { 12 | "^.+\\.(t|j)sx?$": ["@swc/jest"] 13 | }, 14 | testTimeout: 30000, 15 | collectCoverage: false 16 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = tab 8 | indent_size = 4 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false 13 | 14 | [*.yml] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.md] 19 | trim_trailing_whitespace = true 20 | -------------------------------------------------------------------------------- /src/infrastructure/configs/jaeger.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from "@nestjs/config" 2 | 3 | export const jaegerConfig = registerAs("jaeger", () => ({ 4 | serviceName: process.env.JAEGER_SERVICE_NAME, 5 | sampler: { 6 | type: "const", 7 | param: 1, 8 | }, 9 | reporter: { 10 | logSpans: true, 11 | agentHost: process.env.JAEGER_AGENT_HOST, 12 | agentPort: Number(process.env.JAEGER_AGENT_PORT), 13 | }, 14 | })) -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id Int @id @default(autoincrement()) 15 | email String @unique 16 | name String? 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "**/*.spec.ts"], 4 | "include": ["src/**/*"], 5 | "compilerOptions": { 6 | "outDir": "dist", 7 | "noEmit": false, 8 | "sourceMap": false, 9 | "removeComments": true, 10 | "moduleResolution": "node", 11 | "esModuleInterop": true, 12 | "allowJs": false, 13 | "target": "es2020", 14 | "module": "commonjs", 15 | } 16 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("path") 2 | 3 | /** 4 | * @type {import("ts-jest").JestConfigWithTsJest} 5 | */ 6 | module.exports = { 7 | preset: "ts-jest/presets/js-with-ts", 8 | transform: { 9 | "^.+\\.(t|j)sx?$": ["@swc/jest"] 10 | }, 11 | moduleNameMapper: { 12 | "^@/(.*)$": resolve(__dirname, "$1"), 13 | }, 14 | testMatch: ["/**/*.{spec,test}.{ts,js}"], 15 | rootDir: "src", 16 | coverageDirectory: "../coverage", 17 | } -------------------------------------------------------------------------------- /src/entities/@shared/validator.base.ts: -------------------------------------------------------------------------------- 1 | import { ValidationHandler } from "@/entities/@shared/interfaces/validation-handler.interface" 2 | 3 | export abstract class Validator { 4 | 5 | private readonly _handler: ValidationHandler 6 | 7 | constructor(handler: ValidationHandler) { 8 | this._handler = handler 9 | } 10 | 11 | public get handler(): ValidationHandler { 12 | return this._handler 13 | } 14 | 15 | public abstract validate(): void 16 | } -------------------------------------------------------------------------------- /src/presentation/view-models/errors/application-error.view-model.ts: -------------------------------------------------------------------------------- 1 | import { BaseErrorViewModel } from "@/presentation/view-models/errors/base-error.view-model" 2 | import { Details } from "@/usecases/@shared/errors/application.error" 3 | 4 | export class ApplicationErrorViewModel extends BaseErrorViewModel { 5 | constructor( 6 | code = "application_error", 7 | message: string, 8 | readonly details?: Details, 9 | ) { super(code, message, "medium") } 10 | } -------------------------------------------------------------------------------- /src/entities/@shared/errors/domain.error.spec.ts: -------------------------------------------------------------------------------- 1 | import { DomainError } from "@/entities/@shared/errors/domain.error" 2 | 3 | describe("Domain error", () => { 4 | 5 | it("should be able to create a domain error", () => { 6 | const error = DomainError.from("field", "error") 7 | expect(error).toBeInstanceOf(DomainError) 8 | expect(error).toBeInstanceOf(Error) 9 | expect(error.field).toBe("field") 10 | expect(error.message).toBe("error") 11 | }) 12 | 13 | }) -------------------------------------------------------------------------------- /src/entities/@shared/errors/domain.error.ts: -------------------------------------------------------------------------------- 1 | export class DomainError extends Error { 2 | 3 | private readonly _field: string 4 | 5 | private constructor(field: string, message: string) { 6 | super(message) 7 | this.name = this.constructor.name 8 | this._field = field 9 | } 10 | 11 | static from(field: string, message: string): DomainError { 12 | return new DomainError(field, message) 13 | } 14 | 15 | public get field(): string { 16 | return this._field 17 | } 18 | } -------------------------------------------------------------------------------- /src/infrastructure/services/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Injectable, OnModuleInit } from "@nestjs/common" 2 | import { PrismaClient } from "@prisma/client" 3 | 4 | @Injectable() 5 | export class PrismaService extends PrismaClient implements OnModuleInit { 6 | 7 | async onModuleInit() { 8 | await this.$connect() 9 | } 10 | 11 | async enableShutdownHooks(app: INestApplication) { 12 | this.$on("beforeExit", async () => { 13 | await app.close() 14 | }) 15 | } 16 | } -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "sourceMaps": true, 4 | "module": { 5 | "type": "commonjs" 6 | }, 7 | "jsc": { 8 | "target": "es2017", 9 | "parser": { 10 | "syntax": "typescript", 11 | "decorators": true, 12 | "dynamicImport": true 13 | }, 14 | "transform": { 15 | "legacyDecorator": true, 16 | "decoratorMetadata": true 17 | }, 18 | "keepClassNames": true, 19 | "baseUrl": "./", 20 | "paths": { 21 | "@/*": ["src/*"] 22 | } 23 | }, 24 | "minify": false 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es6", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "experimentalDecorators": true, 9 | "emitDecoratorMetadata": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "declaration": true, 13 | "declarationDir": "dist/types", 14 | "outDir": "dist", 15 | "baseUrl": ".", 16 | "paths": { 17 | "@/*": ["src/*"] 18 | } 19 | }, 20 | "exclude": ["node_modules", "dist"] 21 | } -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common" 2 | 3 | import { CacheModule } from "@/infrastructure/ioc/cache.module" 4 | import { FiltersModule } from "@/infrastructure/ioc/filters.module" 5 | import { HealthModule } from "@/infrastructure/ioc/heath.module" 6 | import { JaegerModule } from "@/infrastructure/ioc/jaeger.module" 7 | import { SentryModule } from "@/infrastructure/ioc/sentry.module" 8 | 9 | @Module({ 10 | imports: [HealthModule, FiltersModule, JaegerModule, SentryModule, CacheModule] 11 | }) 12 | export class AppModule {} 13 | -------------------------------------------------------------------------------- /src/presentation/view-models/errors/notification-error.view-model.ts: -------------------------------------------------------------------------------- 1 | import { BaseErrorViewModel } from "@/presentation/view-models/errors/base-error.view-model" 2 | import { DomainErrorViewModel } from "@/presentation/view-models/errors/domain-error.view-model" 3 | 4 | export class NotificationErrorViewModel extends BaseErrorViewModel { 5 | constructor( 6 | readonly errors: DomainErrorViewModel[], 7 | message = "An error occurred while validating the submitted data", 8 | code = "validation_error", 9 | ) { super(code, message, "low") } 10 | } 11 | 12 | -------------------------------------------------------------------------------- /src/infrastructure/ioc/heath.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common" 2 | import { TerminusModule } from "@nestjs/terminus" 3 | 4 | import { PrismaHeathIndiciator } from "@/infrastructure/heath-indicators/prisma.heath-indicator" 5 | import { PrismaModule } from "@/infrastructure/ioc/prisma.module" 6 | import { HealthController } from "@/presentation/controllers/health.controller" 7 | 8 | @Module({ 9 | imports: [TerminusModule, PrismaModule], 10 | controllers: [HealthController], 11 | providers: [PrismaHeathIndiciator] 12 | }) 13 | export class HealthModule {} -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ### Application config 2 | APP_PORT=3000 3 | 4 | ### Jaeger config 5 | JAEGER_SERVICE_NAME=clean-arch 6 | JAEGER_AGENT_HOST=clean-arch-jaeger 7 | JAEGER_AGENT_PORT=6832 8 | 9 | ### Sentry config 10 | SENTRY_DSN= 11 | 12 | ### Database config 13 | DB_HOST=clean-arch-db 14 | DB_PORT=5432 15 | DB_USER=postgres 16 | DB_PASSWORD=postgres 17 | DB_NAME=postgres 18 | 19 | ### Connection Config 20 | DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=public" 21 | 22 | ### Redis 23 | REDIS_HOST=clean-arch-redis 24 | REDIS_PORT=6379 25 | REDIS_PASSWORD= 26 | REDIS_DB=0 27 | -------------------------------------------------------------------------------- /src/infrastructure/telemetry/jaeger.telemetry.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from "@nestjs/common" 2 | import { ConfigType } from "@nestjs/config" 3 | import { initTracer, JaegerTracer } from "jaeger-client" 4 | 5 | import { jaegerConfig } from "@/infrastructure/configs/jaeger.config" 6 | 7 | @Injectable() 8 | export class JaegerService { 9 | private tracer: JaegerTracer 10 | 11 | constructor( 12 | @Inject(jaegerConfig.KEY) 13 | private readonly config: ConfigType, 14 | ) { this.tracer = initTracer(this.config, {}) } 15 | 16 | getTracer(): JaegerTracer { 17 | return this.tracer 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/usecases/@shared/errors/application.error.ts: -------------------------------------------------------------------------------- 1 | export type Details = Record | string | string[] 2 | 3 | export class ApplicationError extends Error { 4 | private constructor( 5 | private readonly _code: string, 6 | message: string, 7 | private readonly _details?: Details, 8 | ) { super(message) } 9 | 10 | static from(code: string, message: string, details?: Details): ApplicationError { 11 | return new ApplicationError(code, message, details) 12 | } 13 | 14 | get code(): string { 15 | return this._code 16 | } 17 | 18 | get details(): Details | undefined { 19 | return this._details 20 | } 21 | } -------------------------------------------------------------------------------- /src/infrastructure/filters/error.filter.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionFilter, Catch, ArgumentsHost } from "@nestjs/common" 2 | import { Response } from "express" 3 | 4 | import { InternalServerError } from "@/presentation/view-models/errors/internal-server-error.view-model" 5 | 6 | @Catch(Error) 7 | export class ErrorFilter implements ExceptionFilter { 8 | catch(exception: Error, host: ArgumentsHost) { 9 | const ctx = host.switchToHttp() 10 | const response = ctx.getResponse() 11 | 12 | const viewModel = new InternalServerError(exception.message) 13 | 14 | response 15 | .status(500) 16 | .json(viewModel) 17 | } 18 | } -------------------------------------------------------------------------------- /src/presentation/controllers/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from "@nestjs/common" 2 | import { HealthCheckService, HealthCheck } from "@nestjs/terminus" 3 | 4 | import { PrismaHeathIndiciator } from "@/infrastructure/heath-indicators/prisma.heath-indicator" 5 | 6 | @Controller("health") 7 | export class HealthController { 8 | constructor( 9 | private readonly health: HealthCheckService, 10 | private readonly prismaIndicator: PrismaHeathIndiciator 11 | ) {} 12 | 13 | @Get() 14 | @HealthCheck() 15 | check() { 16 | return this.health.check([ 17 | () => this.prismaIndicator.isHealthy("prisma") 18 | ]) 19 | } 20 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | # Criar usuário com permissões limitadas 4 | RUN addgroup -S appgroup && adduser -S app -G appgroup 5 | 6 | # Definir diretório de trabalho e copiar arquivos para a imagem 7 | WORKDIR /usr/src/app 8 | COPY package*.json ./ 9 | RUN npm install 10 | COPY . . 11 | 12 | # Gerar arquivos do Prisma 13 | RUN npx prisma generate 14 | 15 | # Buildar a aplicação 16 | RUN npm run build:swc 17 | 18 | # Mudar a propriedade dos arquivos para o usuário criado 19 | RUN chown -R app:appgroup /usr/src/app 20 | USER app 21 | 22 | # Expor a porta da aplicação 23 | EXPOSE 3000 24 | 25 | # Comando para rodar a aplicação 26 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /src/infrastructure/heath-indicators/prisma.heath-indicator.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common" 2 | import { HealthCheckError, HealthIndicator, HealthIndicatorResult } from "@nestjs/terminus" 3 | 4 | import { PrismaService } from "@/infrastructure/services/prisma.service" 5 | 6 | @Injectable() 7 | export class PrismaHeathIndiciator extends HealthIndicator { 8 | 9 | constructor( 10 | private readonly prisma: PrismaService 11 | ) { super() } 12 | 13 | async isHealthy(key: string): Promise { 14 | try { 15 | await this.prisma.$queryRaw`SELECT 1` 16 | return this.getStatus(key, true) 17 | } catch (error) { 18 | throw new HealthCheckError("Prisma is not healthy", this.getStatus(key, false, { error })) 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/entities/@shared/utils/append-yup-errors-in-validation-handler.util.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup" 2 | 3 | import { DomainError } from "@/entities/@shared/errors/domain.error" 4 | import { ValidationHandler } from "@/entities/@shared/interfaces/validation-handler.interface" 5 | 6 | export function appendYupErrorsInValidationHandler( 7 | handler: ValidationHandler, 8 | schema: yup.ObjectSchema, 9 | data: T 10 | ) { 11 | try { 12 | schema.validateSync(data, { abortEarly: false, }) 13 | } catch (error) { 14 | const yupValidationErrors = error as yup.ValidationError 15 | yupValidationErrors.inner.forEach((yupValidationError) => { 16 | handler.appendError(DomainError.from(yupValidationError.path ?? "", yupValidationError.message)) 17 | }) 18 | } 19 | } -------------------------------------------------------------------------------- /src/infrastructure/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from "@nestjs/common" 2 | import { Response } from "express" 3 | 4 | import { BaseErrorViewModel } from "@/presentation/view-models/errors/base-error.view-model" 5 | 6 | @Catch(HttpException) 7 | export class HttpExceptionFilter implements ExceptionFilter { 8 | catch(exception: HttpException, host: ArgumentsHost) { 9 | const ctx = host.switchToHttp() 10 | const response = ctx.getResponse() 11 | 12 | const viewModel = new BaseErrorViewModel( 13 | "generic_error", 14 | exception.message, 15 | exception.getStatus() >= 500 ? "high" : "medium", 16 | ) 17 | 18 | response 19 | .status(exception.getStatus()) 20 | .json(viewModel) 21 | } 22 | } -------------------------------------------------------------------------------- /src/infrastructure/ioc/jaeger.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common" 2 | import { ConfigModule } from "@nestjs/config" 3 | import { APP_INTERCEPTOR } from "@nestjs/core" 4 | 5 | import { applicationConfig } from "@/infrastructure/configs/application.config" 6 | import { jaegerConfig } from "@/infrastructure/configs/jaeger.config" 7 | import { JaegerInterceptor } from "@/infrastructure/interceptors/jaeger.interceptor" 8 | import { JaegerService } from "@/infrastructure/telemetry/jaeger.telemetry" 9 | 10 | @Module({ 11 | imports: [ConfigModule.forFeature(jaegerConfig), ConfigModule.forFeature(applicationConfig)], 12 | providers: [JaegerService, { 13 | provide: APP_INTERCEPTOR, 14 | useClass: JaegerInterceptor, 15 | }], 16 | exports: [JaegerService], 17 | }) 18 | export class JaegerModule {} -------------------------------------------------------------------------------- /src/infrastructure/filters/application-error.filter.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionFilter, Catch, ArgumentsHost } from "@nestjs/common" 2 | import { Response } from "express" 3 | 4 | import { ApplicationErrorViewModel } from "@/presentation/view-models/errors/application-error.view-model" 5 | import { ApplicationError } from "@/usecases/@shared/errors/application.error" 6 | 7 | @Catch(ApplicationError) 8 | export class ApplicationErrorFilter implements ExceptionFilter { 9 | catch(exception: ApplicationError, host: ArgumentsHost) { 10 | const ctx = host.switchToHttp() 11 | const response = ctx.getResponse() 12 | 13 | const viewModel = new ApplicationErrorViewModel( 14 | exception.code, 15 | exception.message, 16 | exception.details, 17 | ) 18 | 19 | response 20 | .status(400) 21 | .json(viewModel) 22 | } 23 | } -------------------------------------------------------------------------------- /src/entities/@shared/errors/notification.error.ts: -------------------------------------------------------------------------------- 1 | import { DomainError } from "@/entities/@shared/errors/domain.error" 2 | import { Notification } from "@/entities/@shared/notification.validation-handler" 3 | 4 | export class NotificationError extends Error { 5 | 6 | private readonly _errors: DomainError[] 7 | 8 | private constructor(message: string, errors: DomainError[]) { 9 | super(message) 10 | this._errors = errors 11 | } 12 | 13 | static from(notification: Notification): NotificationError { 14 | return new NotificationError("domain errors", notification.errors) 15 | } 16 | 17 | static fromWithMessage(message: string, notification: Notification): NotificationError { 18 | return new NotificationError(message, notification.errors) 19 | } 20 | 21 | public get errors(): DomainError[] { 22 | return this._errors 23 | } 24 | } -------------------------------------------------------------------------------- /src/infrastructure/ioc/cache.module.ts: -------------------------------------------------------------------------------- 1 | import { CacheInterceptor, CacheModule as NestjsCacheModule, Module } from "@nestjs/common" 2 | import { ConfigModule, ConfigType } from "@nestjs/config" 3 | import { APP_INTERCEPTOR } from "@nestjs/core" 4 | import { redisStore } from "cache-manager-redis-yet" 5 | import { RedisClientOptions } from "redis" 6 | 7 | import { redisConfig } from "@/infrastructure/configs/redis.config" 8 | 9 | @Module({ 10 | imports: [ 11 | NestjsCacheModule.registerAsync({ 12 | imports: [ConfigModule.forFeature(redisConfig)], 13 | inject: [redisConfig.KEY], 14 | useFactory: async (config: ConfigType) => ({ 15 | store: redisStore, 16 | ...config 17 | }) 18 | }) 19 | ], 20 | providers: [{ 21 | provide: APP_INTERCEPTOR, 22 | useClass: CacheInterceptor 23 | }] 24 | }) 25 | export class CacheModule {} -------------------------------------------------------------------------------- /src/entities/@shared/utils/append-yup-errors-in-validation-handler.util.spec.ts: -------------------------------------------------------------------------------- 1 | import { object, string } from "yup" 2 | 3 | import { DomainError } from "@/entities/@shared/errors/domain.error" 4 | import { Notification } from "@/entities/@shared/notification.validation-handler" 5 | import { appendYupErrorsInValidationHandler } from "@/entities/@shared/utils/append-yup-errors-in-validation-handler.util" 6 | 7 | describe("Append yup errors in validation handler util", () => { 8 | 9 | it("should be able to append yup errors in validation handler", () => { 10 | const schema = object().shape({ 11 | field1: string().required("error1"), 12 | }) 13 | 14 | const notification = Notification.empty() 15 | 16 | appendYupErrorsInValidationHandler(notification, schema, { field1: "" }) 17 | 18 | expect(notification.errors).toContainEqual(DomainError.from("field1", "error1")) 19 | }) 20 | 21 | }) -------------------------------------------------------------------------------- /src/infrastructure/ioc/filters.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common" 2 | import { APP_FILTER } from "@nestjs/core" 3 | 4 | import { ApplicationErrorFilter } from "@/infrastructure/filters/application-error.filter" 5 | import { ErrorFilter } from "@/infrastructure/filters/error.filter" 6 | import { HttpExceptionFilter } from "@/infrastructure/filters/http-exception.filter" 7 | import { NotificationErrorFilter } from "@/infrastructure/filters/notification-error.filter" 8 | import { ErrorsController } from "@/presentation/controllers/errors.controller" 9 | 10 | @Module({ 11 | controllers: [ErrorsController], 12 | providers: [ 13 | { provide: APP_FILTER, useClass: ErrorFilter }, 14 | { provide: APP_FILTER, useClass: NotificationErrorFilter }, 15 | { provide: APP_FILTER, useClass: ApplicationErrorFilter }, 16 | { provide: APP_FILTER, useClass: HttpExceptionFilter } 17 | ], 18 | }) 19 | export class FiltersModule {} -------------------------------------------------------------------------------- /src/entities/@shared/entity.base.ts: -------------------------------------------------------------------------------- 1 | export class Entity { 2 | 3 | protected readonly _id: Identifier 4 | 5 | protected readonly _createdAt: Date = new Date() 6 | 7 | protected _updatedAt: Date = new Date() 8 | 9 | constructor(id: Identifier, createdAt?: Date, updatedAt?: Date) { 10 | this._id = id 11 | this._createdAt = createdAt || new Date() 12 | this._updatedAt = updatedAt || new Date() 13 | } 14 | 15 | public get id(): Identifier { 16 | return this._id 17 | } 18 | 19 | public get createdAt(): Date { 20 | return this._createdAt 21 | } 22 | 23 | public get updatedAt(): Date { 24 | return this._updatedAt 25 | } 26 | 27 | public equals(entity: Entity): boolean { 28 | if (entity === null || entity === undefined) { 29 | return false 30 | } 31 | if (this === entity) { 32 | return true 33 | } 34 | if (this.id !== entity.id) { 35 | return false 36 | } 37 | return true 38 | } 39 | } -------------------------------------------------------------------------------- /src/entities/@shared/errors/notification.error.spec.ts: -------------------------------------------------------------------------------- 1 | import { NotificationError } from "@/entities/@shared/errors/notification.error" 2 | import { Notification } from "@/entities/@shared/notification.validation-handler" 3 | 4 | describe("Notification error", () => { 5 | 6 | it("should be able to create a notification error", () => { 7 | const notification = Notification.empty() 8 | const error = NotificationError.from(notification) 9 | expect(error).toBeInstanceOf(NotificationError) 10 | expect(error).toBeInstanceOf(Error) 11 | expect(error.errors).toEqual([]) 12 | }) 13 | 14 | it("should be able to create a notification error with message", () => { 15 | const notification = Notification.empty() 16 | const error = NotificationError.fromWithMessage("message", notification) 17 | expect(error).toBeInstanceOf(NotificationError) 18 | expect(error).toBeInstanceOf(Error) 19 | expect(error.message).toBe("message") 20 | expect(error.errors).toEqual([]) 21 | }) 22 | 23 | }) -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe, VersioningType } from "@nestjs/common" 2 | import { NestFactory } from "@nestjs/core" 3 | import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger" 4 | import * as Sentry from "@sentry/node" 5 | 6 | import { AppModule } from "./app.module" 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(AppModule) 10 | app.useGlobalPipes(new ValidationPipe({ 11 | transform: true, 12 | whitelist: true 13 | })) 14 | app.enableVersioning({ 15 | defaultVersion: "1", 16 | type: VersioningType.URI, 17 | prefix: "v" 18 | }) 19 | const config = new DocumentBuilder() 20 | .setTitle("Cats API") 21 | .setDescription("The cats API description") 22 | .setVersion("1.0") 23 | .addTag("cats") 24 | .build() 25 | const document = SwaggerModule.createDocument(app, config) 26 | SwaggerModule.setup("api", app, document) 27 | Sentry.init({ 28 | dsn: process.env.SENTRY_DSN, 29 | }) 30 | await app.listen(3000) 31 | } 32 | 33 | bootstrap() -------------------------------------------------------------------------------- /src/presentation/controllers/errors.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from "@nestjs/common" 2 | 3 | import { DomainError } from "@/entities/@shared/errors/domain.error" 4 | import { NotificationError } from "@/entities/@shared/errors/notification.error" 5 | import { Notification } from "@/entities/@shared/notification.validation-handler" 6 | import { ApplicationError } from "@/usecases/@shared/errors/application.error" 7 | 8 | @Controller("errors") 9 | export class ErrorsController { 10 | 11 | @Get("internal-server-error") 12 | internalServerError() { 13 | throw new Error("internal server error") 14 | } 15 | 16 | @Get("validation-error") 17 | validationError() { 18 | throw NotificationError.fromWithMessage( 19 | "validation error", 20 | Notification.fromErrors([DomainError.from("name", "name is required")]), 21 | ) 22 | } 23 | 24 | @Get("application-error") 25 | applicationError() { 26 | throw ApplicationError.from("application_error", "application error", "Error") 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /src/infrastructure/interceptors/sentry.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from "@nestjs/common" 2 | import * as Sentry from "@sentry/node" 3 | import { tap } from "rxjs" 4 | 5 | import { DomainError } from "@/entities/@shared/errors/domain.error" 6 | import { NotificationError } from "@/entities/@shared/errors/notification.error" 7 | import { ApplicationError } from "@/usecases/@shared/errors/application.error" 8 | 9 | @Injectable() 10 | export class SentryInterceptor implements NestInterceptor { 11 | intercept(_: ExecutionContext, next: CallHandler) { 12 | return next 13 | .handle() 14 | .pipe( 15 | tap({ 16 | error: (err) => { 17 | switch (err) { 18 | case err instanceof DomainError: 19 | break 20 | case err instanceof NotificationError: 21 | break 22 | case err instanceof ApplicationError: 23 | break 24 | default: 25 | Sentry.captureException(err) 26 | } 27 | }, 28 | }), 29 | ) 30 | } 31 | } -------------------------------------------------------------------------------- /src/infrastructure/filters/notification-error.filter.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionFilter, Catch, ArgumentsHost } from "@nestjs/common" 2 | import { Response } from "express" 3 | 4 | import { NotificationError } from "@/entities/@shared/errors/notification.error" 5 | import { DomainErrorViewModel } from "@/presentation/view-models/errors/domain-error.view-model" 6 | import { NotificationErrorViewModel } from "@/presentation/view-models/errors/notification-error.view-model" 7 | 8 | @Catch(NotificationError) 9 | export class NotificationErrorFilter implements ExceptionFilter { 10 | catch(exception: NotificationError, host: ArgumentsHost) { 11 | const ctx = host.switchToHttp() 12 | const response = ctx.getResponse() 13 | 14 | const errorsViewModel = exception.errors.map((error) => { 15 | return new DomainErrorViewModel(error.field, error.message) 16 | }) 17 | 18 | const viewModel = new NotificationErrorViewModel( 19 | errorsViewModel, 20 | exception.message, 21 | ) 22 | 23 | response 24 | .status(433) 25 | .json(viewModel) 26 | } 27 | } -------------------------------------------------------------------------------- /src/entities/@shared/notification.validation-handler.ts: -------------------------------------------------------------------------------- 1 | import { DomainError } from "@/entities/@shared/errors/domain.error" 2 | import { ValidationHandler } from "@/entities/@shared/interfaces/validation-handler.interface" 3 | 4 | export class Notification implements ValidationHandler { 5 | 6 | private _errors: DomainError[] = [] 7 | 8 | private constructor(initialErrors: DomainError[] | null | undefined = []) { 9 | this._errors = initialErrors ?? [] 10 | } 11 | 12 | static empty(): Notification { 13 | return new Notification() 14 | } 15 | 16 | static fromErrors(errors: DomainError[]): Notification { 17 | return new Notification(errors) 18 | } 19 | 20 | get errors(): DomainError[] { 21 | return this._errors 22 | } 23 | 24 | hasErrors(): boolean { 25 | return this._errors.length > 0 26 | } 27 | 28 | appendError(error: DomainError): void { 29 | this._errors.push(error) 30 | } 31 | 32 | appendErrors(errors: DomainError[]): void { 33 | errors.forEach(error => this.appendError(error)) 34 | } 35 | 36 | clearErrors(): void { 37 | this._errors = [] 38 | } 39 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "overrides": [ 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "ignorePatterns": ["node_modules", "dist", "*.js"], 14 | "parserOptions": { 15 | "ecmaVersion": "latest", 16 | "sourceType": "module" 17 | }, 18 | "plugins": [ 19 | "@typescript-eslint", 20 | "import", 21 | "check-file" 22 | ], 23 | "rules": { 24 | "indent": [ 25 | "error", 26 | "tab" 27 | ], 28 | "linebreak-style": [ 29 | "error", 30 | "unix" 31 | ], 32 | "quotes": [ 33 | "error", 34 | "double" 35 | ], 36 | "semi": [ 37 | "error", 38 | "never" 39 | ], 40 | "import/order": ["error", { 41 | "groups": [ 42 | "builtin", 43 | "external", 44 | "internal", 45 | "parent", 46 | "sibling", 47 | "index" 48 | ], 49 | "newlines-between": "always", 50 | "alphabetize": { 51 | "order": "asc", 52 | "caseInsensitive": true 53 | } 54 | }], 55 | "check-file/filename-naming-convention": ["error", { 56 | "**/*.{ts,tsx}": "KEBAB_CASE" 57 | },{ "ignoreMiddleExtensions": true }] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | 5 | app: 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | container_name: clean-arch-app 10 | env_file: 11 | - .env 12 | ports: 13 | - "${HTTP_PORT}:3000" 14 | volumes: 15 | - .:/app 16 | depends_on: 17 | - db 18 | networks: 19 | - default 20 | 21 | db: 22 | image: postgres:15.2 23 | container_name: clean-arch-db 24 | ports: 25 | - "5432:5432" 26 | env_file: 27 | - .env 28 | environment: 29 | POSTGRES_USER: ${DB_USER} 30 | POSTGRES_PASSWORD: ${DB_PASSWORD} 31 | POSTGRES_DB: ${DB_NAME} 32 | volumes: 33 | - ./.docker/volumes/db_data:/var/lib/postgresql/data 34 | networks: 35 | - default 36 | 37 | redis: 38 | image: redis:7.0.10 39 | container_name: clean-arch-redis 40 | ports: 41 | - "6379:6379" 42 | volumes: 43 | - ./.docker/volumes/redis_data:/data 44 | networks: 45 | - default 46 | 47 | jaeger: 48 | image: jaegertracing/all-in-one:latest 49 | container_name: clean-arch-jaeger 50 | ports: 51 | - "5775:5775/udp" 52 | - "6831:6831/udp" 53 | - "6832:6832/udp" 54 | - "5778:5778" 55 | - "16686:16686" 56 | - "14268:14268" 57 | - "9411:9411" 58 | volumes: 59 | - ./.docker/volumes/jaeger_data:/var/lib/jaeger 60 | networks: 61 | - default 62 | 63 | networks: 64 | default: 65 | driver: bridge 66 | -------------------------------------------------------------------------------- /src/entities/@shared/entity.validator.ts: -------------------------------------------------------------------------------- 1 | import { isBefore, isEqual } from "date-fns" 2 | import * as yup from "yup" 3 | 4 | import { Entity } from "@/entities/@shared/entity.base" 5 | import { ValidationHandler } from "@/entities/@shared/interfaces/validation-handler.interface" 6 | import { appendYupErrorsInValidationHandler } from "@/entities/@shared/utils/append-yup-errors-in-validation-handler.util" 7 | import { Validator } from "@/entities/@shared/validator.base" 8 | 9 | export class EntityValidator extends Validator { 10 | 11 | private readonly _entity: Entity 12 | 13 | constructor(handler: ValidationHandler, entity: Entity) { 14 | super(handler) 15 | this._entity = entity 16 | } 17 | 18 | public validate(): void { 19 | const schema = yup.object().shape({ 20 | id: yup.mixed().required("the id is required"), 21 | createdAt: yup.date().required("the created date is required") 22 | .max(new Date(), "the created date must be before the current date"), 23 | updatedAt: yup.date().required("the updated date is required") 24 | .max(new Date(), "the updated date must be before the current date") 25 | .test("isBefore", "the updated date must be after the created date", (value: Date) => { 26 | if(isEqual(value, this._entity.createdAt)) { 27 | return true 28 | } 29 | return isBefore(value, this._entity.createdAt) 30 | }) 31 | }) 32 | 33 | appendYupErrorsInValidationHandler(this.handler, schema, { 34 | id: this._entity.id, 35 | createdAt: this._entity.createdAt, 36 | updatedAt: this._entity.updatedAt 37 | }) 38 | } 39 | } -------------------------------------------------------------------------------- /docs/FILES_AND_DIRS.md: -------------------------------------------------------------------------------- 1 | # Padrões de Estilização de Pastas e Arquivos 2 | 3 | ## Nomenclatura 4 | 5 | - Use letras minúsculas para nomear pastas e arquivos, separando as palavras com hífens (`-`); 6 | - Evite o uso de caracteres especiais, acentos ou espaços nos nomes de pastas e arquivos; 7 | - Use nomes descritivos e autoexplicativos para pastas e arquivos. 8 | 9 | Exemplo: `src/api/controllers/user-controller.ts` 10 | 11 | ## Estrutura de Pastas 12 | 13 | - Utilize uma estrutura de pastas que reflita a arquitetura do sistema, seguindo os princípios da Arquitetura Limpa ou outras arquiteturas similares; 14 | - Organize as pastas de acordo com a responsabilidade de cada módulo ou componente do sistema; 15 | - Utilize pastas compartilhadas para arquivos ou funcionalidades que possam ser reutilizados em diferentes partes do sistema. 16 | 17 | Exemplo: 18 | 19 | ``` bash 20 | src/ 21 | ├── api/ 22 | │ ├── controllers/ 23 | │ │ └── user-controller.ts 24 | │ ├── middlewares/ 25 | │ └── routes/ 26 | │ └── user-routes.ts 27 | ├── config/ 28 | ├── domain/ 29 | │ ├── entities/ 30 | │ ├── repositories/ 31 | │ └── use-cases/ 32 | ├── infrastructure/ 33 | ├── interfaces/ 34 | └── shared/ 35 | ├── errors/ 36 | ├── helpers/ 37 | ├── services/ 38 | └── validators/ 39 | ``` 40 | 41 | ## Padronização de Arquivos 42 | 43 | - Utilize sufixos para identificar o tipo de arquivo, como `.ts` para arquivos TypeScript, `.spec.ts` para arquivos de teste unitário, `.test.ts` para arquivos de teste de integração, etc.; 44 | - Utilize prefixos para identificar a ordem de execução dos arquivos, como `001-` para o primeiro arquivo a ser executado, `002-` para o segundo, e assim por diante; 45 | - Evite utilizar arquivos com nomes genéricos, como `index.ts`, a menos que seja necessário. 46 | 47 | Exemplo: `src/api/controllers/user-controller.ts` 48 | -------------------------------------------------------------------------------- /src/entities/@shared/notification.validation-handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { DomainError } from "@/entities/@shared/errors/domain.error" 2 | import { Notification } from "@/entities/@shared/notification.validation-handler" 3 | 4 | describe("notification validation handler", () => { 5 | 6 | it("should be able create a new notification", () => { 7 | const notification = Notification.empty() 8 | 9 | expect(notification.errors).toEqual([]) 10 | expect(notification.hasErrors()).toBeFalsy() 11 | }) 12 | 13 | it("should be able create a new notification with initial errors", () => { 14 | const expectedErrors = [DomainError.from("field1", "error1"), DomainError.from("field2", "error2")] 15 | const notification = Notification.fromErrors(expectedErrors) 16 | 17 | expect(notification.errors).toEqual(expectedErrors) 18 | expect(notification.hasErrors()).toBeTruthy() 19 | }) 20 | 21 | it("should be able append a new error", () => { 22 | const expectedError = DomainError.from("field1", "error1") 23 | const notification = Notification.empty() 24 | 25 | notification.appendError(expectedError) 26 | 27 | expect(notification.errors).toEqual([expectedError]) 28 | expect(notification.hasErrors()).toBeTruthy() 29 | }) 30 | 31 | it("should be able append a new errors", () => { 32 | const expectedErrors = [DomainError.from("field1", "error1"), DomainError.from("field2", "error2")] 33 | const notification = Notification.empty() 34 | 35 | notification.appendErrors(expectedErrors) 36 | 37 | expect(notification.errors).toEqual(expectedErrors) 38 | expect(notification.hasErrors()).toBeTruthy() 39 | }) 40 | 41 | it("should be able clear errors", () => { 42 | const notification = Notification.empty() 43 | 44 | notification.appendErrors([DomainError.from("field1", "error1"), DomainError.from("field2", "error2")]) 45 | notification.clearErrors() 46 | 47 | expect(notification.errors).toEqual([]) 48 | expect(notification.hasErrors()).toBeFalsy() 49 | }) 50 | 51 | }) -------------------------------------------------------------------------------- /docs/COMMIT.md: -------------------------------------------------------------------------------- 1 | # Padronização de Commits 2 | 3 | Este documento define as convenções para mensagens de commit para este projeto. 4 | 5 | ## Cabeçalho 6 | 7 | O cabeçalho é obrigatório e deve ser estruturado da seguinte forma: 8 | 9 | ``` bash 10 | (): 11 | ``` 12 | 13 | O tipo e a descrição curta são obrigatórios. O escopo é opcional, mas altamente recomendado. 14 | 15 | ## Tipo 16 | 17 | O tipo é uma palavra-chave que descreve o tipo de mudança: 18 | 19 | **feat:** nova funcionalidade 20 | **fix:** correção de um bug 21 | **docs:** alteração na documentação 22 | **style:** mudanças que não afetam o significado do código (espaços em branco, formatação, ponto e vírgula ausente, etc) 23 | **refactor:** uma mudança no código que não corrige um bug e não adiciona uma nova funcionalidade 24 | **test:** adição de testes faltantes ou correção de testes existentes 25 | **chore:** mudanças no processo de build, ferramentas auxiliares, bibliotecas, etc. 26 | **perf:** uma mudança de código que melhora o desempenho 27 | 28 | ## Escopo (opcional) 29 | 30 | O escopo é opcional e pode ser usado para identificar a parte do código que está sendo modificada. 31 | 32 | ## Descrição Curta 33 | 34 | A descrição curta é uma frase curta que descreve a mudança. Ela deve ser escrita no tempo presente e não deve exceder 50 caracteres. 35 | 36 | ## Corpo 37 | 38 | O corpo é opcional, mas altamente recomendado. Ele deve fornecer uma descrição mais detalhada da mudança e pode ser dividido em várias linhas. 39 | 40 | ## Rodapé 41 | 42 | O rodapé é opcional e pode ser usado para referenciar problemas relacionados ou outras informações relevantes. Ele deve ser precedido por uma linha em branco e deve ser dividido em várias linhas. 43 | 44 | ## Exemplos 45 | 46 | ``` bash 47 | feat: add new feature X 48 | ``` 49 | 50 | ``` bash 51 | fix: resolve issue with Y 52 | ``` 53 | 54 | ``` bash 55 | docs: update documentation for Z 56 | ``` 57 | 58 | ``` bash 59 | refactor: change implementation of X 60 | ``` 61 | 62 | ``` bash 63 | test: add new test for Y 64 | ``` 65 | 66 | ``` bash 67 | chore: update dependencies 68 | ``` 69 | 70 | ``` bash 71 | style: update formatting in X file 72 | ``` 73 | 74 | ``` bash 75 | perf: improve performance of X 76 | ``` -------------------------------------------------------------------------------- /src/entities/@shared/entity.base.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { Entity } from "@/entities/@shared/entity.base" 3 | import { DomainError } from "@/entities/@shared/errors/domain.error" 4 | import { NotificationError } from "@/entities/@shared/errors/notification.error" 5 | import { Identifier } from "@/entities/@shared/identifier.base" 6 | 7 | class EntityId extends Identifier { 8 | private readonly _value: string 9 | 10 | constructor(value: string) { 11 | super() 12 | this._value = value 13 | } 14 | 15 | get value(): string { 16 | return this._value 17 | } 18 | } 19 | 20 | describe("Entity", () => { 21 | 22 | it("should be able create a new entity", () => { 23 | const idExpected = new EntityId("id") 24 | const createdAtExpected = new Date() 25 | const updatedAtExpected = new Date() 26 | 27 | const entity = new Entity(idExpected, createdAtExpected, updatedAtExpected) 28 | 29 | expect(entity.id).toBe(idExpected) 30 | expect(entity.createdAt).toBe(createdAtExpected) 31 | expect(entity.updatedAt).toBe(updatedAtExpected) 32 | }) 33 | 34 | it("should be able create a new entity with default values", () => { 35 | const idExpected = new EntityId("id") 36 | 37 | const entity = new Entity(idExpected) 38 | 39 | expect(entity.id).toBe(idExpected) 40 | expect(entity.createdAt).toBeInstanceOf(Date) 41 | expect(entity.updatedAt).toBeInstanceOf(Date) 42 | }) 43 | 44 | it("should be able compare two entities equals", () => { 45 | const entity1 = new Entity("id") 46 | const entity2 = entity1 47 | 48 | expect(entity1.equals(entity2)).toBeTruthy() 49 | }) 50 | 51 | it("should be able compare two entities with same id", () => { 52 | const entity1 = new Entity("id") 53 | const entity2 = new Entity("id") 54 | 55 | expect(entity1.equals(entity2)).toBeTruthy() 56 | }) 57 | 58 | it("should not be able compare two entities with different id", () => { 59 | const entity1 = new Entity("id") 60 | const entity2 = new Entity("id2") 61 | 62 | expect(entity1.equals(entity2)).toBeFalsy() 63 | }) 64 | 65 | it("should not be able compare two entities with null or undefined value", () => { 66 | const entity = new Entity("Id") 67 | 68 | // @ts-ignore 69 | expect(entity.equals(null)).toBeFalsy() 70 | 71 | // @ts-ignore 72 | expect(entity.equals(undefined)).toBeFalsy() 73 | }) 74 | 75 | }) -------------------------------------------------------------------------------- /src/infrastructure/interceptors/jaeger.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from "crypto" 2 | 3 | import { 4 | CallHandler, 5 | ExecutionContext, 6 | Inject, 7 | Injectable, 8 | NestInterceptor, 9 | } from "@nestjs/common" 10 | import { ConfigType } from "@nestjs/config" 11 | import { Request, Response } from "express" 12 | import { opentracing } from "jaeger-client" 13 | import { tap } from "rxjs/operators" 14 | 15 | import { applicationConfig } from "@/infrastructure/configs/application.config" 16 | import { JaegerService } from "@/infrastructure/telemetry/jaeger.telemetry" 17 | 18 | @Injectable() 19 | export class JaegerInterceptor implements NestInterceptor { 20 | constructor( 21 | private readonly jaegerService: JaegerService, 22 | @Inject(applicationConfig.KEY) 23 | private readonly appConfig: ConfigType, 24 | ) {} 25 | 26 | intercept(context: ExecutionContext, next: CallHandler) { 27 | const requestId = randomUUID() 28 | 29 | const request = context.switchToHttp().getRequest() 30 | const response = context.switchToHttp().getResponse() 31 | 32 | const spanContext = this.jaegerService 33 | .getTracer() 34 | .extract(opentracing.FORMAT_HTTP_HEADERS, request.headers) 35 | 36 | const span = this.jaegerService.getTracer().startSpan(request.path, { childOf: spanContext || undefined }) 37 | span.log({ event: "request_received" }) 38 | span.setTag("http.method", request.method) 39 | span.setTag("http.url", request.path) 40 | span.setTag("http.content_type", request.headers["content-type"]) 41 | span.setTag("http.params", request.params) 42 | span.setTag("http.query", request.query) 43 | span.setTag("http.user_agent", request.headers["user-agent"]) 44 | span.setTag("http.hostname", request.hostname) 45 | span.setTag("http.ip", request.ip) 46 | span.setTag("http.http_version", request.httpVersion) 47 | span.setTag("http.original_url", request.originalUrl) 48 | span.setTag("http.protocol", request.protocol) 49 | span.setTag("http.secure", request.secure) 50 | span.setTag("http.subdomains", request.subdomains) 51 | span.setTag("http.request_id", requestId) 52 | 53 | if(this.appConfig.environment === "development") { 54 | span.setTag("http.body", request.body) 55 | span.setTag("http.cookies", request.cookies) 56 | span.setTag("http.headers", request.headers) 57 | } 58 | 59 | return next.handle().pipe( 60 | tap(() => { 61 | span.log({ event: "request_finished" }) 62 | span.setTag("http.response.content_type", response.get("content-type")) 63 | span.setTag("http.response.status_code", response.statusCode) 64 | span.setTag("http.response.headers", response.getHeaders()) 65 | 66 | response.setHeader("X-Request-Id", requestId) 67 | 68 | span.finish() 69 | }), 70 | ) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clean-architecture-with-typescript", 3 | "version": "1.0.0", 4 | "description": "Template for a clean architecture project with typescript", 5 | "keywords": [ 6 | "clean", 7 | "architecture", 8 | "typescript", 9 | "node" 10 | ], 11 | "author": { 12 | "name": "Arthur Reis", 13 | "email": "arthurreis074@gmail.com", 14 | "url": "https://arthurreis.dev" 15 | }, 16 | "license": "MIT", 17 | "scripts": { 18 | "husky:install": "husky install", 19 | "start": "npm run prisma:migrate && node dist/main.js", 20 | "start:dev": "nest start --watch", 21 | "build:swc:watch": "npx swc --out-dir dist -w src", 22 | "build:swc": "npx swc --out-dir dist src", 23 | "start:swc": "nodemon dist/main", 24 | "test": "jest", 25 | "test:watch": "jest --watch", 26 | "test:cov": "jest --coverage", 27 | "test:unit": "jest --testPathPattern=\".spec.ts$\"", 28 | "test:integration": "jest --testPathPattern=\".test.ts$\"", 29 | "test:e2e": "jest --config ./jest-e2e.config.js", 30 | "lint": "eslint 'src/**/*.ts'", 31 | "lint:fix": "eslint 'src/**/*.ts' --fix", 32 | "prisma:generate": "prisma generate", 33 | "prisma:migrate": "prisma migrate deploy --preview-feature" 34 | }, 35 | "devDependencies": { 36 | "@commitlint/cli": "^17.4.4", 37 | "@commitlint/config-conventional": "^17.4.4", 38 | "@prisma/migrate": "^4.11.0", 39 | "@swc/cli": "^0.1.62", 40 | "@swc/core": "^1.3.42", 41 | "@swc/jest": "^0.2.24", 42 | "@types/express": "^4.17.17", 43 | "@types/jaeger-client": "^3.18.4", 44 | "@types/jest": "^29.4.0", 45 | "@typescript-eslint/eslint-plugin": "^5.54.1", 46 | "@typescript-eslint/parser": "^5.54.1", 47 | "eslint": "^8.35.0", 48 | "eslint-plugin-check-file": "^2.1.0", 49 | "eslint-plugin-import": "^2.27.5", 50 | "husky": "^8.0.3", 51 | "jest": "^29.5.0", 52 | "nodemon": "^2.0.22", 53 | "prisma": "^4.11.0", 54 | "typescript": "^4.9.5" 55 | }, 56 | "dependencies": { 57 | "@nestjs/common": "^9.3.12", 58 | "@nestjs/config": "^2.3.1", 59 | "@nestjs/core": "^9.3.12", 60 | "@nestjs/platform-express": "^9.3.12", 61 | "@nestjs/swagger": "^6.2.1", 62 | "@nestjs/terminus": "^9.2.1", 63 | "@prisma/client": "^4.11.0", 64 | "@sentry/node": "^7.45.0", 65 | "cache-manager": "^5.2.0", 66 | "cache-manager-redis-yet": "^4.1.1", 67 | "class-transformer": "^0.5.1", 68 | "class-validator": "^0.14.0", 69 | "date-fns": "^2.29.3", 70 | "jaeger-client": "^3.19.0", 71 | "purify-ts": "^2.0.0", 72 | "redis": "^4.6.5", 73 | "reflect-metadata": "^0.1.13", 74 | "rxjs": "^7.8.0", 75 | "ts-jest": "^29.0.5", 76 | "yup": "^1.0.2" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,linux,node 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,linux,node 4 | 5 | ### Linux ### 6 | *~ 7 | 8 | # temporary files which can be created if a process still has a handle open of a deleted file 9 | .fuse_hidden* 10 | 11 | # KDE directory preferences 12 | .directory 13 | 14 | # Linux trash folder which might appear on any partition or disk 15 | .Trash-* 16 | 17 | # .nfs files are created when an open file is removed but is still being accessed 18 | .nfs* 19 | 20 | ### Node ### 21 | # Logs 22 | logs 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | lerna-debug.log* 28 | .pnpm-debug.log* 29 | 30 | # Diagnostic reports (https://nodejs.org/api/report.html) 31 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 32 | 33 | # Runtime data 34 | pids 35 | *.pid 36 | *.seed 37 | *.pid.lock 38 | 39 | # Directory for instrumented libs generated by jscoverage/JSCover 40 | lib-cov 41 | 42 | # Coverage directory used by tools like istanbul 43 | coverage 44 | *.lcov 45 | 46 | # nyc test coverage 47 | .nyc_output 48 | 49 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 50 | .grunt 51 | 52 | # Bower dependency directory (https://bower.io/) 53 | bower_components 54 | 55 | # node-waf configuration 56 | .lock-wscript 57 | 58 | # Compiled binary addons (https://nodejs.org/api/addons.html) 59 | build/Release 60 | 61 | # Dependency directories 62 | node_modules/ 63 | jspm_packages/ 64 | 65 | # Snowpack dependency directory (https://snowpack.dev/) 66 | web_modules/ 67 | 68 | # TypeScript cache 69 | *.tsbuildinfo 70 | 71 | # Optional npm cache directory 72 | .npm 73 | 74 | # Optional eslint cache 75 | .eslintcache 76 | 77 | # Optional stylelint cache 78 | .stylelintcache 79 | 80 | # Microbundle cache 81 | .rpt2_cache/ 82 | .rts2_cache_cjs/ 83 | .rts2_cache_es/ 84 | .rts2_cache_umd/ 85 | 86 | # Optional REPL history 87 | .node_repl_history 88 | 89 | # Output of 'npm pack' 90 | *.tgz 91 | 92 | # Yarn Integrity file 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | .env 97 | .env.development.local 98 | .env.test.local 99 | .env.production.local 100 | .env.local 101 | 102 | # parcel-bundler cache (https://parceljs.org/) 103 | .cache 104 | .parcel-cache 105 | 106 | # Next.js build output 107 | .next 108 | out 109 | 110 | # Nuxt.js build / generate output 111 | .nuxt 112 | dist 113 | 114 | # Gatsby files 115 | .cache/ 116 | # Comment in the public line in if your project uses Gatsby and not Next.js 117 | # https://nextjs.org/blog/next-9-1#public-directory-support 118 | # public 119 | 120 | # vuepress build output 121 | .vuepress/dist 122 | 123 | # vuepress v2.x temp and cache directory 124 | .temp 125 | 126 | # Docusaurus cache and generated files 127 | .docusaurus 128 | 129 | # Serverless directories 130 | .serverless/ 131 | 132 | # FuseBox cache 133 | .fusebox/ 134 | 135 | # DynamoDB Local files 136 | .dynamodb/ 137 | 138 | # TernJS port file 139 | .tern-port 140 | 141 | # Stores VSCode versions used for testing VSCode extensions 142 | .vscode-test 143 | 144 | # yarn v2 145 | .yarn/cache 146 | .yarn/unplugged 147 | .yarn/build-state.yml 148 | .yarn/install-state.gz 149 | .pnp.* 150 | 151 | ### Node Patch ### 152 | # Serverless Webpack directories 153 | .webpack/ 154 | 155 | # Optional stylelint cache 156 | 157 | # SvelteKit build / generate output 158 | .svelte-kit 159 | 160 | ### VisualStudioCode ### 161 | .vscode/* 162 | !.vscode/settings.json 163 | !.vscode/tasks.json 164 | !.vscode/launch.json 165 | !.vscode/extensions.json 166 | !.vscode/*.code-snippets 167 | 168 | # Local History for Visual Studio Code 169 | .history/ 170 | 171 | # Built Visual Studio Code Extensions 172 | *.vsix 173 | 174 | ### VisualStudioCode Patch ### 175 | # Ignore all local history of files 176 | .history 177 | .ionide 178 | 179 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,linux,node 180 | 181 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 182 | 183 | 184 | # Docker Volumes 185 | .docker/volumes -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node.js Template 2 | 3 | Este repositório é um template para a implementação de sistemas utilizando a arquitetura limpa, uma abordagem que separa as diferentes responsabilidades de um sistema em camadas bem definidas. O repositório está organizado em quatro camadas principais: entidades, casos de uso, infraestrutura e apresentação. Cada camada possui sua própria pasta e arquivos correspondentes em TypeScript, de forma a tornar a implementação do sistema mais modular, testável e escalável. 4 | 5 | Ao utilizar este template, você terá uma base sólida para a construção de um sistema bem estruturado, onde cada camada tem uma responsabilidade bem definida e é facilmente substituível, caso necessário. Além disso, o uso de TypeScript garante uma tipagem mais forte e um código mais seguro e confiável. 6 | 7 | ## Clean Architecture 8 | 9 | A **arquitetura limpa**, também conhecida como arquitetura hexagonal, é um padrão arquitetural que tem como objetivo principal separar as diferentes responsabilidades de um sistema, de forma a torná-lo mais modular, testável e escalável. 10 | 11 | A arquitetura limpa é composta por **quatro camadas principais**. A primeira camada é a de **entidades**, que contém os objetos de negócio do sistema, ou seja, as classes que representam as regras de negócio e as entidades do domínio. Essa camada é responsável por definir a estrutura e o comportamento dos objetos que são utilizados pelo sistema. Esses objetos devem ser independentes de qualquer tecnologia ou infraestrutura específica, de forma que possam ser reutilizados em diferentes contextos. 12 | 13 | A segunda camada é a de **casos de uso**, que contém a lógica de negócio do sistema. Essa camada é responsável por implementar as regras de negócio do sistema utilizando as entidades da camada anterior. Os casos de uso são responsáveis por definir as ações que o sistema pode executar e como elas devem ser executadas. Eles também são responsáveis por garantir que as regras de negócio sejam aplicadas de forma consistente em todo o sistema. 14 | 15 | A terceira camada é a de **infraestrutura**, que contém o código responsável por fornecer as implementações concretas para as interfaces definidas nas camadas de entidades e casos de uso. Essa camada é responsável por implementar os detalhes técnicos necessários para que o sistema possa interagir com o mundo exterior, como bancos de dados, serviços externos, dispositivos de entrada e saída, entre outros. 16 | 17 | Por fim, a camada de **apresentação** é responsável por fornecer uma interface de usuário para o sistema. Essa camada é responsável por apresentar as informações para o usuário e capturar as entradas do usuário para repassá-las aos casos de uso. É importante notar que a camada de apresentação deve ser a camada mais externa do sistema, ou seja, ela não deve depender de nenhuma das outras camadas, mas sim o contrário. Dessa forma, a camada de apresentação se torna mais flexível e fácil de ser substituída, já que as outras camadas não precisam ser modificadas para acomodar mudanças na interface de usuário. 18 | 19 | ## Como utilizar? 20 | 21 | 1. Clone o repositorio, utilizando o protocolo SSH ou HTTPS 22 | 23 | (*exemplo com ssh*) 24 | 25 | ```bash 26 | git clone git@github.com:arthur-rs/typescript-clean-arch-template.git 27 | ``` 28 | 29 | ou 30 | 31 | (*exemplo com https*) 32 | 33 | ```bash 34 | git clone https://github.com/arthur-rs/typescript-clean-arch-template.git 35 | ``` 36 | 37 | 2. Após clonar o repositório, instale as dependências 38 | 39 | ```bash 40 | npm install 41 | ``` 42 | 4. Renomeie o arquivo `.env.example` para `.env` e preencha as variáveis de ambiente 43 | 44 | 5. Incialize o Prisma 45 | 46 | ```bash 47 | npm run prisma:generate 48 | ``` 49 | 50 | 3. E por fim, instale os hooks do husky 51 | 52 | ```bash 53 | npm run husky:install 54 | ``` 55 | 56 | Pronto, agora você pode começar a desenvolver seu projeto. 57 | 58 | **Divirta-se!** 59 | 60 | ## Tecnologias utilizadas 61 | 62 | ### Commitlint 63 | 64 | O Commitlint é uma ferramenta que ajuda a garantir que as mensagens de commit estejam em conformidade com um determinado padrão. No projeto, o Commitlint é utilizado para garantir que as mensagens de commit sigam um formato padronizado e fácil de entender. 65 | 66 | ### Eslint 67 | 68 | O Eslint é uma ferramenta de análise de código estático que ajuda a identificar e reportar erros e problemas de estilo no código. No projeto, o Eslint é utilizado para garantir a consistência e qualidade do código. 69 | 70 | ### Husky 71 | 72 | O Husky é uma ferramenta que permite executar scripts antes de cada commit ou push para o repositório. No projeto, o Husky é utilizado para garantir que o código esteja em conformidade com as regras de estilo e qualidade antes de ser enviado para o repositório. 73 | 74 | ### Jest 75 | 76 | O Jest é um framework de testes para aplicações Javascript. No projeto, o Jest é utilizado para criar e executar testes automatizados, garantindo que o código funcione como esperado e não tenha regressões. 77 | 78 | ### Node.js 79 | 80 | O Node.js é uma plataforma de desenvolvimento de aplicações backend em Javascript. No projeto, o Node.js é utilizado como plataforma de desenvolvimento, permitindo que o código seja executado em um servidor. 81 | 82 | ### Nest.js 83 | 84 | O Nest.js é um framework de desenvolvimento web em Node.js que utiliza conceitos de programação orientada a objetos e programação funcional. No projeto, o Nest.js é utilizado para criar uma arquitetura escalável e modular, facilitando o desenvolvimento e a manutenção do código. 85 | 86 | ### SWC 87 | 88 | O SWC é um compilador JavaScript/TypeScript de alto desempenho e baixa latência. No projeto, o SWC é utilizado para compilar o código TypeScript em JavaScript otimizado para produção. 89 | 90 | ### Typescript 91 | 92 | O Typescript é um superset da linguagem Javascript que adiciona recursos como tipagem estática, interfaces e classes. No projeto, o Typescript é utilizado para melhorar a segurança do código, tornando-o mais fácil de ler e manter. 93 | 94 | ## Créditos 95 | 96 | Pensando e desenvolvido por [Arthur Reis](https://github.com/arthur-rs)! --------------------------------------------------------------------------------