DDD + UnitOfWork en NestJs

Cuando programas en lenguajes como C# o Java que son lenguajes con un tipado fuerte y una estructura solida para POO y luego intentas migrar a lenguajes como JavaScript o Python te encuentras que son lenguajes que te dan mucha libertad. Si no le pones freno a esta libertad con buenas practicas de desarrollo vas a construir software muy difícil de mantener.

DDD (Domain Driven Design - Diseño Dirigido por Dominio)

Domain Driven Design es un enfoque de desarrollo de software utilizado por Eric Evans en su libro “ Domain-Driven Design — Tackling Complexity in the Heart of Software, 2004”. Representa distintas claves, terminología y patrones utilizados para desarrollar software donde el dominio es lo más central e importante de una determinada organización. Sus principios se basan en:

  • Colocar los modelos y reglas de negocio de la organización, en el core de la aplicación.
  • Basar nuestro dominio complejo, en un modelo de software.
  • Se utiliza para tener una mejor perspectiva a nivel de colaboración entre expertos del dominio y los desarrolladores, para concebir un software con los objetivos bien claros.

ARQUITECTURA ONION

image.png

Onion se trata de una arquitectura multicapa construida en torno a un modelo de dominio independiente de todo lo demás. Las dependencias van hacia el centro, por lo que todo depende de ese modelo de dominio. A su alrededor se organizan varias capas, estando en las más cercanas las interfaces de repositorio, es decir, las que definen el comportamiento del almacenamiento de los datos pero no lo implementan. En las capas siguientes está la lógica de negocio que usa estas interfaces y que en tiempo de ejecución tendrá las implementaciones apropiadas. Alrededor del núcleo de modelo puede haber un número variable de capas, pero siempre debe cumplirse que las interfaces estén más cerca que las clases que las utilizan.

El proyecto que les presentare a continuación administra unos servicios financieros, se tienen unas cuentas bancarias (ahorro y corriente) y en ellas se pueden realizar las transacciones como consignaciones y retiros.

La estructura del proyecto es la siguiente:

image.png

DOMAIN

image.png

En esta capa encontraremos dos carpetas, la primera llamada entity y la segunda llamada factory estas corresponden a las entidades del dominio y a un patrón de diseño respectivamente.

Las clases del dominio son las clases mas importantes de nuestra arquitectura, pues en ellas se encuentran la logica del dominio que sera en engranaje principal del software. Estas clases deben ser limpias. Recomiendo ir de la mano de los principios SOLID para el correcto diseño de estas.

En esta capa tenemos una interfaz llamada IFinancialServicesInterface la cual determina la estructura de todos los servicios financiero que se van a implementar en el sistema.

A continuación tenemos la clase BankAccount la cual implementa la interfaz mencionada anterior mente y se extiende mediante las clases CurrentAccount y SavingsAccount. Cada cuenta bancaria posee unos movimientos financieros (FinancialMovement) y cada movimiento financiero se da mediante una transacción (Transaction).

INFRASTRUCTURE

image.png

BASE

generic.repository.ts

Esta clase será nuestro repositorio genérico, tendrá los métodos necesarios para el correcto funcionamiento de cada repositorio especifico. En este caso lo extendemos de la clase Repository la cual ya trae esos métodos y no necesitaremos implementar mas pero cada caso es diferente. La letra T indica el tipo con el que va a trabajar cada repositorio especifico.

import { Repository } from 'typeorm';
import { Injectable } from '@nestjs/common';

@Injectable()
export class GenericRepository<T> extends Repository<T>{}

CONTRACTS

unitOfWork.interface.ts

Esta interfaz determina la estructura para nuestra unidad de trabajo. Aquí definimos cuales serán los repositorios específicos y los métodos start y complete para el manejo de las transacciones.

import { BankAccountRepository } from '../repositories/bankAccount.repository';
import { FinancialMovementRepository } from '../repositories/financialMovement.repository';

export interface IUnitOfWork{

  //REPOSITORIES
  bankAccountRepository: BankAccountRepository;
  financialMovementRepository: FinancialMovementRepository;

  start(): void;
  complete(work: () => any): Promise<any>;
}

DATABASE

Esta carpeta contiene todo lo relacionado con la base de datos.

ENTITY

Aquí tendremos una representación de nuestras entidades del dominio para la construcción de la base de datos.

banckAccount.orm.repository

import { FinancialMovementOrm } from './financialMovement.orm.repository';
import { Column, Entity, JoinColumn, OneToMany, PrimaryColumn } from 'typeorm';

@Entity()
export class BankAccountOrm{

  @Column()
  public balance: number;
  @Column()
  public name: string;
  @PrimaryColumn()
  public number: string;
  @Column()
  public city: string;
  @OneToMany(type => FinancialMovementOrm, financialMovement => financialMovement.bankAccount)
  public movements: FinancialMovementOrm[];

}

financialMovement.orm.repository

import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { BankAccountOrm } from './bankAccount.orm.repository';

@Entity()
export class FinancialMovementOrm{

  @PrimaryGeneratedColumn()
  id: number;
  @ManyToOne(type => BankAccountOrm, account => account.movements)
  @JoinColumn({name : 'bankAccount'})
  bankAccount: string;
  @Column({default: 0})
  consignValue: number;
  @Column({default: 0})
  withdrawalValue: number;
  @Column()
  movementDate: string;

}
MIGRATIONS

entities.provider

Esta clase contiene todas las migraciones de nuestros ORM. El atributo inject hace referencia a nuestro provider de la base de datos.

import { Connection } from 'typeorm';
import { BankAccountOrm } from '../entity/bankAccount.orm.repository';
import { FinancialMovementOrm } from '../entity/financialMovement.orm.repository';


export const bankAccountProviders = [
  {
    provide: 'BANK_ACCOUNT_REPOSITORY',
    useFactory: (connection: Connection) => connection.getRepository(BankAccountOrm),
    inject: ['DATABASE_CONNECTION'],
  },
];

export const financialMovementsProviders = [
  {
    provide: 'FINANCIAL_MOVEMENTS_REPOSITORY',
    useFactory: (connection: Connection) => connection.getRepository(FinancialMovementOrm),
    inject: ['DATABASE_CONNECTION'],
  },
];
PROVIDER

Esta es la cadena de conexión a nuestra base de datos.

import { createConnection } from 'typeorm';

export const databaseProviders = [
  {
    provide: 'DATABASE_CONNECTION',
    useFactory: async () => await createConnection({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: '1981',
      database: 'FINANCIAL_SERVICES_TEST',
      entities: [
        'dist/infrastructure/database/entity/*.js',
      ],
      synchronize: true,
    }),
  },
];
database.module

Aquí encapsularemos todo el contenido de la carpeta database en un modulo.

import { Module } from '@nestjs/common';
import { bankAccountProviders, financialMovementsProviders } from './migrations/entities.provider';
import { databaseProviders } from './provider/database.provider';


@Module({
  providers: [
    ...databaseProviders,
    ...bankAccountProviders,
    ...financialMovementsProviders
  ],
  exports: [
    ...databaseProviders,
    ...bankAccountProviders,
    ...financialMovementsProviders
  ]
})
export class DatabaseModule{}

REPOSITORIES

Esta carpeta contiene todos los repositorios específicos que contendrá nuestro sistema.

bankAccount.repository

Este repositorio administrara las cuentas bancarias, podemos ver que su tipo es de BankAccountOrm.

import { GenericRepository } from '../base/generic.repository';
import { BankAccountOrm } from '../database/entity/bankAccount.orm.repository';
import { Injectable } from '@nestjs/common';
import { EntityRepository } from 'typeorm';

@Injectable()
@EntityRepository(BankAccountOrm)
export class BankAccountRepository extends GenericRepository<BankAccountOrm>{}

financialMovement.repository

Este repositorio administra los movimientos financieros, podemos ver que su tipo es de FinancialMovementOrm.

import { Injectable } from '@nestjs/common';
import { EntityRepository } from 'typeorm';
import { FinancialMovementOrm } from '../database/entity/financialMovement.orm.repository';
import { GenericRepository } from '../base/generic.repository';


@Injectable()
@EntityRepository(FinancialMovementOrm)
export class FinancialMovementRepository extends GenericRepository<FinancialMovementOrm>{}

UNITOFWORK

Esta es nuestra unidad de trabajo. A esta clase se le inyecta la conexión a la base de datos llamada 'DATABASE_CONNECTION'. En su constructor se inicializan todos los repositorios específicos que se definen en la interfaz de la unidad de trabajo.

Esta clase cuenta con dos metodos:

  • start(): Es el encargado comenzar una transaccion.
  • complete(): Es el encargado de completar una transacción realizando un commit o un rollback y haciendo un release de la transacción al final.
import { IUnitOfWork } from '../contracts/unitOfWork.interface';
import { Connection, EntityManager, QueryRunner } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import { BankAccountRepository } from '../repositories/bankAccount.repository';
import { FinancialMovementRepository } from '../repositories/financialMovement.repository';

@Injectable()
export class UnitOfWork implements IUnitOfWork{

  private readonly queryRunner: QueryRunner;
  private transactionManager: EntityManager;
  //REPOSITORIES
  public bankAccountRepository: BankAccountRepository;
  public financialMovementRepository: FinancialMovementRepository;

  constructor(@Inject('DATABASE_CONNECTION') private readonly asyncDatabaseConnection: Connection) {
    this.queryRunner = this.asyncDatabaseConnection.createQueryRunner();
    this.bankAccountRepository = this.asyncDatabaseConnection.getCustomRepository(BankAccountRepository);
    this.financialMovementRepository = this.asyncDatabaseConnection.getCustomRepository(FinancialMovementRepository);
  }

  setTransactionManager(){
    this.transactionManager = this.queryRunner.manager;
  }

  async complete(work: () => any): Promise<any> {
    try{
      const response = await work();
      await this.queryRunner.commitTransaction();
      return response;
    }catch (error){
      await this.queryRunner.rollbackTransaction();
      return error.toString();
    }finally {
      await this.queryRunner.release();
    }
  }

  async start() {
    await this.queryRunner.startTransaction();
    this.setTransactionManager();
  }

}

infrastructure.module

Aquí encapsularemos todo el contenido de la carpeta infrastructure en un modulo.

import { Module } from '@nestjs/common';
import { UnitOfWork } from './unitOfWork/unitOfWork';
import { DatabaseModule } from './database/database.module';

@Module({
  imports: [DatabaseModule],
  providers: [UnitOfWork],
  exports: [UnitOfWork]
})
export class InfrastructureModule{}

APPLICATION

image.png

En esta capa van todos los servicios de nuestro programa.

Un servicio esta compuesto por el servicio, la request o solicitud de ese servicio y la response o respuesta de ese servicio.

Como podemos ver a continuación antes de completar una transacción a la base de datos llamamos al metodo start de nuestra unidad de trabajo.

consignBankAccount.service

import { BankAccount } from '../domain/entity/bankAccount.entity';
import { Transaction } from '../domain/entity/transaction.entity';
import { CreateBankAccountResponse } from './createBankAccount.service';
import { IUnitOfWork } from '../infrastructure/contracts/unitOfWork.interface';
import { BankAccountFactory } from '../domain/factory/bankAccount.factory';


export class ConsignBankAccountService{


  constructor(private readonly _unitOfWork: IUnitOfWork) {}

  async execute(request: ConsignBankAccountRequest) : Promise<ConsignBankAccountResponse>{
    const accountOrm = await this._unitOfWork.bankAccountRepository.findOne(request.number);
    accountOrm.movements = await this._unitOfWork.financialMovementRepository.find({ where: { bankAccount: accountOrm.number}});
    if(accountOrm != undefined){
      const bankAccount: BankAccount = new BankAccountFactory().create(request.type);
      bankAccount.name = accountOrm.name;
      bankAccount.city = accountOrm.city;
      bankAccount.number = accountOrm.number;
      bankAccount.balance = accountOrm.balance;
      bankAccount.movements = accountOrm.movements;
      bankAccount.consign(new Transaction(request.value, request.city));
      await this._unitOfWork.start();
      await this._unitOfWork.complete(async () => await this._unitOfWork.bankAccountRepository.save(bankAccount));
      await this._unitOfWork.start();
      await this._unitOfWork.complete(async () => await this._unitOfWork.financialMovementRepository.save(bankAccount.movements[bankAccount.movements.length - 1]));
      return new CreateBankAccountResponse('Se consignaron ' + request.value + ' a la cuenta: ' + bankAccount.number + ' balance total: ' + bankAccount.balance);
    }
    return new CreateBankAccountResponse('El numero de cuenta no existe');
  }

}

export class ConsignBankAccountRequest{
  public number: string;
  public city: string;
  public value: number;
  public type: string;
}

export class ConsignBankAccountResponse{
  constructor(
    public message: string
  ){}
}

application.module

import { Module } from '@nestjs/common';
import {
  CreateBankAccountService,
} from './createBankAccount.service';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
import { ConsignBankAccountService } from './consignBankAccount.service';
import { SearchAllBankAccountsService } from './searchAllBankAccounts.service';

@Module({
  imports: [
    InfrastructureModule,
    CreateBankAccountService,
    ConsignBankAccountService,
    SearchAllBankAccountsService
  ],
  exports: [
    CreateBankAccountService,
    ConsignBankAccountService,
    SearchAllBankAccountsService
  ]
})
export class ApplicationModule{}

CONTROLLERS

En esta capa se incluyen los controladores de nuestra api.

bankAccount.controller

Cada método de nuestro controlador recibe un body de tipo request del servicio especificado.

import { Body, Controller, Get, Post, Put } from '@nestjs/common';
import {
  CreateBankAccountRequest,
  CreateBankAccountResponse,
  CreateBankAccountService } from '../application/createBankAccount.service';
import {
  ConsignBankAccountRequest,
  ConsignBankAccountResponse,
  ConsignBankAccountService,
} from '../application/consignBankAccount.service';
import {
  SearchAllBankAccountsResponse,
  SearchAllBankAccountsService,
} from '../application/searchAllBankAccounts.service';
import { UnitOfWork } from '../infrastructure/unitOfWork/unitOfWork';

@Controller('bankAccount')
export class BankAccountController{

  constructor(private readonly _unitOfWork: UnitOfWork) {}

  @Post()
  async createBankAccount(@Body() request: CreateBankAccountRequest){
    const service: CreateBankAccountService = new CreateBankAccountService(this._unitOfWork);
    const res: CreateBankAccountResponse = await service.execute(request);
    return res.message;
  }

  @Put()
  async consignBankAccount(@Body() request: ConsignBankAccountRequest){
    const service: ConsignBankAccountService = new ConsignBankAccountService(this._unitOfWork);
    const res: ConsignBankAccountResponse = await service.execute(request);
    return res.message;
  }

  @Get()
  async getAllBankAccounts(){
    const res: SearchAllBankAccountsResponse = await new SearchAllBankAccountsService(this._unitOfWork).execute();
    return res;
  }

}

controllers.module

import { Module } from '@nestjs/common';
import { BankAccountController } from './bankAccount.controller';
import { ApplicationModule } from '../application/application.module';
import { InfrastructureModule } from '../infrastructure/infrastructure.module';

@Module({
  imports: [
    ApplicationModule,
    InfrastructureModule
  ],
  controllers: [BankAccountController],
})
export class ControllersModule{}

app.module

import { Module } from '@nestjs/common';
import { ApplicationModule } from './application/application.module';
import { InfrastructureModule } from './infrastructure/infrastructure.module';
import { ControllersModule } from './controllers/controllers.module';

@Module({
  imports: [
    ApplicationModule,
    ControllersModule,
    InfrastructureModule
  ]
})
export class AppModule {}

main

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const options = new DocumentBuilder()
    .setTitle('Financial Services')
    .setDescription('This is a experimental project about how apply DDD architecture to a NestJs project')
    .setVersion('1.0')
    .addTag('DDD')
    .build();
  const document = SwaggerModule.createDocument(app, options);
  SwaggerModule.setup('api', app, document);

  await app.listen(3000);
}
bootstrap();

Muchas gracias por tomarte el tiempo en leer este post. Si tienes alguna corrección o algo que pueda mejorar este experimento no dudes en hacérmela llegar.

Adjunto link del repositorio: github.com/OlsonII/ddd-practice

Referencias:

adictosaltrabajo.com/2019/07/02/capas-cebol.. medium.com/@jonathanloscalzo/domain-driven-..