├── .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 |
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 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
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 |
--------------------------------------------------------------------------------