├── .eslintrc.js ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .prettierrc ├── README.md ├── apps ├── gateway │ ├── src │ │ ├── app.module.ts │ │ └── main.ts │ ├── test │ │ └── jest-e2e.json │ └── tsconfig.app.json ├── service-account │ ├── src │ │ ├── account │ │ │ ├── account.module.ts │ │ │ ├── cqrs │ │ │ │ ├── command │ │ │ │ │ ├── handler │ │ │ │ │ │ └── create-account.handler.ts │ │ │ │ │ └── impl │ │ │ │ │ │ └── create-account.command.ts │ │ │ │ └── saga │ │ │ │ │ └── create-user.saga.ts │ │ │ ├── input │ │ │ │ └── create-account.input.ts │ │ │ ├── model │ │ │ │ ├── account.model.ts │ │ │ │ └── user.model.ts │ │ │ ├── resolver │ │ │ │ ├── account.resolver.ts │ │ │ │ └── user.resolver.ts │ │ │ └── service │ │ │ │ └── account.service.ts │ │ ├── app.module.ts │ │ └── main.ts │ ├── test │ │ └── jest-e2e.json │ └── tsconfig.app.json └── service-user │ ├── src │ ├── app.module.ts │ ├── main.ts │ └── user │ │ ├── cqrs │ │ ├── command │ │ │ ├── handler │ │ │ │ ├── activate-user.handler.ts │ │ │ │ └── create-user.handler.ts │ │ │ └── impl │ │ │ │ ├── activate-user.command.ts │ │ │ │ └── create-user.command.ts │ │ └── saga │ │ │ └── create-user.saga.ts │ │ ├── input │ │ └── create-user.input.ts │ │ ├── model │ │ └── user.model.ts │ │ ├── resolver │ │ └── user.resolver.ts │ │ ├── service │ │ └── user.service.ts │ │ └── user.module.ts │ ├── test │ └── jest-e2e.json │ └── tsconfig.app.json ├── libs └── common │ ├── src │ ├── event │ │ ├── account-created.event.ts │ │ ├── user-activated.event.ts │ │ └── user-created.event.ts │ └── interface │ │ ├── account.interface.ts │ │ └── user.interface.ts │ └── tsconfig.lib.json ├── nest-cli.json ├── package-lock.json ├── package.json ├── renovate.json ├── tsconfig.build.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/no-explicit-any': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | paths-ignore: 10 | - 'README.md' 11 | pull_request: 12 | branches: [master] 13 | paths-ignore: 14 | - 'README.md' 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Use Node.js 14.x 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: 14.x 26 | cache: 'npm' 27 | - run: npm install # cannot use 'npm ci' bcos of @juicycleff/nestjs-event-store 28 | - run: npm run lint 29 | - run: npm run build service-user 30 | - run: npm run build service-account 31 | - run: npm run build gateway 32 | - run: npm test -- --passWithNoTests 33 | env: 34 | CI: true 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.vscode 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS CQRS Microservices Starter 2 | 3 | ## Description 4 | 5 | A starter project featuring an advanced microservice pattern with GraphQL, based on Domain-Driven Design (DDD) using the command query responsibility segregation (CQRS) design pattern. 6 | 7 | ## Technologies 8 | 9 | - [GraphQL](https://graphql.org/) 10 | - [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/) 11 | - [NestJS](https://docs.nestjs.com/) 12 | - [NestJS GraphQL](https://docs.nestjs.com/graphql/quick-start) 13 | - [NestJS Federation](https://docs.nestjs.com/graphql/federation) 14 | - [NestJS TypeORM](https://docs.nestjs.com/techniques/database) 15 | - [NestJS CQRS](https://docs.nestjs.com/recipes/cqrs) 16 | - [NestJS Event Store](https://github.com/juicycleff/nestjs-event-store) 17 | 18 | ## Installation 19 | 20 | **Please use the `nest-v7` branch, the `master` branch still not working properly.** 21 | 22 | ```bash 23 | git clone -b nest-v7 https://github.com/hardyscc/nestjs-cqrs-starter.git 24 | cd 25 | 26 | npm install 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### Start MySQL 32 | 33 | Start MySQL docker instance. 34 | 35 | ```bash 36 | docker run -d -e "MYSQL_ROOT_PASSWORD=Admin12345" -e "MYSQL_USER=usr" -e "MYSQL_PASSWORD=User12345" -e "MYSQL_DATABASE=development" -e "MYSQL_AUTHENTICATION_PLUGIN=mysql_native_password" -p 3306:3306 --name some-mysql bitnami/mysql:8.0.19 37 | ``` 38 | 39 | Connect using MySQL docker instance command line. 40 | 41 | ```bash 42 | docker exec -it some-mysql mysql -uroot -p"Admin12345" 43 | ``` 44 | 45 | Create the Databases for testing 46 | 47 | ```sql 48 | CREATE DATABASE service_user; 49 | GRANT ALL PRIVILEGES ON service_user.* TO 'usr'@'%'; 50 | 51 | CREATE DATABASE service_account; 52 | GRANT ALL PRIVILEGES ON service_account.* TO 'usr'@'%'; 53 | FLUSH PRIVILEGES; 54 | ``` 55 | 56 | Clean up all data if needed to re-testing again 57 | 58 | ```sql 59 | DELETE FROM service_account.ACCOUNT; 60 | DELETE FROM service_user.USER; 61 | ``` 62 | 63 | ### Start EventStore 64 | 65 | ```bash 66 | docker run --name some-eventstore -d -p 2113:2113 -p 1113:1113 eventstore/eventstore:release-5.0.9 67 | ``` 68 | 69 | Create the Persistent Subscriptions 70 | 71 | ```bash 72 | curl -L -X PUT "http://localhost:2113/subscriptions/%24svc-user/account" \ 73 | -H "Content-Type: application/json" \ 74 | -H "Authorization: Basic YWRtaW46Y2hhbmdlaXQ=" \ 75 | -d "{}" 76 | 77 | curl -L -X PUT "http://localhost:2113/subscriptions/%24svc-account/user" \ 78 | -H "Content-Type: application/json" \ 79 | -H "Authorization: Basic YWRtaW46Y2hhbmdlaXQ=" \ 80 | -d "{}" 81 | ``` 82 | 83 | ### Start the microservices 84 | 85 | ```bash 86 | # Start the user service 87 | nest start service-user 88 | 89 | # Start the account service 90 | nest start service-account 91 | 92 | # start the gateway 93 | nest start gateway 94 | ``` 95 | 96 | ## Testing 97 | 98 | Goto GraphQL Playground - http://localhost:3000/graphql 99 | 100 | ### Create a user with a default saving account 101 | 102 | ```graphql 103 | mutation { 104 | createUser(input: { name: "John" }) { 105 | id 106 | name 107 | } 108 | } 109 | ``` 110 | 111 | OR 112 | 113 | ```bash 114 | curl -X POST -H 'Content-Type: application/json' \ 115 | -d '{"query": "mutation { createUser(input: { name: \"John\" }) { id name } }"}' \ 116 | http://localhost:3000/graphql 117 | ``` 118 | 119 | You should see something like this 120 | 121 | 1. Under `service-user` console 122 | 123 | ```sql 124 | Async CreateUserHandler... CreateUserCommand 125 | query: START TRANSACTION 126 | query: INSERT INTO `USER`(`id`, `name`, `nickName`, `status`) VALUES (?, ?, DEFAULT, DEFAULT) -- PARAMETERS: ["4d04689b-ef40-4a08-8a27-6fa420790ddb","John"] 127 | query: SELECT `User`.`id` AS `User_id`, `User`.`status` AS `User_status` FROM `USER` `User` WHERE `User`.`id` = ? -- PARAMETERS: ["4d04689b-ef40-4a08-8a27-6fa420790ddb"] 128 | query: COMMIT 129 | Async ActivateUserHandler... ActivateUserCommand 130 | query: UPDATE `USER` SET `status` = ? WHERE `id` IN (?) -- PARAMETERS: ["A","4d04689b-ef40-4a08-8a27-6fa420790ddb"] 131 | ``` 132 | 133 | 1. under `service-account` console 134 | 135 | ```sql 136 | Async CreateAccountHandler... CreateAccountCommand 137 | query: START TRANSACTION 138 | query: INSERT INTO `ACCOUNT`(`id`, `name`, `balance`, `userId`) VALUES (?, ?, DEFAULT, ?) -- PARAMETERS: ["57c3cc9e-4aa9-4ea8-8c7f-5d4653ee709f","Saving","4d04689b-ef40-4a08-8a27-6fa420790ddb"] 139 | query: SELECT `Account`.`id` AS `Account_id`, `Account`.`balance` AS `Account_balance` FROM `ACCOUNT` `Account` WHERE `Account`.`id` = ? -- PARAMETERS: ["57c3cc9e-4aa9-4ea8-8c7f-5d4653ee709f"] 140 | query: COMMIT 141 | ``` 142 | 143 | ### Query the users 144 | 145 | ```graphql 146 | query { 147 | users { 148 | id 149 | name 150 | accounts { 151 | id 152 | name 153 | balance 154 | } 155 | } 156 | } 157 | ``` 158 | 159 | OR 160 | 161 | ```bash 162 | curl -X POST -H 'Content-Type: application/json' \ 163 | -d '{"query": "query { users { id name accounts { id name balance } } }"}' \ 164 | http://localhost:3000/graphql 165 | ``` 166 | 167 | Output : 168 | 169 | ```JSON 170 | { 171 | "data": { 172 | "users": [ 173 | { 174 | "id": "4d04689b-ef40-4a08-8a27-6fa420790ddb", 175 | "name": "John", 176 | "accounts": [ 177 | { 178 | "id": "57c3cc9e-4aa9-4ea8-8c7f-5d4653ee709f", 179 | "name": "Saving", 180 | "balance": 0 181 | } 182 | ] 183 | } 184 | ] 185 | } 186 | } 187 | ``` 188 | -------------------------------------------------------------------------------- /apps/gateway/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { GraphQLGatewayModule } from '@nestjs/graphql'; 3 | 4 | @Module({ 5 | imports: [ 6 | GraphQLGatewayModule.forRoot({ 7 | gateway: { 8 | serviceList: [ 9 | { 10 | name: 'user', 11 | url: process.env.USER_ENDPOINT || 'http://localhost:4001/graphql', 12 | }, 13 | { 14 | name: 'account', 15 | url: 16 | process.env.ACCOUNT_ENDPOINT || 'http://localhost:4002/graphql', 17 | }, 18 | ], 19 | }, 20 | }), 21 | ], 22 | }) 23 | export class AppModule {} 24 | -------------------------------------------------------------------------------- /apps/gateway/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 | -------------------------------------------------------------------------------- /apps/gateway/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 | -------------------------------------------------------------------------------- /apps/gateway/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../../dist/apps/gateway" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/service-account/src/account/account.module.ts: -------------------------------------------------------------------------------- 1 | import { UserActivatedEvent } from '@hardyscc/common/event/user-activated.event'; 2 | import { UserCreatedEvent } from '@hardyscc/common/event/user-created.event'; 3 | import { 4 | EventStoreModule, 5 | EventStoreSubscriptionType, 6 | } from '@juicycleff/nestjs-event-store'; 7 | import { Module } from '@nestjs/common'; 8 | import { CqrsModule } from '@nestjs/cqrs'; 9 | import { TypeOrmModule } from '@nestjs/typeorm'; 10 | import { CreateAccountHandler } from './cqrs/command/handler/create-account.handler'; 11 | import { CreateUserSaga } from './cqrs/saga/create-user.saga'; 12 | import { Account } from './model/account.model'; 13 | import { AccountResolver } from './resolver/account.resolver'; 14 | import { UserResolver } from './resolver/user.resolver'; 15 | import { AccountService } from './service/account.service'; 16 | 17 | const CommandHandlers = [CreateAccountHandler]; 18 | const Sagas = [CreateUserSaga]; 19 | 20 | @Module({ 21 | imports: [ 22 | CqrsModule, 23 | EventStoreModule.registerFeature({ 24 | type: 'event-store', 25 | featureStreamName: '$svc-account', 26 | subscriptions: [ 27 | { 28 | type: EventStoreSubscriptionType.Persistent, 29 | stream: '$svc-user', 30 | persistentSubscriptionName: 'account', 31 | }, 32 | ], 33 | eventHandlers: { 34 | UserCreatedEvent: (data) => new UserCreatedEvent(data), 35 | UserActivatedEvent: (data) => new UserActivatedEvent(data), 36 | }, 37 | }), 38 | TypeOrmModule.forFeature([Account]), 39 | ], 40 | providers: [ 41 | AccountService, 42 | AccountResolver, 43 | UserResolver, 44 | ...CommandHandlers, 45 | ...Sagas, 46 | ], 47 | }) 48 | export class AccountModule {} 49 | -------------------------------------------------------------------------------- /apps/service-account/src/account/cqrs/command/handler/create-account.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; 2 | import { AccountService } from '../../../service/account.service'; 3 | import { CreateAccountCommand } from '../impl/create-account.command'; 4 | 5 | @CommandHandler(CreateAccountCommand) 6 | export class CreateAccountHandler 7 | implements ICommandHandler { 8 | constructor( 9 | private readonly accountService: AccountService, 10 | private readonly publisher: EventPublisher, 11 | ) {} 12 | 13 | async execute(command: CreateAccountCommand) { 14 | console.log(`Async ${this.constructor.name}...`, command.constructor.name); 15 | const { input } = command; 16 | const account = this.publisher.mergeObjectContext( 17 | await this.accountService.create(input), 18 | ); 19 | account.createAccount(); 20 | account.commit(); 21 | return account; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/service-account/src/account/cqrs/command/impl/create-account.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from '@nestjs/cqrs'; 2 | import { CreateAccountInput } from '../../../input/create-account.input'; 3 | 4 | export class CreateAccountCommand implements ICommand { 5 | constructor(public readonly input: CreateAccountInput) {} 6 | } 7 | -------------------------------------------------------------------------------- /apps/service-account/src/account/cqrs/saga/create-user.saga.ts: -------------------------------------------------------------------------------- 1 | import { UserCreatedEvent } from '@hardyscc/common/event/user-created.event'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { ICommand, ofType, Saga } from '@nestjs/cqrs'; 4 | import { Observable } from 'rxjs'; 5 | import { map } from 'rxjs/operators'; 6 | import { CreateAccountCommand } from '../command/impl/create-account.command'; 7 | 8 | @Injectable() 9 | export class CreateUserSaga { 10 | @Saga() 11 | userCreated = (events$: Observable): Observable => { 12 | return events$.pipe( 13 | ofType(UserCreatedEvent), 14 | map( 15 | (event) => 16 | new CreateAccountCommand({ name: 'Saving', userId: event.user.id }), 17 | ), 18 | ); 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /apps/service-account/src/account/input/create-account.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, InputType } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class CreateAccountInput { 5 | @Field() 6 | name: string; 7 | 8 | @Field(() => ID) 9 | userId: string; 10 | } 11 | -------------------------------------------------------------------------------- /apps/service-account/src/account/model/account.model.ts: -------------------------------------------------------------------------------- 1 | import { AccountCreatedEvent } from '@hardyscc/common/event/account-created.event'; 2 | import { IAccount } from '@hardyscc/common/interface/account.interface'; 3 | import { AggregateRoot } from '@nestjs/cqrs'; 4 | import { Directive, Field, ID, ObjectType } from '@nestjs/graphql'; 5 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 6 | 7 | @Directive(`@key(fields: "id")`) 8 | @ObjectType() 9 | @Entity('ACCOUNT') 10 | export class Account extends AggregateRoot implements IAccount { 11 | @Field(() => ID) 12 | @PrimaryGeneratedColumn('uuid') 13 | id: string; 14 | 15 | @Field() 16 | @Column() 17 | name: string; 18 | 19 | @Field() 20 | @Column({ default: 0 }) 21 | balance: number; 22 | 23 | @Field(() => ID) 24 | @Column() 25 | userId: string; 26 | 27 | createAccount() { 28 | this.apply(new AccountCreatedEvent(this)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/service-account/src/account/model/user.model.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Field, ID, ObjectType } from '@nestjs/graphql'; 2 | 3 | @Directive('@extends') 4 | @Directive(`@key(fields: "id")`) 5 | @ObjectType() 6 | export class User { 7 | @Directive('@external') 8 | @Field(() => ID) 9 | id: string; 10 | } 11 | -------------------------------------------------------------------------------- /apps/service-account/src/account/resolver/account.resolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Args, 3 | Mutation, 4 | Parent, 5 | ResolveProperty, 6 | Resolver, 7 | } from '@nestjs/graphql'; 8 | import { CreateAccountInput } from '../input/create-account.input'; 9 | import { Account } from '../model/account.model'; 10 | import { User } from '../model/user.model'; 11 | import { AccountService } from '../service/account.service'; 12 | 13 | @Resolver(() => Account) 14 | export class AccountResolver { 15 | constructor(private readonly accountService: AccountService) {} 16 | 17 | @Mutation(() => Account) 18 | async createAccount(@Args('input') input: CreateAccountInput) { 19 | return this.accountService.create(input); 20 | } 21 | 22 | @ResolveProperty(() => User) 23 | user(@Parent() account: Account) { 24 | return { __typename: 'User', id: account.userId }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/service-account/src/account/resolver/user.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Parent, ResolveProperty, Resolver } from '@nestjs/graphql'; 2 | import { Account } from '../model/account.model'; 3 | import { User } from '../model/user.model'; 4 | import { AccountService } from '../service/account.service'; 5 | 6 | @Resolver(() => User) 7 | export class UserResolver { 8 | constructor(private readonly accountService: AccountService) {} 9 | 10 | @ResolveProperty(() => [Account]) 11 | accounts(@Parent() user: User) { 12 | return this.accountService.findByUserId(user.id); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/service-account/src/account/service/account.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { CreateAccountInput } from '../input/create-account.input'; 5 | import { Account } from '../model/account.model'; 6 | 7 | @Injectable() 8 | export class AccountService { 9 | constructor( 10 | @InjectRepository(Account) 11 | private readonly accountRepository: Repository, 12 | ) {} 13 | 14 | create(input: CreateAccountInput) { 15 | const account = this.accountRepository.create(input); 16 | return this.accountRepository.save(account); 17 | } 18 | 19 | findByUserId(userId: string) { 20 | return this.accountRepository.find({ where: { userId } }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/service-account/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { EventStoreModule } from '@juicycleff/nestjs-event-store'; 2 | import { Module } from '@nestjs/common'; 3 | import { GraphQLFederationModule } from '@nestjs/graphql'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { AccountModule } from './account/account.module'; 6 | import { Account } from './account/model/account.model'; 7 | 8 | const databaseUrl = 9 | process.env.DATABASE_URL || 10 | 'mysql://usr:User12345@localhost:3306/service_account'; 11 | 12 | @Module({ 13 | imports: [ 14 | GraphQLFederationModule.forRoot({ 15 | autoSchemaFile: true, 16 | }), 17 | EventStoreModule.register({ 18 | type: 'event-store', 19 | tcpEndpoint: { 20 | host: 'localhost', 21 | port: 1113, 22 | }, 23 | options: { 24 | defaultUserCredentials: { 25 | username: 'admin', 26 | password: 'changeit', 27 | }, 28 | }, 29 | }), 30 | TypeOrmModule.forRoot({ 31 | type: 'mysql', 32 | url: databaseUrl, 33 | database: databaseUrl.split('/').pop(), 34 | entities: [Account], 35 | synchronize: true, 36 | logging: true, 37 | }), 38 | AccountModule, 39 | ], 40 | }) 41 | export class AppModule {} 42 | -------------------------------------------------------------------------------- /apps/service-account/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(4002); 7 | } 8 | bootstrap(); 9 | -------------------------------------------------------------------------------- /apps/service-account/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 | -------------------------------------------------------------------------------- /apps/service-account/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../../dist/apps/service-account" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/service-user/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { EventStoreModule } from '@juicycleff/nestjs-event-store'; 2 | import { Module } from '@nestjs/common'; 3 | import { GraphQLFederationModule } from '@nestjs/graphql'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { User } from './user/model/user.model'; 6 | import { UserModule } from './user/user.module'; 7 | 8 | const databaseUrl = 9 | process.env.DATABASE_URL || 10 | 'mysql://usr:User12345@localhost:3306/service_user'; 11 | 12 | @Module({ 13 | imports: [ 14 | GraphQLFederationModule.forRoot({ 15 | autoSchemaFile: true, 16 | }), 17 | EventStoreModule.register({ 18 | type: 'event-store', 19 | tcpEndpoint: { 20 | host: 'localhost', 21 | port: 1113, 22 | }, 23 | options: { 24 | defaultUserCredentials: { 25 | username: 'admin', 26 | password: 'changeit', 27 | }, 28 | }, 29 | }), 30 | TypeOrmModule.forRoot({ 31 | type: 'mysql', 32 | url: databaseUrl, 33 | database: databaseUrl.split('/').pop(), 34 | entities: [User], 35 | synchronize: true, 36 | logging: true, 37 | }), 38 | UserModule, 39 | ], 40 | }) 41 | export class AppModule {} 42 | -------------------------------------------------------------------------------- /apps/service-user/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(4001); 7 | } 8 | bootstrap(); 9 | -------------------------------------------------------------------------------- /apps/service-user/src/user/cqrs/command/handler/activate-user.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; 2 | import { UserService } from '../../../service/user.service'; 3 | import { ActivateUserCommand } from '../impl/activate-user.command'; 4 | 5 | @CommandHandler(ActivateUserCommand) 6 | export class ActivateUserHandler 7 | implements ICommandHandler { 8 | constructor( 9 | private readonly userService: UserService, 10 | private readonly publisher: EventPublisher, 11 | ) {} 12 | 13 | async execute(command: ActivateUserCommand) { 14 | console.log(`Async ${this.constructor.name}...`, command.constructor.name); 15 | const { id } = command; 16 | const user = this.publisher.mergeObjectContext( 17 | await this.userService.activate(id), 18 | ); 19 | user.activateUser(); 20 | user.commit(); 21 | return user; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/service-user/src/user/cqrs/command/handler/create-user.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; 2 | import { UserService } from '../../../service/user.service'; 3 | import { CreateUserCommand } from '../impl/create-user.command'; 4 | 5 | @CommandHandler(CreateUserCommand) 6 | export class CreateUserHandler implements ICommandHandler { 7 | constructor( 8 | private readonly userService: UserService, 9 | private readonly publisher: EventPublisher, 10 | ) {} 11 | 12 | async execute(command: CreateUserCommand) { 13 | console.log(`Async ${this.constructor.name}...`, command.constructor.name); 14 | const { input } = command; 15 | const user = this.publisher.mergeObjectContext( 16 | await this.userService.create(input), 17 | ); 18 | user.createUser(); 19 | user.commit(); 20 | return user; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/service-user/src/user/cqrs/command/impl/activate-user.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from '@nestjs/cqrs'; 2 | 3 | export class ActivateUserCommand implements ICommand { 4 | constructor(public readonly id: string) {} 5 | } 6 | -------------------------------------------------------------------------------- /apps/service-user/src/user/cqrs/command/impl/create-user.command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from '@nestjs/cqrs'; 2 | import { CreateUserInput } from '../../../input/create-user.input'; 3 | 4 | export class CreateUserCommand implements ICommand { 5 | constructor(public readonly input: CreateUserInput) {} 6 | } 7 | -------------------------------------------------------------------------------- /apps/service-user/src/user/cqrs/saga/create-user.saga.ts: -------------------------------------------------------------------------------- 1 | import { AccountCreatedEvent } from '@hardyscc/common/event/account-created.event'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { ICommand, ofType, Saga } from '@nestjs/cqrs'; 4 | import { Observable } from 'rxjs'; 5 | import { map } from 'rxjs/operators'; 6 | import { ActivateUserCommand } from '../command/impl/activate-user.command'; 7 | 8 | @Injectable() 9 | export class CreateUserSaga { 10 | @Saga() 11 | accountCreated = (events$: Observable): Observable => { 12 | return events$.pipe( 13 | ofType(AccountCreatedEvent), 14 | map((event) => new ActivateUserCommand(event.account.userId)), 15 | ); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /apps/service-user/src/user/input/create-user.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class CreateUserInput { 5 | @Field() 6 | name: string; 7 | 8 | @Field({ nullable: true }) 9 | nickName?: string; 10 | } 11 | -------------------------------------------------------------------------------- /apps/service-user/src/user/model/user.model.ts: -------------------------------------------------------------------------------- 1 | import { UserActivatedEvent } from '@hardyscc/common/event/user-activated.event'; 2 | import { UserCreatedEvent } from '@hardyscc/common/event/user-created.event'; 3 | import { IUser, UserStatus } from '@hardyscc/common/interface/user.interface'; 4 | import { AggregateRoot } from '@nestjs/cqrs'; 5 | import { Directive, Field, ID, ObjectType } from '@nestjs/graphql'; 6 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 7 | 8 | @Directive(`@key(fields: "id")`) 9 | @ObjectType() 10 | @Entity('USER') 11 | export class User extends AggregateRoot implements IUser { 12 | @Field(() => ID) 13 | @PrimaryGeneratedColumn('uuid') 14 | id: string; 15 | 16 | @Field() 17 | @Column() 18 | name: string; 19 | 20 | @Field({ nullable: true }) 21 | @Column({ nullable: true }) 22 | nickName?: string; 23 | 24 | @Column({ type: 'enum', enum: UserStatus, default: UserStatus.PENDING }) 25 | status: UserStatus; 26 | 27 | createUser() { 28 | this.apply(new UserCreatedEvent(this)); 29 | } 30 | 31 | activateUser() { 32 | this.apply(new UserActivatedEvent(this)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/service-user/src/user/resolver/user.resolver.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | import { CommandBus } from '@nestjs/cqrs'; 3 | import { 4 | Args, 5 | Mutation, 6 | Query, 7 | Resolver, 8 | ResolveReference, 9 | } from '@nestjs/graphql'; 10 | import { CreateUserCommand } from '../cqrs/command/impl/create-user.command'; 11 | import { CreateUserInput } from '../input/create-user.input'; 12 | import { User } from '../model/user.model'; 13 | import { UserService } from '../service/user.service'; 14 | 15 | @Resolver(() => User) 16 | export class UserResolver { 17 | constructor( 18 | private readonly userService: UserService, 19 | private readonly commandBus: CommandBus, 20 | ) {} 21 | 22 | @Query(() => User) 23 | async user(@Args('id') id: string) { 24 | const user = await this.userService.findOneById(id); 25 | if (!user) { 26 | throw new NotFoundException(id); 27 | } 28 | return user; 29 | } 30 | 31 | @Mutation(() => User) 32 | async createUser(@Args('input') input: CreateUserInput) { 33 | return await this.commandBus.execute(new CreateUserCommand(input)); 34 | } 35 | 36 | @Mutation(() => Boolean) 37 | removeUser(@Args('id') id: string) { 38 | return this.userService.remove(id); 39 | } 40 | 41 | @Query(() => [User]) 42 | users() { 43 | return this.userService.find(); 44 | } 45 | 46 | @ResolveReference() 47 | resolveReference(reference: { __typename: string; id: string }) { 48 | return this.userService.findOneById(reference.id); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/service-user/src/user/service/user.service.ts: -------------------------------------------------------------------------------- 1 | import { UserStatus } from '@hardyscc/common/interface/user.interface'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { Repository } from 'typeorm'; 5 | import { CreateUserInput } from '../input/create-user.input'; 6 | import { User } from '../model/user.model'; 7 | 8 | @Injectable() 9 | export class UserService { 10 | constructor( 11 | @InjectRepository(User) 12 | private readonly userRepository: Repository, 13 | ) {} 14 | 15 | create(input: CreateUserInput) { 16 | const user = this.userRepository.create(input); 17 | return this.userRepository.save(user); 18 | } 19 | 20 | async activate(id: string) { 21 | await this.userRepository.update(id, { status: UserStatus.ACTIVE }); 22 | return this.findOneById(id); 23 | } 24 | 25 | findOneById(id: string) { 26 | return this.userRepository.findOneOrFail(id); 27 | } 28 | 29 | async remove(id: string) { 30 | const { affected } = await this.userRepository.delete(id); 31 | return affected === 1; 32 | } 33 | 34 | find() { 35 | return this.userRepository.find({ where: { status: UserStatus.ACTIVE } }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/service-user/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { AccountCreatedEvent } from '@hardyscc/common/event/account-created.event'; 2 | import { 3 | EventStoreModule, 4 | EventStoreSubscriptionType, 5 | } from '@juicycleff/nestjs-event-store'; 6 | import { Module } from '@nestjs/common'; 7 | import { CqrsModule } from '@nestjs/cqrs'; 8 | import { TypeOrmModule } from '@nestjs/typeorm'; 9 | import { ActivateUserHandler } from './cqrs/command/handler/activate-user.handler'; 10 | import { CreateUserHandler } from './cqrs/command/handler/create-user.handler'; 11 | import { CreateUserSaga } from './cqrs/saga/create-user.saga'; 12 | import { User } from './model/user.model'; 13 | import { UserResolver } from './resolver/user.resolver'; 14 | import { UserService } from './service/user.service'; 15 | 16 | const CommandHandlers = [CreateUserHandler, ActivateUserHandler]; 17 | const Sagas = [CreateUserSaga]; 18 | 19 | @Module({ 20 | imports: [ 21 | CqrsModule, 22 | EventStoreModule.registerFeature({ 23 | type: 'event-store', 24 | featureStreamName: '$svc-user', 25 | subscriptions: [ 26 | { 27 | type: EventStoreSubscriptionType.Persistent, 28 | stream: '$svc-account', 29 | persistentSubscriptionName: 'user', 30 | }, 31 | ], 32 | eventHandlers: { 33 | AccountCreatedEvent: (data) => new AccountCreatedEvent(data), 34 | }, 35 | }), 36 | TypeOrmModule.forFeature([User]), 37 | ], 38 | providers: [UserService, UserResolver, ...CommandHandlers, ...Sagas], 39 | }) 40 | export class UserModule {} 41 | -------------------------------------------------------------------------------- /apps/service-user/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 | -------------------------------------------------------------------------------- /apps/service-user/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../../dist/apps/service-user" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /libs/common/src/event/account-created.event.ts: -------------------------------------------------------------------------------- 1 | import { IEvent } from '@nestjs/cqrs'; 2 | import { IAccount } from '../interface/account.interface'; 3 | 4 | export class AccountCreatedEvent implements IEvent { 5 | constructor(public readonly account: IAccount) {} 6 | } 7 | -------------------------------------------------------------------------------- /libs/common/src/event/user-activated.event.ts: -------------------------------------------------------------------------------- 1 | import { IEvent } from '@nestjs/cqrs'; 2 | import { IUser } from '../interface/user.interface'; 3 | 4 | export class UserActivatedEvent implements IEvent { 5 | constructor(public readonly user: IUser) {} 6 | } 7 | -------------------------------------------------------------------------------- /libs/common/src/event/user-created.event.ts: -------------------------------------------------------------------------------- 1 | import { IEvent } from '@nestjs/cqrs'; 2 | import { IUser } from '../interface/user.interface'; 3 | 4 | export class UserCreatedEvent implements IEvent { 5 | constructor(public readonly user: IUser) {} 6 | } 7 | -------------------------------------------------------------------------------- /libs/common/src/interface/account.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IAccount { 2 | id: string; 3 | name: string; 4 | balance: number; 5 | userId: string; 6 | } 7 | -------------------------------------------------------------------------------- /libs/common/src/interface/user.interface.ts: -------------------------------------------------------------------------------- 1 | export enum UserStatus { 2 | PENDING = 'P', 3 | ACTIVE = 'A', 4 | DELETED = 'D', 5 | } 6 | 7 | export interface IUser { 8 | id: string; 9 | name: string; 10 | nickName?: string; 11 | status: UserStatus; 12 | } 13 | -------------------------------------------------------------------------------- /libs/common/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/common" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "apps/gateway/src", 4 | "monorepo": true, 5 | "root": "apps/gateway", 6 | "compilerOptions": { 7 | "webpack": true, 8 | "tsConfigPath": "apps/gateway/tsconfig.app.json" 9 | }, 10 | "projects": { 11 | "gateway": { 12 | "type": "application", 13 | "root": "apps/gateway", 14 | "entryFile": "main", 15 | "sourceRoot": "apps/gateway/src", 16 | "compilerOptions": { 17 | "tsConfigPath": "apps/gateway/tsconfig.app.json" 18 | } 19 | }, 20 | "service-user": { 21 | "type": "application", 22 | "root": "apps/service-user", 23 | "entryFile": "main", 24 | "sourceRoot": "apps/service-user/src", 25 | "compilerOptions": { 26 | "tsConfigPath": "apps/service-user/tsconfig.app.json" 27 | } 28 | }, 29 | "service-account": { 30 | "type": "application", 31 | "root": "apps/service-account", 32 | "entryFile": "main", 33 | "sourceRoot": "apps/service-account/src", 34 | "compilerOptions": { 35 | "tsConfigPath": "apps/service-account/tsconfig.app.json" 36 | } 37 | }, 38 | "common": { 39 | "type": "library", 40 | "root": "libs/common", 41 | "entryFile": "index", 42 | "sourceRoot": "libs/common/src", 43 | "compilerOptions": { 44 | "tsConfigPath": "libs/common/tsconfig.lib.json" 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-cqrs-starter", 3 | "version": "0.0.1", 4 | "description": "NestJS CQRS Microservices Starter", 5 | "author": "Hardys", 6 | "license": "MIT", 7 | "scripts": { 8 | "prebuild": "rimraf dist", 9 | "build": "nest build", 10 | "format": "prettier --write \"{apps,libs}/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings 0", 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 ./apps/gateway/test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@juicycleff/nestjs-event-store": "3.1.18", 24 | "@nestjs/common": "8.4.7", 25 | "@nestjs/core": "8.4.7", 26 | "@nestjs/cqrs": "8.0.5", 27 | "@nestjs/graphql": "9.1.2", 28 | "@nestjs/platform-express": "8.4.7", 29 | "@nestjs/typeorm": "8.1.4", 30 | "apollo-server-core": "3.9.0", 31 | "apollo-server-express": "3.9.0", 32 | "graphql": "16.5.0", 33 | "graphql-tools": "8.2.12", 34 | "mysql": "2.18.1", 35 | "node-eventstore-client": "0.2.18", 36 | "node-nats-streaming": "0.3.2", 37 | "reflect-metadata": "0.1.13", 38 | "rimraf": "3.0.2", 39 | "rxjs": "7.5.5", 40 | "type-graphql": "1.1.1", 41 | "typeorm": "0.2.45" 42 | }, 43 | "devDependencies": { 44 | "@commitlint/cli": "17.0.2", 45 | "@commitlint/config-conventional": "17.0.2", 46 | "@nestjs/cli": "8.2.6", 47 | "@nestjs/schematics": "8.0.11", 48 | "@nestjs/testing": "8.4.7", 49 | "@types/express": "4.17.13", 50 | "@types/jest": "28.1.2", 51 | "@types/node": "16.11.41", 52 | "@types/supertest": "2.0.12", 53 | "@typescript-eslint/eslint-plugin": "5.29.0", 54 | "@typescript-eslint/parser": "5.29.0", 55 | "eslint": "8.18.0", 56 | "eslint-config-prettier": "8.5.0", 57 | "eslint-plugin-import": "2.26.0", 58 | "husky": "8.0.1", 59 | "jest": "28.1.1", 60 | "lint-staged": "13.0.2", 61 | "prettier": "2.7.1", 62 | "supertest": "6.2.3", 63 | "ts-jest": "28.0.5", 64 | "ts-loader": "9.3.0", 65 | "ts-node": "10.8.1", 66 | "tsconfig-paths": "4.0.0", 67 | "typescript": "4.7.4" 68 | }, 69 | "jest": { 70 | "moduleFileExtensions": [ 71 | "js", 72 | "json", 73 | "ts" 74 | ], 75 | "rootDir": ".", 76 | "testRegex": ".spec.ts$", 77 | "transform": { 78 | ".+\\.(t|j)s$": "ts-jest" 79 | }, 80 | "coverageDirectory": "./coverage", 81 | "testEnvironment": "node", 82 | "roots": [ 83 | "/apps/", 84 | "/libs/" 85 | ], 86 | "moduleNameMapper": { 87 | "@hardyscc/common/(.*)": "/libs/common/src/$1", 88 | "@hardyscc/common": "/libs/common/src" 89 | } 90 | }, 91 | "lint-staged": { 92 | "*.ts": [ 93 | "prettier --write" 94 | ] 95 | }, 96 | "commitlint": { 97 | "extends": [ 98 | "@commitlint/config-conventional" 99 | ] 100 | }, 101 | "husky": { 102 | "hooks": { 103 | "pre-commit": "lint-staged", 104 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "timezone": "Asia/Hong_Kong", 3 | "schedule": "before 6am", 4 | "automerge": true, 5 | "extends": ["config:base"] 6 | } 7 | -------------------------------------------------------------------------------- /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": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true, 13 | "paths": { 14 | "@hardyscc/common": [ 15 | "libs/common/src" 16 | ], 17 | "@hardyscc/common/*": [ 18 | "libs/common/src/*" 19 | ] 20 | } 21 | }, 22 | "exclude": [ 23 | "node_modules", 24 | "dist" 25 | ] 26 | } --------------------------------------------------------------------------------