├── .gitignore ├── LICENSE ├── README.md ├── docker-compose.yml ├── nest-cli.json ├── nodemon-debug.json ├── nodemon.json ├── package.json ├── src ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── databases │ ├── database.module.ts │ └── database.provider.ts ├── domain │ ├── catalog │ │ ├── catalog.module.ts │ │ ├── commands │ │ │ ├── add-product-to-catalog.command.ts │ │ │ └── handlers │ │ │ │ └── add-product-to-catalog.handler.ts │ │ ├── entities │ │ │ └── catalog.entity.ts │ │ ├── providers │ │ │ └── catalog.provider.ts │ │ └── stores │ │ │ └── catalog.store.ts │ └── product │ │ ├── aggregates │ │ └── product.aggregate.ts │ │ ├── commands │ │ ├── handlers │ │ │ ├── index.ts │ │ │ ├── modification-product.handler.ts │ │ │ ├── register-product.handler.ts │ │ │ └── remove-product.handler.ts │ │ ├── modification-product.command.ts │ │ ├── register-product.command.ts │ │ └── remove-product.command.ts │ │ ├── controllers │ │ └── product.controller.ts │ │ ├── dto │ │ ├── product-modification.dto.ts │ │ └── product-registration.dto.ts │ │ ├── entities │ │ └── product.entity.ts │ │ ├── events │ │ ├── handlers │ │ │ └── product-was-added.handler.event.ts │ │ └── product-was-added.event.ts │ │ ├── produdct.module.ts │ │ ├── providers │ │ └── product.provider.ts │ │ ├── queries │ │ ├── get-all-products.query.ts │ │ ├── get-by-sku-product.query.ts │ │ └── handlers │ │ │ ├── get-all-products.handler.query.ts │ │ │ ├── get-by-sku-product.handler.query.ts │ │ │ └── index.ts │ │ ├── sagas │ │ └── product.saga.ts │ │ ├── services │ │ └── product.service.ts │ │ └── stores │ │ └── product.store.ts └── main.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .history 2 | dist 3 | node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Arnaud Méhat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [travis-image]: https://api.travis-ci.org/nestjs/nest.svg?branch=master 6 | [travis-url]: https://travis-ci.org/nestjs/nest 7 | [linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux 8 | [linux-url]: https://travis-ci.org/nestjs/nest 9 | 10 |

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

11 |

12 | NPM Version 13 | Package License 14 | NPM Downloads 15 | Travis 16 | Linux 17 | Coverage 18 | Gitter 19 | Backers on Open Collective 20 | Sponsors on Open Collective 21 | 22 | 23 |

24 | 26 | 27 | ## Description 28 | 29 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 30 | 31 | Simple Nestjs application implementing CQRS principles with SAGA around a product. 32 | 33 | The saga adds the product to the catalog (it's just an example ;) ) 34 | 35 | ## Installation 36 | 37 | ```bash 38 | $ npm install 39 | ``` 40 | 41 | ## Running the app 42 | 43 | ```bash 44 | # start docker compose 45 | $ docker-compose up 46 | 47 | # end docker compose 48 | $ docker-compose down 49 | 50 | # development 51 | $ npm run start 52 | 53 | # watch mode 54 | $ npm run start:dev 55 | 56 | # production mode 57 | $ npm run start:prod 58 | ``` 59 | 60 | ## REST 61 | 62 | ### Create product 63 | 64 | POST http://localhost:3000/product 65 | 66 | Body raw (JSON): 67 | 68 | ```json 69 | { 70 | "name": "chemise", 71 | "sku": "f5865847-6951-467d-a1bd-bef0b970ab35", 72 | "price": "29", 73 | "currency": "euro" 74 | } 75 | ``` 76 | 77 | ### Update Product 78 | 79 | PUT http://localhost:3000/product/{id} 80 | 81 | Example : http://localhost:3000/product/f5865847-6951-467d-a1bd-bef0b970ab35 82 | 83 | Body raw (JSON): 84 | 85 | ```json 86 | { 87 | "name": "pantalon", 88 | "sku": "f5865847-6951-467d-a1bd-bef0b970ab35", 89 | "price": "43", 90 | "currency": "euro" 91 | } 92 | ``` 93 | 94 | ### Delete product 95 | 96 | DELETE http://localhost:3000/product/{id} 97 | 98 | Example : http://localhost:3000/product/f5865847-6951-467d-a1bd-bef0b970ab35 99 | 100 | ### Get all products 101 | 102 | GET http://localhost:3000/product 103 | 104 | ### Get product by sku 105 | 106 | GET http://localhost:3000/product/{id} 107 | 108 | Example : http://localhost:3000/product/f5865847-6951-467d-a1bd-bef0b970ab35 109 | 110 | ## Test 111 | 112 | ```bash 113 | # unit tests 114 | $ npm run test 115 | 116 | # e2e tests 117 | $ npm run test:e2e 118 | 119 | # test coverage 120 | $ npm run test:cov 121 | ``` 122 | 123 | 124 | ## License 125 | 126 | [MIT licensed](LICENSE). 127 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.4' 2 | 3 | services: 4 | 5 | db-nest: 6 | container_name: maria-db 7 | image: mariadb:10.3.0 8 | environment: 9 | - "MYSQL_DATABASE=nest" 10 | - "MYSQL_USER=nestuser" 11 | - "MYSQL_PASSWORD=nestpwd" 12 | - "MYSQL_ROOT_PASSWORD=root" 13 | - "MYSQL_RANDOM_ROOT_PASSWORD=random" 14 | - "MYSQL_ALLOW_EMPTY_PASSWORD=true" 15 | volumes: 16 | - type: volume 17 | source: nestvolume 18 | target: /var/lib/mysql 19 | ports: 20 | - "3333:3306" 21 | 22 | rabbit1: 23 | image: rabbitmq:3-management 24 | hostname: rabbit1 25 | environment: 26 | - ERLANG_COOKIE=abcdefg 27 | - RABBITMQ_DEFAULT_USER=test 28 | - RABBITMQ_DEFAULT_PASS=test 29 | ports: 30 | - "5672:5672" 31 | - "15672:15672" 32 | 33 | eventstore: 34 | image: eventstore/eventstore 35 | ports: 36 | - 2113:2113 37 | - 1113:1113 38 | environment: 39 | - EVENTSTORE_RUN_PROJECTIONS=All 40 | - EVENTSTORE_START_STANDARD_PROJECTIONS=true 41 | 42 | volumes: 43 | nestvolume: 44 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /nodemon-debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "node --inspect-brk -r ts-node/register -r tsconfig-paths/register src/main.ts" 6 | } 7 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["dist"], 3 | "ext": "js", 4 | "exec": "node dist/main" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-cqrs-saga", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "tsc -p tsconfig.build.json", 9 | "format": "prettier --write \"src/**/*.ts\"", 10 | "start": "ts-node -r tsconfig-paths/register src/main.ts", 11 | "start:dev": "concurrently --handle-input \"wait-on dist/main.js && nodemon\" \"tsc -w -p tsconfig.build.json\" ", 12 | "start:debug": "nodemon --config nodemon-debug.json", 13 | "prestart:prod": "rimraf dist && npm run build", 14 | "start:prod": "node dist/main.js", 15 | "lint": "tslint -p tsconfig.json -c tslint.json", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^7.0.7", 24 | "@nestjs/core": "^7.0.7", 25 | "@nestjs/cqrs": "^6.1.0", 26 | "@nestjs/platform-express": "^7.0.7", 27 | "@nestjs/typeorm": "^7.0.0", 28 | "mysql": "^2.18.1", 29 | "reflect-metadata": "^0.1.12", 30 | "rimraf": "^2.6.2", 31 | "rxjs": "^6.3.3", 32 | "typeorm": "^0.2.25" 33 | }, 34 | "devDependencies": { 35 | "@nestjs/testing": "^7.0.7", 36 | "@types/express": "^4.17.4", 37 | "@types/jest": "^23.3.13", 38 | "@types/node": "^10.12.18", 39 | "@types/supertest": "^2.0.7", 40 | "concurrently": "^4.1.0", 41 | "jest": "^23.6.0", 42 | "nodemon": "^1.18.9", 43 | "prettier": "^1.15.3", 44 | "supertest": "^3.4.1", 45 | "ts-jest": "24.0.2", 46 | "ts-node": "8.1.0", 47 | "tsconfig-paths": "3.8.0", 48 | "tslint": "5.16.0", 49 | "typescript": "3.7.2", 50 | "wait-on": "^3.2.0" 51 | }, 52 | "jest": { 53 | "moduleFileExtensions": [ 54 | "js", 55 | "json", 56 | "ts" 57 | ], 58 | "rootDir": "src", 59 | "testRegex": ".spec.ts$", 60 | "transform": { 61 | "^.+\\.(t|j)s$": "ts-jest" 62 | }, 63 | "coverageDirectory": "../coverage", 64 | "testEnvironment": "node" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { AppController } from './app.controller'; 5 | import { AppService } from './app.service'; 6 | import { ProductModule } from './domain/product/produdct.module'; 7 | import { CatalogModule } from './domain/catalog/catalog.module'; 8 | import { DatabaseModule } from './databases/database.module'; 9 | import { DatabaseProvider } from './databases/database.provider'; 10 | 11 | @Module({ 12 | imports: [ 13 | DatabaseModule, 14 | CatalogModule, 15 | ProductModule, 16 | ], 17 | controllers: [AppController], 18 | providers: [AppService, ...DatabaseProvider], 19 | exports: [...DatabaseProvider], 20 | }) 21 | export class AppModule {} 22 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Nestjs CQRS'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/databases/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { DatabaseProvider } from './database.provider'; 4 | 5 | @Module({ 6 | providers: [...DatabaseProvider], 7 | exports: [...DatabaseProvider], 8 | }) 9 | export class DatabaseModule {} 10 | -------------------------------------------------------------------------------- /src/databases/database.provider.ts: -------------------------------------------------------------------------------- 1 | import { createConnection } from 'typeorm'; 2 | 3 | export const DatabaseProvider = [ 4 | { 5 | provide: 'DATABASE_CONNECTION', 6 | useFactory: async () => await createConnection({ 7 | type: 'mysql', 8 | host: 'localhost', 9 | port: 3333, 10 | username: 'nestuser', 11 | password: 'nestpwd', 12 | database: 'nest', 13 | entities: ['dist/**/**.entity.js'], 14 | // logging: true, 15 | synchronize: true, 16 | /* 17 | dropSchema: true, 18 | keepConnectionAlive: true, 19 | retryAttempts: 2, 20 | retryDelay: 1000, 21 | */ 22 | }), 23 | }, 24 | ]; 25 | -------------------------------------------------------------------------------- /src/domain/catalog/catalog.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CqrsModule } from '@nestjs/cqrs'; 3 | 4 | import { DatabaseModule } from '../../databases/database.module'; 5 | import { AddProductToCatalogHandler } from './commands/handlers/add-product-to-catalog.handler' 6 | import { CatalogStore } from './stores/catalog.store'; 7 | import { CatalogProvider } from './providers/catalog.provider'; 8 | 9 | @Module({ 10 | imports: [ 11 | CqrsModule, 12 | DatabaseModule, 13 | ], 14 | providers: [ 15 | AddProductToCatalogHandler, 16 | CatalogStore, 17 | ...CatalogProvider, 18 | ], 19 | }) 20 | export class CatalogModule {} 21 | -------------------------------------------------------------------------------- /src/domain/catalog/commands/add-product-to-catalog.command.ts: -------------------------------------------------------------------------------- 1 | export class AddProductToCatalogCommand { 2 | public constructor( 3 | public readonly name: string, 4 | public readonly sku: string, 5 | public readonly price: number, 6 | public readonly currency: string, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/catalog/commands/handlers/add-product-to-catalog.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; 2 | 3 | import { AddProductToCatalogCommand } from '../add-product-to-catalog.command'; 4 | import { CatalogStore } from '../../stores/catalog.store'; 5 | import { Catalog } from '../../entities/catalog.entity'; 6 | import { Logger } from '@nestjs/common'; 7 | 8 | @CommandHandler(AddProductToCatalogCommand) 9 | export class AddProductToCatalogHandler implements ICommandHandler { 10 | public constructor(private readonly catalogStore: CatalogStore) {} 11 | 12 | public async execute(command: AddProductToCatalogCommand): Promise { 13 | Logger.log('add product to catalog domain called'); 14 | const { sku, name, price, currency } = command; 15 | const catalogEntity = new Catalog(); 16 | catalogEntity.sku = sku; 17 | catalogEntity.name = name; 18 | catalogEntity.price = price; 19 | catalogEntity.currency = currency; 20 | 21 | await this.catalogStore.register(catalogEntity); 22 | 23 | return catalogEntity; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/domain/catalog/entities/catalog.entity.ts: -------------------------------------------------------------------------------- 1 | import { PrimaryGeneratedColumn, Column, Entity } from 'typeorm'; 2 | 3 | @Entity() 4 | export class Catalog { 5 | @PrimaryGeneratedColumn() id: number; 6 | 7 | @Column() 8 | name: string; 9 | 10 | @Column() 11 | sku: string; 12 | 13 | @Column() 14 | price: number; 15 | 16 | @Column() 17 | currency: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/domain/catalog/providers/catalog.provider.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from 'typeorm'; 2 | 3 | import { Catalog } from '../entities/catalog.entity'; 4 | 5 | export const CatalogProvider = [ 6 | { 7 | provide: 'CATALOG_REPOSITORY', 8 | useFactory: (connection: Connection) => connection.getRepository(Catalog), 9 | inject: ['DATABASE_CONNECTION'], 10 | } 11 | ]; 12 | -------------------------------------------------------------------------------- /src/domain/catalog/stores/catalog.store.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@nestjs/common'; 2 | import { Repository } from 'typeorm'; 3 | 4 | import { Catalog } from '../entities/catalog.entity'; 5 | 6 | 7 | @Injectable() 8 | export class CatalogStore { 9 | public constructor( 10 | @Inject('CATALOG_REPOSITORY') 11 | private catalogRepository: Repository, 12 | ) {} 13 | 14 | public async getAllCatalog(): Promise { 15 | return await this.catalogRepository.find(); 16 | } 17 | 18 | public async getCatalogBySku(sku: string): Promise { 19 | return await this.catalogRepository.findOne({ sku }); 20 | } 21 | 22 | public async register( 23 | catalogEntity: Catalog, 24 | sku?: string, 25 | ): Promise { 26 | if (sku) { 27 | return await this.update(catalogEntity, sku); 28 | } else { 29 | return await this.create(catalogEntity); 30 | } 31 | } 32 | 33 | private async create(catalogEntity: Catalog): Promise { 34 | try { 35 | const catalog = this.catalogRepository.create(catalogEntity); 36 | return await this.catalogRepository.save(catalog); 37 | } catch (e) { 38 | return new Error(e); 39 | } 40 | } 41 | 42 | private async update( 43 | catalogEntity: Catalog, 44 | sku: string, 45 | ): Promise { 46 | try { 47 | await this.catalogRepository.update({ sku }, catalogEntity); 48 | return this.catalogRepository.findOne({ sku }); 49 | } catch (e) { 50 | return new Error(e); 51 | } 52 | } 53 | 54 | public async removeCatalog(sku: string): Promise { 55 | try { 56 | const catalog = this.catalogRepository.findOne({ sku }); 57 | await this.catalogRepository.delete({ sku }); 58 | 59 | return catalog; 60 | } catch (e) { 61 | return new Error(e); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/domain/product/aggregates/product.aggregate.ts: -------------------------------------------------------------------------------- 1 | import { AggregateRoot } from '@nestjs/cqrs'; 2 | 3 | import { ProductWasAddedEvent } from '../events/product-was-added.event'; 4 | 5 | export class ProductAggregate extends AggregateRoot { 6 | constructor(private readonly sku: string) { 7 | super(); 8 | } 9 | 10 | public registerProduct(sku: string, name: string, price: number, currency: string) { 11 | this.apply(new ProductWasAddedEvent(sku, name, price, currency)); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/domain/product/commands/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { RegisterProductHandler } from './register-product.handler'; 2 | import { ModificationProductHandler } from './modification-product.handler'; 3 | import { RemoveProductHandler} from './remove-product.handler'; 4 | 5 | export const CommandHandlers = [ModificationProductHandler, RegisterProductHandler, RemoveProductHandler]; 6 | export { RegisterProductHandler }; 7 | -------------------------------------------------------------------------------- /src/domain/product/commands/handlers/modification-product.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; 2 | 3 | import { ModificationProductCommand } from '../modification-product.command'; 4 | import { ProductStore } from '../../stores/product.store'; 5 | import { Product } from '../../entities/product.entity'; 6 | 7 | @CommandHandler(ModificationProductCommand) 8 | export class ModificationProductHandler 9 | implements ICommandHandler { 10 | public constructor(private readonly productStore: ProductStore) {} 11 | 12 | public async execute(command: ModificationProductCommand): Promise { 13 | try { 14 | const { sku, name, price, currency } = command; 15 | const productEntity = new Product(); 16 | productEntity.sku = sku; 17 | productEntity.name = name; 18 | productEntity.price = price; 19 | productEntity.currency = currency; 20 | 21 | const product = this.productStore.register(productEntity, sku); 22 | if (product instanceof Error) { 23 | throw product; 24 | } 25 | 26 | return product; 27 | } catch (e) { 28 | return new Error(e); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/domain/product/commands/handlers/register-product.handler.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { CommandHandler, ICommandHandler, EventPublisher } from '@nestjs/cqrs'; 3 | 4 | import { RegisterProductCommand } from '../register-product.command'; 5 | import { ProductStore } from '../../stores/product.store'; 6 | import { Product } from '../../entities/product.entity'; 7 | import { ProductAggregate } from '../../aggregates/product.aggregate'; 8 | 9 | @CommandHandler(RegisterProductCommand) 10 | export class RegisterProductHandler implements ICommandHandler { 11 | public constructor( 12 | private readonly productStore: ProductStore, 13 | private readonly publisher: EventPublisher, 14 | ) {} 15 | 16 | public async execute(command: RegisterProductCommand): Promise { 17 | try { 18 | const { name, sku, price, currency } = command; 19 | const productEntity = new Product(); 20 | productEntity.name = name; 21 | productEntity.sku = sku; 22 | productEntity.price = price; 23 | productEntity.currency = currency; 24 | const product = await this.productStore.register(productEntity); 25 | if (product instanceof Error) { 26 | throw product; 27 | } 28 | const productAggregate = this.publisher.mergeObjectContext( 29 | await new ProductAggregate(sku), 30 | ); 31 | productAggregate.registerProduct(sku, name, price, currency); 32 | productAggregate.commit(); 33 | 34 | return product; 35 | } catch (e) { 36 | Logger.error(e, 'RegisterProductHandler.execute() Error Handler: '); 37 | return e; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/domain/product/commands/handlers/remove-product.handler.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; 3 | 4 | import { RemoveProductCommand } from '../remove-product.command'; 5 | import { ProductStore } from '../../stores/product.store'; 6 | import { Product } from '../../entities/product.entity'; 7 | 8 | @CommandHandler(RemoveProductCommand) 9 | export class RemoveProductHandler 10 | implements ICommandHandler { 11 | public constructor(private readonly productStore: ProductStore) {} 12 | execute(command: RemoveProductCommand): Promise { 13 | try { 14 | const { sku } = command; 15 | const product = this.productStore.removeProduct(sku); 16 | if (product instanceof Error) { 17 | throw product; 18 | } 19 | 20 | return product; 21 | } catch (e) { 22 | Logger.error(e, 'RemoveProductHandler.execute() Error Handler: '); 23 | return e; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/domain/product/commands/modification-product.command.ts: -------------------------------------------------------------------------------- 1 | export class ModificationProductCommand { 2 | public constructor(public readonly name: string, public readonly sku: string, public readonly price: number, public readonly currency: string) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/domain/product/commands/register-product.command.ts: -------------------------------------------------------------------------------- 1 | export class RegisterProductCommand { 2 | public constructor( 3 | public readonly name: string, 4 | public readonly sku: string, 5 | public readonly price: number, 6 | public readonly currency: string, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/product/commands/remove-product.command.ts: -------------------------------------------------------------------------------- 1 | export class RemoveProductCommand { 2 | public constructor(public readonly sku: string) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/domain/product/controllers/product.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, Post, Put, Delete, Body } from '@nestjs/common'; 2 | 3 | import { ProductService } from '../services/product.service'; 4 | import { ProductRegistrationDto } from '../dto/product-registration.dto'; 5 | import { ProductModificationDto } from '../dto/product-modification.dto'; 6 | import { Product } from '../entities/product.entity'; 7 | 8 | @Controller('product') 9 | export class ProductController { 10 | public constructor(private readonly productService: ProductService) {} 11 | 12 | @Get() 13 | public getAll(): Promise { 14 | return this.productService.getAll(); 15 | } 16 | 17 | @Get(':sku') 18 | public getBySku(@Param('sku') sku: string): Promise { 19 | return this.productService.getBySku(sku); 20 | } 21 | 22 | @Post() 23 | public productRegistration( 24 | @Body() productRegistrationDto: ProductRegistrationDto, 25 | ): Promise { 26 | const { name, sku, price, currency } = productRegistrationDto; 27 | return this.productService.productRegistration(name, sku, price, currency); 28 | } 29 | 30 | @Put(':sku') 31 | public productModification( 32 | @Body() productModificationDto: ProductModificationDto, 33 | @Param('sku') sku: string, 34 | ): Promise { 35 | const { name, price, currency } = productModificationDto; 36 | return this.productService.productModification(name, sku, price, currency); 37 | } 38 | 39 | @Delete(':sku') 40 | public async delete(@Param('sku') sku: string): Promise { 41 | return this.productService.removeProduct(sku); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/domain/product/dto/product-modification.dto.ts: -------------------------------------------------------------------------------- 1 | export class ProductModificationDto { 2 | public name: string; 3 | public price: number; 4 | public currency: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/product/dto/product-registration.dto.ts: -------------------------------------------------------------------------------- 1 | export class ProductRegistrationDto { 2 | public name: string; 3 | public sku: string; 4 | public price: number; 5 | public currency: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/domain/product/entities/product.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; 2 | 3 | @Entity() 4 | export class Product { 5 | @PrimaryGeneratedColumn() id: number; 6 | 7 | @Column() 8 | name: string; 9 | 10 | @Column() 11 | sku: string; 12 | 13 | @Column() 14 | price: number; 15 | 16 | @Column() 17 | currency: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/domain/product/events/handlers/product-was-added.handler.event.ts: -------------------------------------------------------------------------------- 1 | import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; 2 | 3 | import { ProductWasAddedEvent } from '../product-was-added.event'; 4 | import { Logger } from '@nestjs/common'; 5 | 6 | @EventsHandler(ProductWasAddedEvent) 7 | export class ProductWasAddedHandlerEvent implements IEventHandler { 8 | handle(event: ProductWasAddedEvent) { 9 | Logger.log('ProductWasAddedHandlerEvent called'); 10 | return event; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/domain/product/events/product-was-added.event.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | 3 | export class ProductWasAddedEvent { 4 | public constructor( 5 | public readonly name: string, 6 | public readonly sku: string, 7 | public readonly price: number, 8 | public readonly currency: string, 9 | ) { 10 | Logger.log('ProductWasAddedEvent called'); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/domain/product/produdct.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CqrsModule } from '@nestjs/cqrs'; 3 | 4 | import { ProductController } from './controllers/product.controller'; 5 | import { ProductService } from './services/product.service'; 6 | import { CommandHandlers } from './commands/handlers'; 7 | import { QueriesHandlers } from './queries/handlers'; 8 | import { ProductStore } from './stores/product.store'; 9 | import { DatabaseModule } from '../../databases/database.module'; 10 | import { ProductProvider } from './providers/product.provider'; 11 | import { ProductSaga } from './sagas/product.saga'; 12 | import { ProductWasAddedHandlerEvent } from './events/handlers/product-was-added.handler.event'; 13 | 14 | @Module({ 15 | controllers: [ProductController], 16 | imports: [ 17 | CqrsModule, 18 | DatabaseModule, 19 | ], 20 | providers: [ 21 | ...CommandHandlers, 22 | ProductService, 23 | ...ProductProvider, 24 | ProductStore, 25 | ...QueriesHandlers, 26 | ProductSaga, 27 | ProductWasAddedHandlerEvent, 28 | ], 29 | }) 30 | export class ProductModule {} 31 | -------------------------------------------------------------------------------- /src/domain/product/providers/product.provider.ts: -------------------------------------------------------------------------------- 1 | import { Connection, Repository } from 'typeorm'; 2 | 3 | import { Product } from '../entities/product.entity'; 4 | 5 | export const ProductProvider = [ 6 | { 7 | provide: 'PRODUCT_REPOSITORY', 8 | useFactory: (connection: Connection) => connection.getRepository(Product), 9 | inject: ['DATABASE_CONNECTION'], 10 | } 11 | ]; 12 | -------------------------------------------------------------------------------- /src/domain/product/queries/get-all-products.query.ts: -------------------------------------------------------------------------------- 1 | export class GetAllProductsQuery {} 2 | -------------------------------------------------------------------------------- /src/domain/product/queries/get-by-sku-product.query.ts: -------------------------------------------------------------------------------- 1 | export class GetBySkuProductQuery { 2 | public constructor(public readonly sku: string) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/domain/product/queries/handlers/get-all-products.handler.query.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; 2 | import { Repository } from 'typeorm'; 3 | 4 | import { GetAllProductsQuery } from '../get-all-products.query'; 5 | import { ProductStore } from '../../stores/product.store'; 6 | import { InjectRepository } from '@nestjs/typeorm'; 7 | import { Product } from '../../entities/product.entity'; 8 | 9 | @QueryHandler(GetAllProductsQuery) 10 | export class GetAllProductHandlerQuery 11 | implements IQueryHandler { 12 | public constructor( 13 | private readonly productStore: ProductStore, 14 | ) {} 15 | 16 | public async execute(query: GetAllProductsQuery) { 17 | return await this.productStore.getAllProducts(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/domain/product/queries/handlers/get-by-sku-product.handler.query.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; 2 | import { GetBySkuProductQuery } from '../get-by-sku-product.query'; 3 | import { ProductStore } from '../../stores/product.store'; 4 | import { Product } from '../../entities/product.entity'; 5 | 6 | @QueryHandler(GetBySkuProductQuery) 7 | export class GetBySkuProductHandlerQuery 8 | implements IQueryHandler { 9 | public constructor(private readonly productStore: ProductStore) {} 10 | 11 | public async execute(query: GetBySkuProductQuery): Promise { 12 | try { 13 | const { sku } = query; 14 | const product = this.productStore.getProductBySku(sku); 15 | if (product instanceof Error) { 16 | throw product; 17 | } 18 | return product; 19 | } catch (e) { 20 | return new Error(e); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/domain/product/queries/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { GetAllProductHandlerQuery } from './get-all-products.handler.query'; 2 | import { GetBySkuProductHandlerQuery } from './get-by-sku-product.handler.query'; 3 | 4 | export const QueriesHandlers = [GetAllProductHandlerQuery, GetBySkuProductHandlerQuery]; 5 | -------------------------------------------------------------------------------- /src/domain/product/sagas/product.saga.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Injectable, Logger } from '@nestjs/common'; 3 | import { ICommand, ofType, Saga } from '@nestjs/cqrs'; 4 | import { Observable } from 'rxjs'; 5 | import { delay, map } from 'rxjs/operators'; 6 | 7 | import { AddProductToCatalogCommand } from '../../catalog/commands/add-product-to-catalog.command'; 8 | import { ProductWasAddedEvent } from '../events/product-was-added.event'; 9 | 10 | @Injectable() 11 | export class ProductSaga { 12 | @Saga() 13 | productWasAdded = (events$: Observable): Observable => { 14 | return events$ 15 | .pipe( 16 | ofType(ProductWasAddedEvent), 17 | delay(1000), 18 | map(event => { 19 | Logger.log('saga call AddProductToCatalogCommand'); 20 | return new AddProductToCatalogCommand(event.sku, event.name, event.price, event.currency); 21 | }), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/domain/product/services/product.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { CommandBus, QueryBus } from '@nestjs/cqrs'; 3 | 4 | import { Product } from '../entities/product.entity'; 5 | import { RegisterProductCommand } from '../commands/register-product.command'; 6 | import { GetAllProductsQuery} from '../queries/get-all-products.query'; 7 | import { ModificationProductCommand } from '../commands/modification-product.command'; 8 | import { GetBySkuProductQuery } from '../queries/get-by-sku-product.query'; 9 | import { RemoveProductCommand } from '../commands/remove-product.command'; 10 | 11 | @Injectable() 12 | export class ProductService { 13 | public constructor( 14 | private commandBus: CommandBus, 15 | private queryBus: QueryBus, 16 | ) {} 17 | 18 | public async productRegistration(name: string, sku: string, price: number, currency: string): Promise { 19 | return this.commandBus.execute(new RegisterProductCommand(name, sku, price, currency)); 20 | } 21 | 22 | public async productModification(name: string, sku: string, price: number, currency: string): Promise { 23 | return this.commandBus.execute(new ModificationProductCommand(name, sku, price, currency)); 24 | } 25 | 26 | public async removeProduct(sku: string): Promise { 27 | return this.commandBus.execute(new RemoveProductCommand(sku)); 28 | } 29 | 30 | public async getAll(): Promise { 31 | return this.queryBus.execute(new GetAllProductsQuery()); 32 | } 33 | 34 | public async getBySku(sku: string): Promise { 35 | return this.queryBus.execute(new GetBySkuProductQuery(sku)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/domain/product/stores/product.store.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@nestjs/common'; 2 | import { Repository } from 'typeorm'; 3 | 4 | import { Product } from '../entities/product.entity'; 5 | 6 | 7 | @Injectable() 8 | export class ProductStore { 9 | public constructor( 10 | @Inject('PRODUCT_REPOSITORY') 11 | private productRepository: Repository, 12 | ) {} 13 | 14 | public async getAllProducts(): Promise { 15 | return await this.productRepository.find(); 16 | } 17 | 18 | public async getProductBySku(sku: string): Promise { 19 | return await this.productRepository.findOne({ sku }); 20 | } 21 | 22 | public async register( 23 | productEntity: Product, 24 | sku?: string, 25 | ): Promise { 26 | if (sku) { 27 | return await this.update(productEntity, sku); 28 | } else { 29 | return await this.create(productEntity); 30 | } 31 | } 32 | 33 | private async create(productEntity: Product): Promise { 34 | try { 35 | const product = this.productRepository.create(productEntity); 36 | return await this.productRepository.save(product); 37 | } catch (e) { 38 | return new Error(e); 39 | } 40 | } 41 | 42 | private async update( 43 | productEntity: Product, 44 | sku: string, 45 | ): Promise { 46 | try { 47 | await this.productRepository.update({ sku }, productEntity); 48 | return this.productRepository.findOne({ sku }); 49 | } catch (e) { 50 | return new Error(e); 51 | } 52 | } 53 | 54 | public async removeProduct(sku: string): Promise { 55 | try { 56 | const product = this.productRepository.findOne({ sku }); 57 | await this.productRepository.delete({ sku }); 58 | 59 | return product; 60 | } catch (e) { 61 | return new Error(e); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | async function bootstrap() { 5 | const app = await NestFactory.create(AppModule); 6 | await app.listen(3000); 7 | } 8 | bootstrap(); 9 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as request from 'supertest'; 3 | import { AppModule } from './../src/app.module'; 4 | 5 | describe('AppController (e2e)', () => { 6 | let app; 7 | 8 | beforeEach(async () => { 9 | const moduleFixture: TestingModule = await Test.createTestingModule({ 10 | imports: [AppModule], 11 | }).compile(); 12 | 13 | app = moduleFixture.createNestApplication(); 14 | await app.init(); 15 | }); 16 | 17 | it('/ (GET)', () => { 18 | return request(app.getHttpServer()) 19 | .get('/') 20 | .expect(200) 21 | .expect('Hello World!'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es6", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true 13 | }, 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single"], 9 | "member-access": [false], 10 | "ordered-imports": [false], 11 | "max-line-length": [true, 150], 12 | "member-ordering": [false], 13 | "interface-name": [false], 14 | "arrow-parens": false, 15 | "object-literal-sort-keys": false 16 | }, 17 | "rulesDirectory": [] 18 | } 19 | --------------------------------------------------------------------------------