├── .eslintignore
├── .eslintrc.yml
├── .gitignore
├── .prettierrc
├── CHANGELOG.md
├── Dockerfile
├── LICENSE.md
├── Makefile
├── README.md
├── assets
├── .DS_Store
└── architecture.png
├── docker-compose.app.yml
├── docker-compose.yml
├── nodemon.json
├── package.json
├── packages
├── application
│ ├── .env_template
│ ├── nodemon.json
│ ├── package.json
│ ├── src
│ │ ├── api
│ │ │ ├── http
│ │ │ │ ├── controllers
│ │ │ │ │ ├── application.controller.ts
│ │ │ │ │ ├── common-controller.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── middlewares
│ │ │ │ │ └── error-handler.ts
│ │ │ │ └── processors
│ │ │ │ │ └── response.ts
│ │ │ └── index.ts
│ │ ├── application
│ │ │ ├── commands
│ │ │ │ ├── definitions
│ │ │ │ │ └── create-application.ts
│ │ │ │ └── handlers
│ │ │ │ │ └── create-application-handler.ts
│ │ │ ├── events
│ │ │ │ ├── definitions
│ │ │ │ │ └── job-created.ts
│ │ │ │ └── handlers
│ │ │ │ │ ├── application-created-handler.ts
│ │ │ │ │ └── job-created-handler.ts
│ │ │ └── queries
│ │ │ │ ├── definitions
│ │ │ │ └── get-all-applications-query.ts
│ │ │ │ └── handlers
│ │ │ │ └── get-all-applications-query-handler.ts
│ │ ├── config
│ │ │ └── main.ts
│ │ ├── domain
│ │ │ ├── application-event-store.interface.ts
│ │ │ ├── application-repository.interface.ts
│ │ │ ├── application.ts
│ │ │ └── events
│ │ │ │ └── application-created.ts
│ │ ├── index.ts
│ │ ├── infrastructure
│ │ │ ├── commandBus
│ │ │ │ └── index.ts
│ │ │ ├── db
│ │ │ │ └── mongodb.ts
│ │ │ ├── eventbus
│ │ │ │ └── kafka.ts
│ │ │ ├── eventstore
│ │ │ │ └── application-event-store.ts
│ │ │ ├── module.ts
│ │ │ ├── query-bus
│ │ │ │ └── index.ts
│ │ │ └── repositories
│ │ │ │ └── application-repository.ts
│ │ ├── startup.ts
│ │ ├── subscribers
│ │ │ └── index.ts
│ │ └── types.ts
│ └── tsconfig.json
├── core
│ ├── package.json
│ ├── src
│ │ ├── AggregateRoot.ts
│ │ ├── Command.ts
│ │ ├── Errors.ts
│ │ ├── Event.ts
│ │ ├── EventDescriptor.ts
│ │ ├── EventSourcedRepository.ts
│ │ ├── EventStore.ts
│ │ ├── index.ts
│ │ ├── interfaces
│ │ │ ├── ICommand.ts
│ │ │ ├── ICommandBus.ts
│ │ │ ├── ICommandHandler.ts
│ │ │ ├── IEvent.ts
│ │ │ ├── IEventBus.ts
│ │ │ ├── IEventHandler.ts
│ │ │ ├── IEventStore.ts
│ │ │ ├── IMessage.ts
│ │ │ ├── IQuery.ts
│ │ │ ├── IQueryBus.ts
│ │ │ ├── IQueryHandler.ts
│ │ │ ├── IReadModelFacade.ts
│ │ │ └── IRepository.ts
│ │ └── utilities
│ │ │ ├── EventProcessor.ts
│ │ │ └── Logger.ts
│ └── tsconfig.json
└── job
│ ├── .env_template
│ ├── nodemon.json
│ ├── package.json
│ ├── src
│ ├── api
│ │ ├── http
│ │ │ ├── controllers
│ │ │ │ ├── common-controller.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── job.controller.ts
│ │ │ ├── middlewares
│ │ │ │ └── error-handler.ts
│ │ │ └── processors
│ │ │ │ └── response.ts
│ │ └── index.ts
│ ├── application
│ │ ├── commands
│ │ │ ├── definitions
│ │ │ │ ├── archive-job.ts
│ │ │ │ ├── create-job.ts
│ │ │ │ └── update-job.ts
│ │ │ └── handlers
│ │ │ │ ├── archive-job-handler.ts
│ │ │ │ ├── create-job-handler.ts
│ │ │ │ └── update-job-handler.ts
│ │ ├── events
│ │ │ └── handlers
│ │ │ │ ├── job-archived-handler.ts
│ │ │ │ ├── job-created-handler.ts
│ │ │ │ └── job-updated-handler.ts
│ │ └── queries
│ │ │ ├── definitions
│ │ │ ├── get-all-jobs-query.ts
│ │ │ └── job-response.ts
│ │ │ └── handlers
│ │ │ └── get-all-jobs-query-handler.ts
│ ├── config
│ │ └── main.ts
│ ├── domain
│ │ ├── events
│ │ │ ├── job-archived.ts
│ │ │ ├── job-created.ts
│ │ │ └── job-updated.ts
│ │ ├── job-event-store.interface.ts
│ │ ├── job-repository.interface.ts
│ │ ├── job.ts
│ │ └── status.ts
│ ├── infrastructure
│ │ ├── commandBus
│ │ │ └── index.ts
│ │ ├── db
│ │ │ ├── cassandra.ts
│ │ │ └── mongodb.ts
│ │ ├── event-store
│ │ │ └── job-event-store.ts
│ │ ├── eventbus
│ │ │ └── kafka.ts
│ │ ├── module.ts
│ │ ├── query-bus
│ │ │ └── index.ts
│ │ └── repositories
│ │ │ └── job-repository.ts
│ ├── startup.ts
│ ├── subscribers
│ │ └── index.ts
│ └── types.ts
│ └── tsconfig.json
├── setup
├── cassandra.sh
└── kafka.sh
├── tsconfig.json
└── yarn.lock
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
--------------------------------------------------------------------------------
/.eslintrc.yml:
--------------------------------------------------------------------------------
1 | env:
2 | browser: true
3 | es2020: true
4 | extends:
5 | - 'eslint:recommended'
6 | - 'plugin:@typescript-eslint/recommended'
7 | - 'plugin:prettier/recommended'
8 | - 'plugin:import/recommended'
9 | - 'plugin:import/typescript'
10 | parser: '@typescript-eslint/parser'
11 | settings:
12 | import/resolver:
13 | typescript:
14 | project:
15 | - "tsconfig.json"
16 | - "packages/**/tsconfig.json"
17 | parserOptions:
18 | ecmaVersion: 11
19 | sourceType: module
20 | plugins:
21 | - '@typescript-eslint'
22 | rules:
23 | indent:
24 | - error
25 | - 2
26 | linebreak-style:
27 | - error
28 | - unix
29 | quotes:
30 | - error
31 | - single
32 | semi:
33 | - error
34 | - always
35 | import/order:
36 | - error
37 | - groups:
38 | - builtin
39 | - external
40 | - internal
41 | - parent
42 | - sibling
43 | - index
44 | newlines-between: always
45 | alphabetize:
46 | order: asc
47 | caseInsensitive: true
48 | '@typescript-eslint/no-explicit-any':
49 | - off
50 | '@typescript-eslint/explicit-module-boundary-types':
51 | - off
52 | '@typescript-eslint/no-empty-interface':
53 | - off
54 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .env
4 | .vscode
5 | yarn-error.log
6 | tsconfig.tsbuildinfo
7 | .DS_Store
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "tabWidth": 2,
4 | "semi": true,
5 | "singleQuote": true,
6 | "printWidth": 120
7 | }
8 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ### March 23rd, 2021
2 | #### By Yerin Adler (03:00 PM)
3 | - Separate `author` read model from `book` read model (Facade)
4 | - Introduce `projection` directory to store projection related files
5 |
6 | ### March 23rd, 2021
7 | #### By Yerin Adler (09:00 AM)
8 | - Add inter-boundaries model communication
9 | Note: `Author` model is embedded within the `Book` context
10 |
11 | ### November 3rd, 2021
12 | #### By Chatthana Janethanakarn
13 | - Change message bus implementation from `EventEmitter` to `Redis Pub/Sub`
14 | ### November 20th, 2021
15 | #### By Yerin Adler
16 | - Revise event handling mechanism
17 | - Revise dependency registration in `entrypoint.ts`
18 | - Change `entrypoint.ts` to `startup.ts`
19 |
20 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:gallium-alpine
2 | ARG BUILD_CONTEXT
3 | ARG RUN_COMMAND
4 | ENV RUN_CONTEXT ${BUILD_CONTEXT} ${RUN_COMMAND}
5 |
6 | WORKDIR /usr/src/app
7 |
8 | COPY ./package.json .
9 | COPY ./tsconfig.json .
10 | COPY ./yarn.lock .
11 |
12 | COPY ./packages/core ./packages/core
13 | COPY ./packages/${BUILD_CONTEXT} ./packages/${BUILD_CONTEXT}
14 |
15 | RUN yarn
16 |
17 | COPY ./packages/${BUILD_CONTEXT} ./packages/${BUILD_CONTEXT}
18 |
19 | RUN yarn ${BUILD_CONTEXT} build
20 |
21 | EXPOSE 3000
22 |
23 | CMD yarn ${RUN_CONTEXT} start
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020 Yerin J. Adler
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: help
2 | start-infra:
3 | docker compose up -d
4 | start:
5 | docker compose -f docker-compose.yml -f docker-compose.app.yml up -d
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TypeScript CQRS & Event Sourcing Boilerplate
2 |
3 | **Note:** This project is a experimental project that leverages the concepts of `CQRS` and `Event Sourcing`. However, the events as written in this project are the results of the `Strategic Design` and this project can be used during the `Tactical Design` phase
4 |
5 | ### Business Scenario
6 | The business scenario in this project is the job application. Let's imagine we are building a platform that allows users to apply to their desired jobs. So below is the requirement
7 |
8 | 1. The admin can create a job with the required information e.g. description, title, etc.
9 | 2. The admin can archive the job but there is no way he/she can delete the job.
10 | 3. The job can be updated at any time.
11 | 4. Once the job is created, the user can browse through the list of applications and may choose to apply to any of them.
12 | 5. After the user successfully filled in the required information. The application is created.
13 |
14 | ### Foreword from the author
15 |
16 | This API project utilises information from multiple sources to create the fine-tuned API product with the following objectives
17 |
18 | 1. To build a maintainable enterprise grade application
19 | 2. The application that follows `SOLID` principles as much as possible
20 | 3. To build an application that benefits most of the stakeholders in an organisation
21 | 4. To decouple the read and the write side of the application. Thus, they can be scaled indenpendently
22 | 5. To build the CQRS and Event Sourcing system
23 |
24 | ### Architecture
25 |
26 | This project uses DDD with Onion Architecture as illustrated in below images
27 |
28 | Below image illustrates the more detailed architecture
29 |
30 | 
31 |
32 | In CQRS & Event Sourcing systems, the main idea is to implement different data models for read and write sides of the application.
33 |
34 | The workflow is that the write side sends the `commands` to the `command handlers` through `commandBus` to alter the information. The succeeded commands will then generate resulting `events` which are then stored in the `event store`. Finally, the `event handlers` subscribe to events and generate the denormalised data ready for the query. Please note that the events could also be handled by multiple event handlers. Some of them may handle notification tasks.
35 |
36 | The only source of truth of Event Sourcing systems is the `event store` while the data in the read store is simply a derivative of the events generated from the write side. This means we can use totally different data structure between the read and the write sides and we can replay the events from the event store from the whenever we want the regenerate the denormalised data in whatever shapes we want.
37 |
38 | In this example, we use `MongoDB` as an event store and `Redis` as the read store.
39 |
40 | The commands are sent by the frontend to the `commandBus` which then selects appropriate `command handlers` for the commands. The command handlers then prepare the `Aggregate Root` and apply the business logic suitable for them. If the commands succeed, they result in events which will then be sent to the `eventBus` to the `event handlers`. In this example, the eventBus is implemented using `Apache Kafka`.
41 |
42 | To read more about CQRS and Event Sourcing. Please check [this link](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs)
43 |
44 | In order to demonstrate the use case with inter-service communication. Two seprate microservices for job and application is created. So a job microservice manages job (create, update, archive) and application microservice manages the user application.
45 |
46 | The pattern in `Event-Driven Architecture` called `Event-Carried State Transfer` is used between job and application microservices. When the job is created, the `JobCreated` event is embedded with the job information as a payload so the application microservice uses this information to replicate the job information for local query. Thus, whenever the application microservice needs the job information, it does not make a direct RPC / REST call to the job microservice at all.
47 |
48 | ### Technologies
49 |
50 | 1. Node.js
51 | 2. TypeScript
52 | 3. MongoDB with MongoDB native driver as an event store (mongodb package on NPM)
53 | 4. InversifyJS as an IoC container
54 | 5. Express (via Inversify Express Utils) as an API framework
55 | 6. Redis as a read store for application microservice
56 | 7. Apache Cassandra as a read store for job microservice
57 | 8. Apache Kafka as a message broker / event bus
58 |
59 | ## Components
60 |
61 | This project follows the standard CQRS & Event Sourcing applications available on GitHub. Highly inspired by Greg Young's SimpleCQRS project (written in ASP.NET C#).
62 |
63 | Below is the list of components in this project
64 |
65 | 1. **Domain Model** (Aggregate Root)
66 | Contains the business logic required for the application
67 | 2. **Commands**
68 | The command class which implements `ICommand` interface. This reflects the intention of the users to alter the state of the application
69 | 3. **CommandHandlers**
70 | The command processor managed by `CommandBus`. It prepares the Aggregate Root and applies business logic on it.
71 | 4. **CommandBus**
72 | The command management object which receieves incoming commands and select appropriate handlers for them. Please note that in order to use the command handlers. They must be registered to the `CommandBus` first at `entrypoint.ts` file.
73 | 5. **Events**
74 | The resulting objects from describing the changes generated by succeeding commands which are sent through the `EventBus`. This class implements `IEvent` interface.
75 | 6. **Event Store**
76 | The storage that stores events. This is the only source of truth of the system (The sequence of events generated by the application).
77 | 7. **EventBus**
78 | The bus that contains the events where event handlers subscribe to. In this example, `Apache Kafka` is used to implement this
79 | 8. **Event Handlers**
80 | The event processor. This could be the projector or the denormaliser that generates the data for the read side on the read storage.
81 |
82 | ## Getting Started
83 |
84 | To run the project, make sure you have these dependencies installed on your system
85 |
86 | > The `docker-compose.yml` containing all dependencies for running the project is provided
87 | > but make sure you have at least 8GB of RAM available on your local machine before running these dependencies
88 |
89 | 1. Node.js v8 or later
90 | 2. Typescript with `tsc` command
91 | 3. Nodemon
92 | 4. ts-node
93 | 5. MongoDB
94 | 6. Redis Server and Clients (redis-cli)
95 | 7. CQLSH for interacting with Cassandra
96 | 8. Docker installed on the machine
97 |
98 | You also need to setup and initialise MongoDB database. Then, copy the `.env_example` file into `.env` file by firing the command
99 |
100 | ```bash
101 | cp .env_template .env
102 | ```
103 |
104 | Do adjust the `DB_NAME` and `MONGODB_URI` to match your configuration then run
105 |
106 | ```bash
107 | yarn dev
108 | ```
109 |
--------------------------------------------------------------------------------
/assets/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yerinadler/typescript-event-sourcing-sample-app/354ab3c160472e6676a5aeb06fad8e554cd4d689/assets/.DS_Store
--------------------------------------------------------------------------------
/assets/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yerinadler/typescript-event-sourcing-sample-app/354ab3c160472e6676a5aeb06fad8e554cd4d689/assets/architecture.png
--------------------------------------------------------------------------------
/docker-compose.app.yml:
--------------------------------------------------------------------------------
1 | services:
2 | job:
3 | build:
4 | context: .
5 | dockerfile: Dockerfile
6 | args:
7 | BUILD_CONTEXT: job
8 | ports:
9 | - 3000:3000
10 | environment:
11 | NODE_ENV: dev
12 | API_PORT: 3000
13 | MONGODB_URI: mongodb://mongodb:27017
14 | DB_NAME: job-dev
15 | REDIS_URI: redis://redis:6379
16 | KAFKA_BROKER_LIST: kafka:29092
17 | KAFKA_CONSUMER_GROUP_ID: cqrs-es-job.dev
18 | KAFKA_TOPICS_TO_SUBSCRIBE: job
19 | CASSANDRA_HOSTS: cassandra:9042
20 | CASSANDRA_DC: datacenter1
21 | CASSANDRA_KEYSPACE: cqrs_es_dev
22 | depends_on:
23 | - kafka
24 | - mongodb
25 | - redis
26 | - cassandra
27 | job-subscriber:
28 | build:
29 | context: .
30 | dockerfile: Dockerfile
31 | args:
32 | BUILD_CONTEXT: job
33 | environment:
34 | NODE_ENV: dev
35 | MONGODB_URI: mongodb://mongodb:27017
36 | DB_NAME: job-dev
37 | REDIS_URI: redis://redis:6379
38 | KAFKA_BROKER_LIST: kafka:29092
39 | KAFKA_CONSUMER_GROUP_ID: cqrs-es-job.dev
40 | KAFKA_TOPICS_TO_SUBSCRIBE: job
41 | CASSANDRA_HOSTS: cassandra:9042
42 | CASSANDRA_DC: datacenter1
43 | CASSANDRA_KEYSPACE: cqrs_es_dev
44 | depends_on:
45 | - kafka
46 | - mongodb
47 | - redis
48 | - cassandra
49 | command: yarn job start:subscriber
50 | application:
51 | build:
52 | context: .
53 | dockerfile: Dockerfile
54 | args:
55 | BUILD_CONTEXT: application
56 | ports:
57 | - 4000:3000
58 | environment:
59 | NODE_ENV: dev
60 | API_PORT: 3000
61 | MONGODB_URI: mongodb://mongodb:27017
62 | DB_NAME: application-dev
63 | REDIS_URI: redis://redis:6379
64 | KAFKA_BROKER_LIST: kafka:29092
65 | KAFKA_CONSUMER_GROUP_ID: cqrs-es-application.dev
66 | KAFKA_TOPICS_TO_SUBSCRIBE: application,job
67 | depends_on:
68 | - kafka
69 | - mongodb
70 | - redis
71 | application-subscriber:
72 | build:
73 | context: .
74 | dockerfile: Dockerfile
75 | args:
76 | BUILD_CONTEXT: application
77 | environment:
78 | NODE_ENV: dev
79 | MONGODB_URI: mongodb://mongodb:27017
80 | DB_NAME: application-dev
81 | REDIS_URI: redis://redis:6379
82 | KAFKA_BROKER_LIST: kafka:29092
83 | KAFKA_CONSUMER_GROUP_ID: cqrs-es-application.dev
84 | KAFKA_TOPICS_TO_SUBSCRIBE: application,job
85 | depends_on:
86 | - kafka
87 | - mongodb
88 | - redis
89 | command: yarn application start:subscriber
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.9'
2 | services:
3 | mongodb:
4 | container_name: cqrs-es-mongodb
5 | image: mongo:4.4
6 | restart: unless-stopped
7 | ports:
8 | - 27017:27017
9 | redis:
10 | container_name: cqrs-es-redis
11 | image: redis:6
12 | restart: unless-stopped
13 | ports:
14 | - 6379:6379
15 | zookeeper:
16 | container_name: cqrs-es-zookeeper
17 | image: confluentinc/cp-zookeeper:7.2.0
18 | restart: unless-stopped
19 | ports:
20 | - 2181:2181
21 | environment:
22 | ZOOKEEPER_CLIENT_PORT: 2181
23 | kafka:
24 | container_name: cqrs-es-kafka
25 | image: confluentinc/cp-kafka:7.2.0
26 | restart: unless-stopped
27 | ports:
28 | - 9092:9092
29 | expose:
30 | - 29092
31 | depends_on:
32 | - zookeeper
33 | environment:
34 | KAFKA_ADVERTISED_HOST_NAME: localhost
35 | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
36 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
37 | KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
38 | KAFKA_ADVERTISED_LISTENERS: PLAINTEXT_HOST://localhost:9092,PLAINTEXT://kafka:29092
39 | KAFKA_LISTENERS: PLAINTEXT_HOST://0.0.0.0:9092,PLAINTEXT://0.0.0.0:29092
40 | KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
41 | KAFKA_DELETE_TOPIC_ENABLE: 'true'
42 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
43 | KAFKA_MIN_INSYNC_REPLICAS: 1
44 | init-kafka:
45 | image: confluentinc/cp-kafka:7.2.0
46 | depends_on:
47 | - kafka
48 | entrypoint: ["/setup.sh"]
49 | volumes:
50 | - ./setup/kafka.sh:/setup.sh
51 | cassandra:
52 | container_name: cqrs-es-cassandra
53 | image: cassandra:4
54 | ports:
55 | - 9042:9042
56 | environment:
57 | CASSANDRA_CLUSTER_NAME: cqrs_es_cluster
58 | init-cassandra:
59 | image: cassandra:4
60 | depends_on:
61 | - cassandra
62 | entrypoint: ["/setup.sh"]
63 | volumes:
64 | - ./setup/cassandra.sh:/setup.sh
65 | networks:
66 | default:
67 | name: cqrs-es-net
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": ["src/**/*.ts"],
3 | "ext": "ts",
4 | "exec": "tsc --build --force && node dist/index.js"
5 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "typescript-event-sourcing-sample-app",
3 | "private": true,
4 | "version": "1.0.0",
5 | "workspaces": [
6 | "packages/*"
7 | ],
8 | "scripts": {
9 | "application": "yarn workspace @cqrs-es/application",
10 | "job": "yarn workspace @cqrs-es/job",
11 | "core": "yarn workspace @cqrs-es/core"
12 | },
13 | "main": "index.js",
14 | "repository": "git@github.com:yerinadler/typescript-event-sourcing-sample-app.git",
15 | "author": "Yerin Adler ",
16 | "license": "MIT"
17 | }
18 |
--------------------------------------------------------------------------------
/packages/application/.env_template:
--------------------------------------------------------------------------------
1 | NODE_ENV=dev
2 | API_PORT=4200
3 | MONGODB_URI=mongodb://localhost:27017
4 | DB_NAME=application-dev
5 | REDIS_URI=redis://localhost:6379
6 | KAFKA_BROKER_LIST=localhost:9092
7 | KAFKA_CONSUMER_GROUP_ID=cqrs-es-application.dev
8 | KAFKA_TOPICS_TO_SUBSCRIBE=application,job
--------------------------------------------------------------------------------
/packages/application/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": ["src/**/*.ts"],
3 | "ext": "ts",
4 | "exec": "rm -rf dist && tsc --build --force && concurrently --raw \"TS_NODE_BASEURL=dist node -r tsconfig-paths/register dist/api/index.js\" \"TS_NODE_BASEURL=dist node -r tsconfig-paths/register dist/subscribers/index.js\""
5 | }
--------------------------------------------------------------------------------
/packages/application/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@cqrs-es/application",
3 | "version": "1.0.0",
4 | "main": "dist/index.js",
5 | "license": "MIT",
6 | "scripts": {
7 | "build": "tsc --build --force",
8 | "start": "TS_NODE_BASEURL=dist node -r tsconfig-paths/register dist/api/index.js",
9 | "start:subscriber": "TS_NODE_BASEURL=dist node -r tsconfig-paths/register dist/subscribers/index.js",
10 | "dev": "nodemon",
11 | "lint": "eslint --fix ."
12 | },
13 | "_moduleAliases": {
14 | "@src": "./dist",
15 | "@core": "./dist/core",
16 | "@common": "./dist/common",
17 | "@config": "./dist/config",
18 | "@constants": "dist/constants",
19 | "@domain": "dist/domain",
20 | "@infrastructure": "dist/infrastructure",
21 | "@api": "./dist/api"
22 | },
23 | "dependencies": {
24 | "@cqrs-es/core": "1.0.0",
25 | "@types/body-parser": "^1.19.0",
26 | "@types/express": "^4.17.13",
27 | "@types/ioredis": "^4.17.8",
28 | "@types/mongodb": "^3.5.25",
29 | "@types/uuid": "^8.0.0",
30 | "class-transformer": "^0.4.0",
31 | "dotenv": "^8.2.0",
32 | "express": "^4.17.1",
33 | "http-status-codes": "^1.4.0",
34 | "inversify": "^5.0.1",
35 | "inversify-express-utils": "^6.3.2",
36 | "ioredis": "^4.19.2",
37 | "kafkajs": "^1.16.0",
38 | "module-alias": "^2.2.2",
39 | "mongodb": "^3.5.9",
40 | "nanoid": "^3.1.20",
41 | "reflect-metadata": "^0.1.13",
42 | "uuid": "^8.2.0",
43 | "winston": "^3.7.2"
44 | },
45 | "devDependencies": {
46 | "@types/node": "^16.10.3",
47 | "@typescript-eslint/eslint-plugin": "^3.5.0",
48 | "@typescript-eslint/parser": "^3.5.0",
49 | "eslint": "^7.32.0",
50 | "eslint-config-prettier": "^8.3.0",
51 | "eslint-import-resolver-typescript": "^2.5.0",
52 | "eslint-plugin-import": "^2.24.2",
53 | "eslint-plugin-node": "^11.1.0",
54 | "eslint-plugin-prettier": "^4.0.0",
55 | "prettier": "^2.4.1",
56 | "tsconfig-paths": "^4.0.0",
57 | "typescript": "^4.7.4"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/packages/application/src/api/http/controllers/application.controller.ts:
--------------------------------------------------------------------------------
1 | import { ICommandBus, IQueryBus } from '@cqrs-es/core';
2 | import { Request, Response } from 'express';
3 | import { inject } from 'inversify';
4 | import { controller, httpGet, httpPost, request, response } from 'inversify-express-utils';
5 |
6 | import { CreateApplicationCommand } from '@src/application/commands/definitions/create-application';
7 | import { GetAllApplicationsQuery } from '@src/application/queries/definitions/get-all-applications-query';
8 | import { TYPES } from '@src/types';
9 |
10 | import { ok } from '../processors/response';
11 |
12 | @controller('/api/v1/applications')
13 | export class ApplicationController {
14 | constructor(
15 | @inject(TYPES.CommandBus) private readonly _commandBus: ICommandBus,
16 | @inject(TYPES.QueryBus) private readonly _queryBus: IQueryBus
17 | ) {}
18 |
19 | @httpPost('')
20 | async createApplication(@request() req: Request, @response() res: Response) {
21 | const { jobId, firstname, lastname, email, currentPosition } = req.body;
22 | const result = await this._commandBus.send(
23 | new CreateApplicationCommand(jobId, firstname, lastname, email, currentPosition)
24 | );
25 | return res.json(ok('Created a new application successfully', result));
26 | }
27 |
28 | @httpGet('')
29 | async getAllApplications(@request() req: Request, @response() res: Response) {
30 | const query: GetAllApplicationsQuery = new GetAllApplicationsQuery();
31 | const result = await this._queryBus.execute(query);
32 | return res.json(ok('Retrieved all applications successfully', result));
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/packages/application/src/api/http/controllers/common-controller.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 | import { controller, httpGet, request, response } from 'inversify-express-utils';
3 |
4 | import { ok } from '../processors/response';
5 |
6 | @controller('')
7 | export class CommonController {
8 | @httpGet('/healthz')
9 | async healthcheck(@request() req: Request, @response() res: Response) {
10 | return res.json(ok('Success', undefined));
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/application/src/api/http/controllers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './common-controller';
2 | export * from './application.controller';
3 |
--------------------------------------------------------------------------------
/packages/application/src/api/http/middlewares/error-handler.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 |
3 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
4 | export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
5 | return res.status(err.httpCode || 500).json({
6 | status: err.statusCode || '500',
7 | message: err.message,
8 | });
9 | };
10 |
--------------------------------------------------------------------------------
/packages/application/src/api/http/processors/response.ts:
--------------------------------------------------------------------------------
1 | export const ok = (message: string, data: any) => ({
2 | status: '000',
3 | message: message || 'Success',
4 | data,
5 | });
6 |
--------------------------------------------------------------------------------
/packages/application/src/api/index.ts:
--------------------------------------------------------------------------------
1 | import * as dotenv from 'dotenv';
2 | import { Application } from 'express';
3 | dotenv.config();
4 | import 'reflect-metadata';
5 | import { Producer } from 'kafkajs';
6 |
7 | import config from '@config/main';
8 |
9 | import { initialise } from '../startup';
10 | import { TYPES } from '../types';
11 |
12 | (async () => {
13 | const container = await initialise();
14 | const api: Application = container.get(TYPES.ApiServer);
15 |
16 | const kafkaProducer = container.get(TYPES.KafkaProducer);
17 | kafkaProducer.connect();
18 |
19 | api.listen(config.API_PORT, () => console.log('The application is initialised on the port %s', config.API_PORT));
20 | })();
21 |
--------------------------------------------------------------------------------
/packages/application/src/application/commands/definitions/create-application.ts:
--------------------------------------------------------------------------------
1 | import { Command } from '@cqrs-es/core';
2 |
3 | export class CreateApplicationCommand extends Command {
4 | public jobId: string;
5 | public firstname: string;
6 | public lastname: string;
7 | public email: string;
8 | public currentPosition: string;
9 |
10 | constructor(
11 | jobId: string,
12 | firstname: string,
13 | lastname: string,
14 | email: string,
15 | currentPosition: string,
16 | guid?: string
17 | ) {
18 | super(guid);
19 | this.jobId = jobId;
20 | this.firstname = firstname;
21 | this.lastname = lastname;
22 | this.email = email;
23 | this.currentPosition = currentPosition;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/packages/application/src/application/commands/handlers/create-application-handler.ts:
--------------------------------------------------------------------------------
1 | import { ICommandHandler } from '@cqrs-es/core';
2 | import { inject, injectable } from 'inversify';
3 |
4 | import { Application } from '@src/domain/application';
5 | import { IApplicationRepository } from '@src/domain/application-repository.interface';
6 | import { TYPES } from '@src/types';
7 |
8 | import { CreateApplicationCommand } from '../definitions/create-application';
9 |
10 | @injectable()
11 | export class CreateApplicationCommandHandler implements ICommandHandler {
12 | commandToHandle: string = CreateApplicationCommand.name;
13 |
14 | constructor(@inject(TYPES.ApplicationRepository) private readonly _repository: IApplicationRepository) {}
15 |
16 | async handle(command: CreateApplicationCommand): Promise<{ guid: string }> {
17 | const application: Application = new Application(
18 | command.guid,
19 | command.jobId,
20 | command.firstname,
21 | command.lastname,
22 | command.email,
23 | command.currentPosition
24 | );
25 | await this._repository.save(application, -1);
26 | return { guid: command.guid };
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/packages/application/src/application/events/definitions/job-created.ts:
--------------------------------------------------------------------------------
1 | import { Event } from '@cqrs-es/core';
2 |
3 | export class JobCreated extends Event {
4 | eventName = JobCreated.name;
5 | aggregateName = 'job';
6 |
7 | constructor(public guid: string, public title: string) {
8 | super(guid);
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/packages/application/src/application/events/handlers/application-created-handler.ts:
--------------------------------------------------------------------------------
1 | import { IEventHandler, NotFoundException } from '@cqrs-es/core';
2 | import { inject, injectable } from 'inversify';
3 | import { Redis } from 'ioredis';
4 | import { Logger } from 'winston';
5 |
6 | import { ApplicationCreated } from '@src/domain/events/application-created';
7 | import { TYPES } from '@src/types';
8 |
9 | @injectable()
10 | export class ApplicationCreatedEventHandler implements IEventHandler {
11 | public event = ApplicationCreated.name;
12 |
13 | constructor(
14 | @inject(TYPES.Redis) private readonly _redisClient: Redis,
15 | @inject(TYPES.Logger) private readonly _logger: Logger
16 | ) {}
17 |
18 | async handle(event: ApplicationCreated) {
19 | const job = await this._redisClient.get(`job-repl:${event.jobId}`);
20 |
21 | if (!job) {
22 | throw new NotFoundException('The related job does not exist or is in sync');
23 | }
24 |
25 | const parsedJob = JSON.parse(job);
26 | await this._redisClient.set(
27 | `application:${event.guid}`,
28 | JSON.stringify({
29 | guid: event.guid,
30 | jobId: event.jobId,
31 | jobTitle: parsedJob.title,
32 | firstname: event.firstname,
33 | lastname: event.lastname,
34 | email: event.email,
35 | currentPosition: event.currentPosition,
36 | version: event.version,
37 | })
38 | );
39 |
40 | this._logger.info(`created read model for the application ${JSON.stringify(event)}`);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/packages/application/src/application/events/handlers/job-created-handler.ts:
--------------------------------------------------------------------------------
1 | import { IEventHandler } from '@cqrs-es/core';
2 | import { inject, injectable } from 'inversify';
3 | import { Redis } from 'ioredis';
4 | import { Logger } from 'winston';
5 |
6 | import { TYPES } from '@src/types';
7 |
8 | import { JobCreated } from '../definitions/job-created';
9 |
10 | @injectable()
11 | export class JobCreatedEventHandler implements IEventHandler {
12 | public event = JobCreated.name;
13 |
14 | constructor(
15 | @inject(TYPES.Redis) private readonly _redisClient: Redis,
16 | @inject(TYPES.Logger) private readonly _logger: Logger
17 | ) {}
18 |
19 | async handle(event: JobCreated) {
20 | await this._redisClient.set(
21 | `job-repl:${event.guid}`,
22 | JSON.stringify({
23 | guid: event.guid,
24 | title: event.title,
25 | version: event.version,
26 | })
27 | );
28 | this._logger.info(`replicated job for the application => ${JSON.stringify(event)}`);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/packages/application/src/application/queries/definitions/get-all-applications-query.ts:
--------------------------------------------------------------------------------
1 | import { IQuery } from '@cqrs-es/core';
2 |
3 | export class GetAllApplicationsQuery implements IQuery {}
4 |
--------------------------------------------------------------------------------
/packages/application/src/application/queries/handlers/get-all-applications-query-handler.ts:
--------------------------------------------------------------------------------
1 | import { IQueryHandler } from '@cqrs-es/core';
2 | import { inject, injectable } from 'inversify';
3 | import { Redis } from 'ioredis';
4 |
5 | import { TYPES } from '@src/types';
6 |
7 | import { GetAllApplicationsQuery } from '../definitions/get-all-applications-query';
8 |
9 | @injectable()
10 | export class GetAllApplicationsQueryHandler implements IQueryHandler {
11 | queryToHandle = GetAllApplicationsQuery.name;
12 |
13 | constructor(@inject(TYPES.Redis) private readonly _redisClient: Redis) {}
14 |
15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
16 | async execute(query: GetAllApplicationsQuery) {
17 | const keys: string[] = await this._redisClient.keys('application:*');
18 | const applications: any[] = [];
19 | for (const key of keys) {
20 | const application = await this._redisClient.get(key);
21 | if (application) {
22 | applications.push(JSON.parse(application));
23 | }
24 | }
25 | return applications;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/application/src/config/main.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | API_PORT: process.env.API_PORT || 3000,
3 | MONGODB_URI: process.env.MONGODB_URI || 'mongodb://localhost:27017',
4 | DB_NAME: process.env.DB_NAME || 'job-listing',
5 | REDIS_URI: process.env.REDIS_URI || 'redis://localhost:6379',
6 | KAFKA_BROKER_LIST: process.env.KAFKA_BROKER_LIST || 'localhost:9092',
7 | KAFKA_CONSUMER_GROUP_ID: process.env.KAFKA_CONSUMER_GROUP_ID || 'cqrs-es',
8 | KAFKA_TOPICS_TO_SUBSCRIBE: process.env.KAFKA_TOPICS_TO_SUBSCRIBE || 'application',
9 | };
10 |
--------------------------------------------------------------------------------
/packages/application/src/domain/application-event-store.interface.ts:
--------------------------------------------------------------------------------
1 | import { IEventStore } from '@cqrs-es/core';
2 |
3 | import { Application } from './application';
4 |
5 | export interface IApplicationEventStore extends IEventStore {}
6 |
--------------------------------------------------------------------------------
/packages/application/src/domain/application-repository.interface.ts:
--------------------------------------------------------------------------------
1 | import { IRepository } from '@cqrs-es/core';
2 |
3 | import { Application } from './application';
4 |
5 | export interface IApplicationRepository extends IRepository {}
6 |
--------------------------------------------------------------------------------
/packages/application/src/domain/application.ts:
--------------------------------------------------------------------------------
1 | import { AggregateRoot } from '@cqrs-es/core';
2 |
3 | import { ApplicationCreated } from './events/application-created';
4 |
5 | export class Application extends AggregateRoot {
6 | private jobId: string;
7 | private firstname: string;
8 | private lastname: string;
9 | private email: string;
10 | private currentPosition: string;
11 |
12 | constructor();
13 |
14 | constructor(guid: string, jobId: string, firstname: string, lastname: string, email: string, currentPosition: string);
15 |
16 | constructor(
17 | guid?: string,
18 | jobId?: string,
19 | firstname?: string,
20 | lastname?: string,
21 | email?: string,
22 | currentPosition?: string
23 | ) {
24 | super(guid);
25 |
26 | if (guid && jobId && firstname && lastname && email && currentPosition) {
27 | this.applyChange(new ApplicationCreated(this.guid, jobId, firstname, lastname, email, currentPosition));
28 | }
29 | }
30 |
31 | applyApplicationCreated(event: ApplicationCreated) {
32 | this.jobId = event.jobId;
33 | this.firstname = event.firstname;
34 | this.lastname = event.lastname;
35 | this.email = event.email;
36 | this.currentPosition = event.currentPosition;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/packages/application/src/domain/events/application-created.ts:
--------------------------------------------------------------------------------
1 | import { Event } from '@cqrs-es/core';
2 |
3 | export class ApplicationCreated extends Event {
4 | eventName = ApplicationCreated.name;
5 | aggregateName = 'application';
6 |
7 | constructor(
8 | public guid: string,
9 | public jobId: string,
10 | public firstname: string,
11 | public lastname: string,
12 | public email: string,
13 | public currentPosition: string
14 | ) {
15 | super(guid);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/application/src/index.ts:
--------------------------------------------------------------------------------
1 | import * as dotenv from 'dotenv';
2 | dotenv.config();
3 |
4 | import 'reflect-metadata';
5 |
6 | import { initialise } from './startup';
7 | import { TYPES } from './types';
8 |
9 | // eslint-disable-next-line import/order
10 | import { IEventBus } from '@cqrs-es/core';
11 |
12 | (async () => {
13 | const container = await initialise();
14 | const baseEventHandler = container.get(TYPES.EventBus);
15 | baseEventHandler.subscribeEvents();
16 | })();
17 |
--------------------------------------------------------------------------------
/packages/application/src/infrastructure/commandBus/index.ts:
--------------------------------------------------------------------------------
1 | import { ICommand, ICommandBus, ICommandHandler } from '@cqrs-es/core';
2 | import { injectable } from 'inversify';
3 |
4 | @injectable()
5 | export class CommandBus implements ICommandBus {
6 | public handlers: Map> = new Map();
7 |
8 | public registerHandler(handler: ICommandHandler) {
9 | const targetCommand: string = handler.commandToHandle;
10 | if (this.handlers.has(targetCommand)) {
11 | return;
12 | }
13 | this.handlers.set(targetCommand, handler);
14 | }
15 |
16 | public async send(command: T) {
17 | if (this.handlers.has(command.constructor.name)) {
18 | return (this.handlers.get(command.constructor.name) as ICommandHandler).handle(command);
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/application/src/infrastructure/db/mongodb.ts:
--------------------------------------------------------------------------------
1 | import { MongoClientOptions, MongoClient, Db } from 'mongodb';
2 |
3 | import config from '@config/main';
4 |
5 | export const createMongodbConnection = async (
6 | host: string,
7 | options: MongoClientOptions = {
8 | useNewUrlParser: true,
9 | useUnifiedTopology: true,
10 | }
11 | ): Promise => {
12 | return new Promise((resolve, reject) => {
13 | MongoClient.connect(host, options, (error, client) => {
14 | if (error) reject(error);
15 | resolve(client.db(config.DB_NAME));
16 | });
17 | });
18 | };
19 |
--------------------------------------------------------------------------------
/packages/application/src/infrastructure/eventbus/kafka.ts:
--------------------------------------------------------------------------------
1 | import { EventDescriptor, IEvent, IEventBus, IEventHandler, rehydrateEventFromDescriptor } from '@cqrs-es/core';
2 | import { classToPlain } from 'class-transformer';
3 | import { inject, injectable, multiInject } from 'inversify';
4 | import { Consumer, Producer } from 'kafkajs';
5 |
6 | import { TYPES } from '@src/types';
7 |
8 | @injectable()
9 | export class KafkaEventBus implements IEventBus {
10 | constructor(
11 | @multiInject(TYPES.Event) private readonly eventHandlers: IEventHandler[],
12 | @inject(TYPES.KafkaConsumer) private readonly _subscriber: Consumer,
13 | @inject(TYPES.KafkaProducer) private readonly _producer: Producer
14 | ) {}
15 |
16 | async publish(channel: string, eventDescriptor: EventDescriptor): Promise {
17 | const payload: string = JSON.stringify({ ...classToPlain(eventDescriptor) });
18 | await this._producer.send({
19 | topic: channel,
20 | messages: [
21 | {
22 | key: eventDescriptor.aggregateGuid,
23 | value: payload,
24 | },
25 | ],
26 | });
27 | }
28 |
29 | async subscribeEvents(): Promise {
30 | await this._subscriber.run({
31 | eachMessage: async ({ message, heartbeat }) => {
32 | if (message.value) {
33 | const eventDescriptor = JSON.parse(message.value.toString());
34 | const matchedHandlers: IEventHandler[] = this.eventHandlers.filter(
35 | (handler) => handler.event === eventDescriptor.eventName
36 | );
37 | await Promise.all(
38 | matchedHandlers.map((handler: IEventHandler) => {
39 | handler.handle(rehydrateEventFromDescriptor(eventDescriptor));
40 | })
41 | );
42 | await heartbeat();
43 | }
44 | },
45 | });
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/packages/application/src/infrastructure/eventstore/application-event-store.ts:
--------------------------------------------------------------------------------
1 | import { IEventBus, IEventStore, EventStore } from '@cqrs-es/core';
2 | import { inject, injectable } from 'inversify';
3 | import { Db } from 'mongodb';
4 |
5 | import { Application } from '@domain/application';
6 | import { TYPES } from '@src/types';
7 |
8 | @injectable()
9 | export class ApplicationEventStore extends EventStore implements IEventStore {
10 | constructor(@inject(TYPES.Db) private readonly db: Db, @inject(TYPES.EventBus) private readonly eventBus: IEventBus) {
11 | super(db.collection('application-events'), eventBus);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/application/src/infrastructure/module.ts:
--------------------------------------------------------------------------------
1 | import { ICommandBus, IEventBus, IQuery, IQueryBus } from '@cqrs-es/core';
2 | import { AsyncContainerModule, interfaces } from 'inversify';
3 | import RedisClient, { Redis } from 'ioredis';
4 | import { Consumer, Kafka, Producer } from 'kafkajs';
5 | import { Db } from 'mongodb';
6 |
7 | import config from '@config/main';
8 | import { IApplicationEventStore } from '@src/domain/application-event-store.interface';
9 | import { IApplicationRepository } from '@src/domain/application-repository.interface';
10 | import { TYPES } from '@src/types';
11 |
12 | import { CommandBus } from './commandBus';
13 | import { createMongodbConnection } from './db/mongodb';
14 | import { KafkaEventBus } from './eventbus/kafka';
15 | import { ApplicationEventStore } from './eventstore/application-event-store';
16 | import { QueryBus } from './query-bus';
17 | import { ApplicationRepository } from './repositories/application-repository';
18 |
19 | export const infrastructureModule = new AsyncContainerModule(async (bind: interfaces.Bind) => {
20 | const db: Db = await createMongodbConnection(config.MONGODB_URI);
21 |
22 | const kafka = new Kafka({ brokers: config.KAFKA_BROKER_LIST.split(',') });
23 | const kafkaProducer = kafka.producer();
24 | const kafkaConsumer = kafka.consumer({ groupId: config.KAFKA_CONSUMER_GROUP_ID });
25 | kafkaProducer.connect();
26 |
27 | bind(TYPES.Db).toConstantValue(db);
28 | bind(TYPES.KafkaProducer).toConstantValue(kafkaProducer);
29 | bind(TYPES.KafkaConsumer).toConstantValue(kafkaConsumer);
30 | bind(TYPES.Redis).toConstantValue(new RedisClient(config.REDIS_URI));
31 | bind(TYPES.EventBus).to(KafkaEventBus);
32 | bind(TYPES.ApplicationEventStore).to(ApplicationEventStore).inSingletonScope();
33 | bind(TYPES.ApplicationRepository).to(ApplicationRepository).inSingletonScope();
34 | bind(TYPES.CommandBus).toConstantValue(new CommandBus());
35 | bind>(TYPES.QueryBus).toConstantValue(new QueryBus());
36 | });
37 |
--------------------------------------------------------------------------------
/packages/application/src/infrastructure/query-bus/index.ts:
--------------------------------------------------------------------------------
1 | import { IQuery, IQueryBus, IQueryHandler } from '@cqrs-es/core';
2 | import { injectable } from 'inversify';
3 |
4 | @injectable()
5 | export class QueryBus implements IQueryBus {
6 | public handlers: Map> = new Map();
7 |
8 | public registerHandler(handler: IQueryHandler) {
9 | const queryName = handler.queryToHandle;
10 | if (this.handlers.has(queryName)) {
11 | return;
12 | }
13 | this.handlers.set(queryName, handler);
14 | }
15 |
16 | public async execute(query: T) {
17 | if (this.handlers.has(query.constructor.name)) {
18 | return (this.handlers.get(query.constructor.name) as IQueryHandler).execute(query);
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/application/src/infrastructure/repositories/application-repository.ts:
--------------------------------------------------------------------------------
1 | import { EventSourcedRepository } from '@cqrs-es/core';
2 | import { inject, injectable } from 'inversify';
3 |
4 | import { IApplicationEventStore } from '@src/domain/application-event-store.interface';
5 | import { TYPES } from '@src/types';
6 |
7 | import { Application } from '../../domain/application';
8 | import { IApplicationRepository } from '../../domain/application-repository.interface';
9 |
10 | @injectable()
11 | export class ApplicationRepository extends EventSourcedRepository implements IApplicationRepository {
12 | constructor(@inject(TYPES.ApplicationEventStore) private readonly eventstore: IApplicationEventStore) {
13 | super(eventstore, Application);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/application/src/startup.ts:
--------------------------------------------------------------------------------
1 | import '@src/api/http/controllers';
2 |
3 | import {
4 | ICommand,
5 | IQuery,
6 | ICommandHandler,
7 | ICommandBus,
8 | IQueryBus,
9 | IQueryHandler,
10 | IEventHandler,
11 | createWinstonLogger,
12 | } from '@cqrs-es/core';
13 | import { Application, urlencoded, json } from 'express';
14 | import { Container } from 'inversify';
15 | import { InversifyExpressServer } from 'inversify-express-utils';
16 | import winston from 'winston';
17 |
18 | import { ApplicationCreated } from '@domain/events/application-created';
19 | import { errorHandler } from '@src/api/http/middlewares/error-handler';
20 | import { CreateApplicationCommandHandler } from '@src/application/commands/handlers/create-application-handler';
21 | import { ApplicationCreatedEventHandler } from '@src/application/events/handlers/application-created-handler';
22 | import { GetAllApplicationsQueryHandler } from '@src/application/queries/handlers/get-all-applications-query-handler';
23 | import { TYPES } from '@src/types';
24 |
25 | import { JobCreated } from './application/events/definitions/job-created';
26 | import { JobCreatedEventHandler } from './application/events/handlers/job-created-handler';
27 | import { infrastructureModule } from './infrastructure/module';
28 |
29 | const initialise = async () => {
30 | const container = new Container();
31 |
32 | await container.loadAsync(infrastructureModule);
33 |
34 | const logger = createWinstonLogger('cqrs-es-application');
35 |
36 | container.bind(TYPES.Logger).toConstantValue(logger);
37 |
38 | container.bind>(TYPES.Event).to(ApplicationCreatedEventHandler);
39 | container.bind>(TYPES.Event).to(JobCreatedEventHandler);
40 | container.bind>(TYPES.CommandHandler).to(CreateApplicationCommandHandler);
41 | container.bind>(TYPES.QueryHandler).to(GetAllApplicationsQueryHandler);
42 |
43 | const commandBus = container.get(TYPES.CommandBus);
44 |
45 | container.getAll>(TYPES.CommandHandler).forEach((handler: ICommandHandler) => {
46 | commandBus.registerHandler(handler);
47 | });
48 |
49 | const queryBus = container.get(TYPES.QueryBus);
50 | container.getAll>(TYPES.QueryHandler).forEach((handler: IQueryHandler) => {
51 | queryBus.registerHandler(handler);
52 | });
53 |
54 | const server = new InversifyExpressServer(container);
55 |
56 | server.setConfig((app: Application) => {
57 | app.use(urlencoded({ extended: true }));
58 | app.use(json());
59 | });
60 |
61 | server.setErrorConfig((app: Application) => {
62 | app.use(errorHandler);
63 | });
64 |
65 | const apiServer = server.build();
66 | container.bind(TYPES.ApiServer).toConstantValue(apiServer);
67 |
68 | return container;
69 | };
70 |
71 | export { initialise };
72 |
--------------------------------------------------------------------------------
/packages/application/src/subscribers/index.ts:
--------------------------------------------------------------------------------
1 | import { IEventBus } from '@cqrs-es/core';
2 | import * as dotenv from 'dotenv';
3 | dotenv.config();
4 | import 'reflect-metadata';
5 | import { Consumer } from 'kafkajs';
6 |
7 | import config from '@config/main';
8 |
9 | import { initialise } from '../startup';
10 | import { TYPES } from '../types';
11 |
12 | (async () => {
13 | const container = await initialise();
14 |
15 | const kafkaConsumer = container.get(TYPES.KafkaConsumer);
16 | kafkaConsumer.connect();
17 |
18 | for (const topic of config.KAFKA_TOPICS_TO_SUBSCRIBE.split(',')) {
19 | await kafkaConsumer.subscribe({ topic });
20 | }
21 |
22 | const baseEventHandler = container.get(TYPES.EventBus);
23 | baseEventHandler.subscribeEvents();
24 | })();
25 |
--------------------------------------------------------------------------------
/packages/application/src/types.ts:
--------------------------------------------------------------------------------
1 | export const TYPES = {
2 | Db: Symbol('Db'),
3 | KafkaProducer: Symbol('KafkaProducer'),
4 | KafkaConsumer: Symbol('KafkaConsumer'),
5 | RedisSubscriber: Symbol('RedisSubscriber'),
6 | Redis: Symbol('Redis'),
7 | EventBus: Symbol('EventBus'),
8 | CommandBus: Symbol('CommandBus'),
9 | QueryBus: Symbol('QueryBus'),
10 | CommandHandler: Symbol('CommandHandler'),
11 | QueryHandler: Symbol('QueryHandler'),
12 | Event: Symbol('Event'),
13 | EventHandler: Symbol('EventHandler'),
14 | EventStore: Symbol('EventStore'),
15 | ApplicationRepository: Symbol('ApplicationRepository'),
16 | ApplicationEventStore: Symbol('ApplicationEventStore'),
17 | GetAllApplicationsQueryHandler: Symbol('GetAllApplicationsQueryHandler'),
18 | ApiServer: Symbol('ApiServer'),
19 | Logger: Symbol('Logger'),
20 | };
21 |
--------------------------------------------------------------------------------
/packages/application/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "rootDir": "src",
6 | "baseUrl": "src",
7 | "outDir": "dist",
8 | "paths": {
9 | "@src/*": [
10 | "*"
11 | ],
12 | "@common/*": [
13 | "common/*"
14 | ],
15 | "@commands/*": [
16 | "commands/*"
17 | ],
18 | "@config/*": [
19 | "config/*"
20 | ],
21 | "@constants/*": [
22 | "constants/*"
23 | ],
24 | "@domain/*": [
25 | "domain/*"
26 | ],
27 | "@infrastructure/*": [
28 | "infrastructure/*"
29 | ],
30 | "@api/*": [
31 | "api/*"
32 | ]
33 | },
34 | "types": [
35 | "reflect-metadata"
36 | ],
37 | },
38 | "include": ["src"],
39 | "exclude": [
40 | "node_modules"
41 | ],
42 | "references": [
43 | { "path": "../core" }
44 | ]
45 | }
--------------------------------------------------------------------------------
/packages/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@cqrs-es/core",
3 | "version": "1.0.0",
4 | "main": "dist/index.js",
5 | "typings": "dist/index.d.ts",
6 | "scripts": {
7 | "build": "tsc"
8 | },
9 | "license": "MIT",
10 | "devDependencies": {
11 | "@types/express": "^4.17.13",
12 | "http-status-codes": "^2.2.0",
13 | "nanoid": "^3.3.4",
14 | "typescript": "^4.7.4"
15 | },
16 | "dependencies": {
17 | "class-transformer": "^0.5.1"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/core/src/AggregateRoot.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid';
2 |
3 | import { IEvent } from './interfaces/IEvent';
4 |
5 | export abstract class AggregateRoot {
6 | public guid: string;
7 | private __version = 0;
8 | private __changes: IEvent[] = [];
9 |
10 | get version() {
11 | return this.__version;
12 | }
13 |
14 | constructor(guid?: string) {
15 | this.guid = guid || nanoid();
16 | }
17 |
18 | public getUncommittedEvents(): IEvent[] {
19 | return [...this.__changes];
20 | }
21 |
22 | public markChangesAsCommitted(): void {
23 | this.__changes = [];
24 | }
25 |
26 | protected applyChange(event: IEvent): void {
27 | this.applyEvent(event, true);
28 | }
29 |
30 | private applyEvent(event: IEvent, isNew = false): void {
31 | const handlerMethod = `apply${event.eventName}`;
32 |
33 | if (typeof (this as any)[handlerMethod] === 'function') {
34 | (this as any)[handlerMethod](event);
35 | } else {
36 | throw new Error(`Handler method ${handlerMethod} not implemented`);
37 | }
38 |
39 | if (isNew) {
40 | this.__changes.push(event);
41 | }
42 | }
43 |
44 | public loadFromHistory(events: IEvent[]): void {
45 | for (const event of events) {
46 | this.applyEvent(event);
47 | this.__version++;
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/packages/core/src/Command.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid';
2 |
3 | import { ICommand } from './interfaces/ICommand';
4 |
5 | export abstract class Command implements ICommand {
6 | public guid: string;
7 |
8 | constructor(guid?: string) {
9 | this.guid = guid || nanoid();
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/core/src/Errors.ts:
--------------------------------------------------------------------------------
1 | import { BAD_REQUEST, CONFLICT, NOT_FOUND } from 'http-status-codes';
2 |
3 | export class ApplicationError extends Error {
4 | public readonly httpCode: number;
5 | public readonly statusCode: string;
6 | constructor(httpCode: number, statusCode: string, message: string) {
7 | super(message);
8 | this.httpCode = httpCode;
9 | this.statusCode = statusCode;
10 | }
11 | }
12 |
13 | export class NotFoundException extends ApplicationError {
14 | constructor(public readonly message: string) {
15 | super(NOT_FOUND, '404', message || 'Entity not found');
16 | }
17 | }
18 |
19 | export class ConcurrencyException extends ApplicationError {
20 | constructor(public readonly message: string) {
21 | super(CONFLICT, '409', message || 'Concurrency detected');
22 | }
23 | }
24 |
25 | export class DomainException extends ApplicationError {
26 | constructor(public readonly message: string) {
27 | super(BAD_REQUEST, '5310', message || 'Domain exception detected');
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/packages/core/src/Event.ts:
--------------------------------------------------------------------------------
1 | import { IEvent } from './interfaces/IEvent';
2 |
3 | export type EVENT_METADATA_TYPES = 'eventName' | 'aggregateName' | 'aggregateId' | 'version';
4 |
5 | export const EVENT_METADATA = ['eventName', 'aggregateName', 'aggregateId', 'version'];
6 |
7 | export abstract class Event implements IEvent {
8 | public abstract eventName: string;
9 | public abstract aggregateName: string;
10 | public aggregateId: string;
11 | public version: number;
12 |
13 | constructor(aggregateId: string) {
14 | this.aggregateId = aggregateId;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/core/src/EventDescriptor.ts:
--------------------------------------------------------------------------------
1 | import { StorageEvent } from './utilities/EventProcessor';
2 |
3 | export class EventDescriptor {
4 | constructor(
5 | public readonly aggregateGuid: string,
6 | public readonly aggregateName: string,
7 | public readonly eventName: string,
8 | public readonly payload: StorageEvent,
9 | public readonly version?: number
10 | ) {}
11 | }
12 |
--------------------------------------------------------------------------------
/packages/core/src/EventSourcedRepository.ts:
--------------------------------------------------------------------------------
1 | import { injectable, unmanaged } from 'inversify';
2 |
3 | import { AggregateRoot } from './AggregateRoot';
4 | import { IEventStore } from './interfaces/IEventStore';
5 | import { IRepository } from './interfaces/IRepository';
6 |
7 | @injectable()
8 | export class EventSourcedRepository implements IRepository {
9 | constructor(
10 | @unmanaged() private readonly eventStore: IEventStore,
11 | @unmanaged() private readonly Type: { new (): T }
12 | ) {}
13 |
14 | async save(aggregateRoot: T, expectedVersion: number) {
15 | await this.eventStore.saveEvents(aggregateRoot.guid, aggregateRoot.getUncommittedEvents(), expectedVersion);
16 | aggregateRoot.markChangesAsCommitted();
17 | }
18 |
19 | async getById(guid: string) {
20 | const aggregateRoot = new this.Type() as T;
21 | const history = await this.eventStore.getEventsForAggregate(guid);
22 | aggregateRoot.loadFromHistory(history);
23 | return aggregateRoot;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/packages/core/src/EventStore.ts:
--------------------------------------------------------------------------------
1 | import { injectable, unmanaged } from 'inversify';
2 | import { Collection } from 'mongodb';
3 |
4 | import { ConcurrencyException, NotFoundException } from './Errors';
5 | import { EventDescriptor } from './EventDescriptor';
6 | import { IEvent } from './interfaces/IEvent';
7 | import { IEventBus } from './interfaces/IEventBus';
8 | import { IEventStore } from './interfaces/IEventStore';
9 | import { createEventDescriptor, rehydrateEventFromDescriptor } from './utilities/EventProcessor';
10 |
11 | @injectable()
12 | export abstract class EventStore implements IEventStore {
13 | constructor(
14 | @unmanaged() private readonly eventCollection: Collection,
15 | @unmanaged() private readonly _eventBus: IEventBus
16 | ) {}
17 |
18 | async saveEvents(aggregateGuid: string, events: IEvent[], expectedVersion: number) {
19 | const operations: any[] = [];
20 |
21 | const latestEvent = await this.getLastEventDescriptor(aggregateGuid);
22 |
23 | if (latestEvent && latestEvent.version !== expectedVersion && expectedVersion !== -1) {
24 | throw new ConcurrencyException('Cannot perform the operation due to internal conflict');
25 | }
26 |
27 | let i: number = expectedVersion;
28 |
29 | for (const event of events) {
30 | i++;
31 | event.version = i;
32 | const eventDescriptor = createEventDescriptor(event);
33 | this._eventBus.publish(event.aggregateName, eventDescriptor);
34 | operations.push({ insertOne: eventDescriptor });
35 | }
36 |
37 | await this.eventCollection.bulkWrite(operations);
38 | }
39 |
40 | async getEventsForAggregate(aggregateGuid: string): Promise {
41 | const events = await this.eventCollection.find({ aggregateGuid }).toArray();
42 | if (!events.length) {
43 | throw new NotFoundException('Aggregate with the requested Guid does not exist');
44 | }
45 | return events.map((eventDescriptor: EventDescriptor) => rehydrateEventFromDescriptor(eventDescriptor));
46 | }
47 |
48 | private async getLastEventDescriptor(aggregateGuid: string) {
49 | const [latestEvent] = await this.eventCollection.find({ aggregateGuid }, { sort: { _id: -1 } }).toArray();
50 | return latestEvent;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/packages/core/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './AggregateRoot';
2 | export * from './Errors';
3 | export * from './Command';
4 | export * from './Event';
5 | export * from './EventDescriptor';
6 | export * from './EventSourcedRepository';
7 | export * from './EventStore';
8 | export * from './interfaces/ICommand';
9 | export * from './interfaces/ICommandBus';
10 | export * from './interfaces/ICommandHandler';
11 | export * from './interfaces/IEvent';
12 | export * from './interfaces/IEventBus';
13 | export * from './interfaces/IEventHandler';
14 | export * from './interfaces/IEventStore';
15 | export * from './interfaces/IMessage';
16 | export * from './interfaces/IQuery';
17 | export * from './interfaces/IQueryBus';
18 | export * from './interfaces/IQueryHandler';
19 | export * from './interfaces/IReadModelFacade';
20 | export * from './interfaces/IRepository';
21 | export * from './utilities/EventProcessor';
22 | export * from './utilities/Logger';
23 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/ICommand.ts:
--------------------------------------------------------------------------------
1 | import { IMessage } from './IMessage';
2 |
3 | export interface ICommand extends IMessage {
4 | guid: string;
5 | }
6 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/ICommandBus.ts:
--------------------------------------------------------------------------------
1 | import { ICommand } from './ICommand';
2 | import { ICommandHandler } from './ICommandHandler';
3 |
4 | export interface ICommandBus {
5 | registerHandler(handler: ICommandHandler): any;
6 | send(command: T): any;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/ICommandHandler.ts:
--------------------------------------------------------------------------------
1 | import { ICommand } from './ICommand';
2 |
3 | export interface ICommandHandler {
4 | commandToHandle: string;
5 | handle(command: TCommand): any;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/IEvent.ts:
--------------------------------------------------------------------------------
1 | import { IMessage } from './IMessage';
2 |
3 | export interface IEvent extends IMessage {
4 | eventName: string;
5 | aggregateName: string;
6 | aggregateId: string;
7 | version?: number;
8 | }
9 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/IEventBus.ts:
--------------------------------------------------------------------------------
1 | import { EventDescriptor } from 'EventDescriptor';
2 |
3 | export interface IEventBus {
4 | publish(channel: string, event: EventDescriptor): Promise;
5 | subscribeEvents(): Promise;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/IEventHandler.ts:
--------------------------------------------------------------------------------
1 | import { IEvent } from './IEvent';
2 |
3 | export interface IEventHandler {
4 | event: string;
5 | handle(event: T): void;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/IEventStore.ts:
--------------------------------------------------------------------------------
1 | import { IEvent } from './IEvent';
2 |
3 | export interface IEventStore {
4 | saveEvents(aggregateGuid: string, eventHistory: IEvent[], version: number): void;
5 | getEventsForAggregate(aggregateGuid: string): Promise;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/IMessage.ts:
--------------------------------------------------------------------------------
1 | export interface IMessage {}
2 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/IQuery.ts:
--------------------------------------------------------------------------------
1 | export interface IQuery {}
2 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/IQueryBus.ts:
--------------------------------------------------------------------------------
1 | import { IQuery } from './IQuery';
2 | import { IQueryHandler } from './IQueryHandler';
3 |
4 | export interface IQueryBus {
5 | registerHandler(queryHandler: IQueryHandler): void;
6 | execute(query: T): Promise;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/IQueryHandler.ts:
--------------------------------------------------------------------------------
1 | import { IQuery } from './IQuery';
2 |
3 | export interface IQueryHandler {
4 | queryToHandle: string;
5 | execute(query: T): Promise;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/IReadModelFacade.ts:
--------------------------------------------------------------------------------
1 | export interface IReadModelFacade {
2 | getAll(): Promise;
3 | getById(guid: string): Promise;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces/IRepository.ts:
--------------------------------------------------------------------------------
1 | export interface IRepository {
2 | save(aggregateRoot: T, expectedVersion: number): void;
3 | getById(guid: string): Promise;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/core/src/utilities/EventProcessor.ts:
--------------------------------------------------------------------------------
1 | import { instanceToPlain, plainToInstance } from 'class-transformer';
2 |
3 | import { EVENT_METADATA, EVENT_METADATA_TYPES } from '../Event';
4 | import { EventDescriptor } from '../EventDescriptor';
5 | import { IEvent } from '../interfaces/IEvent';
6 |
7 | export type StorageEvent = Omit;
8 | export class RehydratedEvent {}
9 |
10 | export function createEventDescriptor(event: T): EventDescriptor {
11 | const JSONEvent = instanceToPlain(event);
12 |
13 | for (const attribute of EVENT_METADATA) {
14 | delete JSONEvent[attribute];
15 | }
16 |
17 | return new EventDescriptor(event.aggregateId, event.aggregateName, event.eventName, JSONEvent, event.version);
18 | }
19 |
20 | export function rehydrateEventFromDescriptor(storageEvent: EventDescriptor): IEvent {
21 | const event: any = plainToInstance(RehydratedEvent, storageEvent);
22 | return {
23 | aggregateId: storageEvent.aggregateGuid,
24 | aggregateName: storageEvent.aggregateName,
25 | eventName: storageEvent.eventName,
26 | version: storageEvent.version,
27 | ...event.payload,
28 | };
29 | }
30 |
--------------------------------------------------------------------------------
/packages/core/src/utilities/Logger.ts:
--------------------------------------------------------------------------------
1 | import { createLogger, transports, format } from 'winston';
2 |
3 | export function createWinstonLogger(service: string) {
4 | return createLogger({
5 | level: 'info',
6 | defaultMeta: { service },
7 | format: format.combine(
8 | format.simple(),
9 | format.label({
10 | label: '[LOGGER]',
11 | }),
12 | format.colorize({ all: true }),
13 | format.timestamp({ format: 'YY-MM-DD HH:mm:ss' }),
14 | format.align(),
15 | format.printf((info) => `[${info.level}] ${info.timestamp} : ${info.message}`)
16 | ),
17 | transports: [new transports.Console()],
18 | });
19 | }
20 |
--------------------------------------------------------------------------------
/packages/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "declaration": true,
6 | "declarationMap": true,
7 | "sourceMap": true,
8 | "rootDir": "src",
9 | "baseUrl": "src",
10 | "outDir": "dist",
11 | },
12 | "include": ["src"],
13 | "exclude": ["dist/**/*"]
14 | }
--------------------------------------------------------------------------------
/packages/job/.env_template:
--------------------------------------------------------------------------------
1 | NODE_ENV=dev
2 | API_PORT=3200
3 | MONGODB_URI=mongodb://localhost:27017
4 | DB_NAME=job-dev
5 | REDIS_URI=redis://localhost:6379
6 | KAFKA_BROKER_LIST=localhost:9092
7 | KAFKA_CONSUMER_GROUP_ID=cqrs-es-job.dev
8 | KAFKA_TOPICS_TO_SUBSCRIBE=job
9 | CASSANDRA_HOSTS=localhost:9042
10 | CASSANDRA_DC=datacenter1
11 | CASSANDRA_KEYSPACE=cqrs_es_dev
--------------------------------------------------------------------------------
/packages/job/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": ["src/**/*.ts"],
3 | "ext": "ts",
4 | "exec": "rm -rf dist && tsc --build --force && concurrently --raw \"TS_NODE_BASEURL=dist node -r tsconfig-paths/register dist/api/index.js\" \"TS_NODE_BASEURL=dist node -r tsconfig-paths/register dist/subscribers/index.js\""
5 | }
--------------------------------------------------------------------------------
/packages/job/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@cqrs-es/job",
3 | "version": "1.0.0",
4 | "main": "dist/index.js",
5 | "license": "MIT",
6 | "scripts": {
7 | "build": "tsc --build --force",
8 | "start": "TS_NODE_BASEURL=dist node -r tsconfig-paths/register dist/api/index.js",
9 | "start:subscriber": "TS_NODE_BASEURL=dist node -r tsconfig-paths/register dist/subscribers/index.js",
10 | "dev": "nodemon",
11 | "lint": "eslint --fix ."
12 | },
13 | "_moduleAliases": {
14 | "@src": "./dist",
15 | "@core": "./dist/core",
16 | "@common": "./dist/common",
17 | "@config": "./dist/config",
18 | "@constants": "dist/constants",
19 | "@domain": "dist/domain",
20 | "@infrastructure": "dist/infrastructure",
21 | "@api": "./dist/api"
22 | },
23 | "dependencies": {
24 | "@cqrs-es/core": "1.0.0",
25 | "@types/body-parser": "^1.19.0",
26 | "@types/express": "^4.17.13",
27 | "@types/ioredis": "^4.17.8",
28 | "@types/mongodb": "^3.5.25",
29 | "@types/uuid": "^8.0.0",
30 | "cassandra-driver": "^4.6.4",
31 | "class-transformer": "^0.4.0",
32 | "dotenv": "^8.2.0",
33 | "express": "^4.17.1",
34 | "http-status-codes": "^1.4.0",
35 | "inversify": "^5.0.1",
36 | "inversify-express-utils": "^6.3.2",
37 | "ioredis": "^4.19.2",
38 | "kafkajs": "^1.16.0",
39 | "module-alias": "^2.2.2",
40 | "mongodb": "^3.5.9",
41 | "nanoid": "^3.1.20",
42 | "reflect-metadata": "^0.1.13",
43 | "uuid": "^8.2.0",
44 | "winston": "^3.7.2"
45 | },
46 | "devDependencies": {
47 | "@types/cassandra-driver": "^4.2.0",
48 | "@types/node": "^16.10.3",
49 | "@typescript-eslint/eslint-plugin": "^3.5.0",
50 | "@typescript-eslint/parser": "^3.5.0",
51 | "concurrently": "^7.6.0",
52 | "eslint": "^7.32.0",
53 | "eslint-config-prettier": "^8.3.0",
54 | "eslint-import-resolver-typescript": "^2.5.0",
55 | "eslint-plugin-import": "^2.24.2",
56 | "eslint-plugin-node": "^11.1.0",
57 | "eslint-plugin-prettier": "^4.0.0",
58 | "prettier": "^2.4.1",
59 | "tsconfig-paths": "^4.0.0",
60 | "typescript": "^4.7.4"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/packages/job/src/api/http/controllers/common-controller.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 | import { controller, httpGet, request, response } from 'inversify-express-utils';
3 |
4 | import { ok } from '../processors/response';
5 |
6 | @controller('')
7 | export class CommonController {
8 | @httpGet('/healthz')
9 | async healthcheck(@request() req: Request, @response() res: Response) {
10 | return res.json(ok('Success', undefined));
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/job/src/api/http/controllers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './common-controller';
2 | export * from './job.controller';
3 |
--------------------------------------------------------------------------------
/packages/job/src/api/http/controllers/job.controller.ts:
--------------------------------------------------------------------------------
1 | import { ICommandBus, IQueryBus } from '@cqrs-es/core';
2 | import { TYPES } from '@src/types';
3 | import { Request, Response } from 'express';
4 | import { inject } from 'inversify';
5 | import { controller, httpGet, httpPost, httpPut, request, response } from 'inversify-express-utils';
6 |
7 | import { ArchiveJobCommand } from '@src/application/commands/definitions/archive-job';
8 | import { CreateJobCommand } from '@src/application/commands/definitions/create-job';
9 | import { UpdateJobCommand } from '@src/application/commands/definitions/update-job';
10 | import { GetAllJobsQuery } from '@src/application/queries/definitions/get-all-jobs-query';
11 |
12 | import { ok } from '../processors/response';
13 |
14 | @controller('/api/v1/jobs')
15 | export class JobController {
16 | constructor(
17 | @inject(TYPES.CommandBus) private readonly _commandBus: ICommandBus,
18 | @inject(TYPES.QueryBus) private readonly _queryBus: IQueryBus
19 | ) {}
20 |
21 | @httpPost('')
22 | async createJob(@request() req: Request, @response() res: Response) {
23 | const { title, description } = req.body;
24 | const result = await this._commandBus.send(new CreateJobCommand(title, description));
25 | return res.json(ok('Created job successfully', result));
26 | }
27 |
28 | @httpPut('/:id')
29 | async updateJob(@request() req: Request, @response() res: Response) {
30 | const { title, description, version } = req.body;
31 | await this._commandBus.send(new UpdateJobCommand(req.params.id, title, description, version));
32 | return res.json(ok('Updated the job successfully', undefined));
33 | }
34 |
35 | @httpPut('/:id/archive')
36 | async archiveJob(@request() req: Request, @response() res: Response) {
37 | const { version } = req.body;
38 | await this._commandBus.send(new ArchiveJobCommand(req.params.id, version));
39 | return res.json(ok('Archived job successfully', undefined));
40 | }
41 |
42 | @httpGet('')
43 | async getAllJobs(@request() req: Request, @response() res: Response) {
44 | const jobs = await this._queryBus.execute(new GetAllJobsQuery());
45 | return res.json(ok('Get all jobs successfully', jobs));
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/packages/job/src/api/http/middlewares/error-handler.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express';
2 |
3 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
4 | export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
5 | return res.status(err.httpCode || 500).json({
6 | status: err.statusCode || '500',
7 | message: err.message,
8 | });
9 | };
10 |
--------------------------------------------------------------------------------
/packages/job/src/api/http/processors/response.ts:
--------------------------------------------------------------------------------
1 | export const ok = (message: string, data: any) => ({
2 | status: '000',
3 | message: message || 'Success',
4 | data,
5 | });
6 |
--------------------------------------------------------------------------------
/packages/job/src/api/index.ts:
--------------------------------------------------------------------------------
1 | import config from '@config/main';
2 | import * as dotenv from 'dotenv';
3 | import { Application } from 'express';
4 | dotenv.config();
5 |
6 | import 'reflect-metadata';
7 |
8 | import { initialise } from '../startup';
9 | import { TYPES } from '../types';
10 |
11 | (async () => {
12 | const container = await initialise();
13 | const api: Application = container.get(TYPES.ApiServer);
14 | api.listen(config.API_PORT, () => console.log('The application is initialised on the port %s', config.API_PORT));
15 | })();
16 |
--------------------------------------------------------------------------------
/packages/job/src/application/commands/definitions/archive-job.ts:
--------------------------------------------------------------------------------
1 | import { Command } from '@cqrs-es/core';
2 |
3 | export class ArchiveJobCommand extends Command {
4 | public readonly originalVersion: number;
5 | constructor(guid: string, originalVersion: number) {
6 | super(guid);
7 | this.originalVersion = originalVersion;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/job/src/application/commands/definitions/create-job.ts:
--------------------------------------------------------------------------------
1 | import { Command } from '@cqrs-es/core';
2 |
3 | export class CreateJobCommand extends Command {
4 | public title: string;
5 | public description: string;
6 |
7 | constructor(title: string, description: string, guid?: string) {
8 | super(guid);
9 | this.title = title;
10 | this.description = description;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/job/src/application/commands/definitions/update-job.ts:
--------------------------------------------------------------------------------
1 | import { Command } from '@cqrs-es/core';
2 |
3 | export class UpdateJobCommand extends Command {
4 | public title: string;
5 | public description: string;
6 | public readonly originalVersion: number;
7 |
8 | constructor(guid: string, title: string, description: string, originalVersion: number) {
9 | super(guid);
10 | this.title = title;
11 | this.description = description;
12 | this.originalVersion = originalVersion;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/packages/job/src/application/commands/handlers/archive-job-handler.ts:
--------------------------------------------------------------------------------
1 | import { ICommandHandler } from '@cqrs-es/core';
2 | import { TYPES } from '@src/types';
3 | import { inject, injectable } from 'inversify';
4 |
5 | import { IJobRepository } from '@src/domain/job-repository.interface';
6 |
7 | import { ArchiveJobCommand } from '../definitions/archive-job';
8 |
9 | @injectable()
10 | export class ArchiveJobCommandHandler implements ICommandHandler {
11 | commandToHandle: string = ArchiveJobCommand.name;
12 |
13 | constructor(@inject(TYPES.JobRepository) private readonly _repository: IJobRepository) {}
14 |
15 | async handle(command: ArchiveJobCommand): Promise {
16 | const job = await this._repository.getById(command.guid);
17 | job.archive();
18 | await this._repository.save(job, command.originalVersion);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/job/src/application/commands/handlers/create-job-handler.ts:
--------------------------------------------------------------------------------
1 | import { ICommandHandler } from '@cqrs-es/core';
2 | import { TYPES } from '@src/types';
3 | import { inject, injectable } from 'inversify';
4 |
5 | import { Job } from '@src/domain/job';
6 | import { IJobRepository } from '@src/domain/job-repository.interface';
7 |
8 | import { CreateJobCommand } from '../definitions/create-job';
9 |
10 | @injectable()
11 | export class CreateJobCommandHandler implements ICommandHandler {
12 | commandToHandle: string = CreateJobCommand.name;
13 |
14 | constructor(@inject(TYPES.JobRepository) private readonly _repository: IJobRepository) {}
15 |
16 | async handle(command: CreateJobCommand): Promise<{ guid: string }> {
17 | const job: Job = new Job(command.guid, command.title, command.description);
18 | await this._repository.save(job, -1);
19 | return { guid: command.guid };
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/job/src/application/commands/handlers/update-job-handler.ts:
--------------------------------------------------------------------------------
1 | import { ICommandHandler } from '@cqrs-es/core';
2 | import { TYPES } from '@src/types';
3 | import { inject, injectable } from 'inversify';
4 |
5 | import { IJobRepository } from '@src/domain/job-repository.interface';
6 |
7 | import { UpdateJobCommand } from '../definitions/update-job';
8 |
9 | @injectable()
10 | export class UpdateJobCommandHandler implements ICommandHandler {
11 | commandToHandle: string = UpdateJobCommand.name;
12 |
13 | constructor(@inject(TYPES.JobRepository) private readonly _repository: IJobRepository) {}
14 |
15 | async handle(command: UpdateJobCommand): Promise {
16 | const job = await this._repository.getById(command.guid);
17 | job.updateInfo(command.title, command.description);
18 | await this._repository.save(job, command.originalVersion);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/job/src/application/events/handlers/job-archived-handler.ts:
--------------------------------------------------------------------------------
1 | import { IEventHandler } from '@cqrs-es/core';
2 | import { TYPES } from '@src/types';
3 | import { Client } from 'cassandra-driver';
4 | import { inject, injectable } from 'inversify';
5 |
6 | import { JobArchived } from '@src/domain/events/job-archived';
7 |
8 | @injectable()
9 | export class JobArchivedEventHandler implements IEventHandler {
10 | public event = JobArchived.name;
11 |
12 | constructor(@inject(TYPES.CassandraDb) private readonly _cassandraClient: Client) {}
13 |
14 | async handle(event: JobArchived) {
15 | const query = 'UPDATE jobs SET status = ?, version = ? WHERE guid = ?';
16 | await this._cassandraClient.execute(query, [event.status, event.version, event.guid], { prepare: true });
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/packages/job/src/application/events/handlers/job-created-handler.ts:
--------------------------------------------------------------------------------
1 | import { IEventHandler } from '@cqrs-es/core';
2 | import { TYPES } from '@src/types';
3 | import { Client } from 'cassandra-driver';
4 | import { inject, injectable } from 'inversify';
5 | import { Logger } from 'winston';
6 |
7 | import { JobCreated } from '@src/domain/events/job-created';
8 |
9 | @injectable()
10 | export class JobCreatedEventHandler implements IEventHandler {
11 | public event = JobCreated.name;
12 |
13 | constructor(
14 | @inject(TYPES.CassandraDb) private readonly _cassandraClient: Client,
15 | @inject(TYPES.Logger) private readonly _logger: Logger
16 | ) {}
17 |
18 | async handle(event: JobCreated) {
19 | const query = 'INSERT INTO jobs (guid, title, description, status, version) VALUES (?, ?, ?, ?, ?)';
20 |
21 | await this._cassandraClient.execute(
22 | query,
23 | [event.guid, event.title, event.description, event.status, event.version],
24 | { prepare: true }
25 | );
26 |
27 | this._logger.info(`created read model for job ${JSON.stringify(event)}`);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/packages/job/src/application/events/handlers/job-updated-handler.ts:
--------------------------------------------------------------------------------
1 | import { IEventHandler } from '@cqrs-es/core';
2 | import { TYPES } from '@src/types';
3 | import { Client } from 'cassandra-driver';
4 | import { inject, injectable } from 'inversify';
5 |
6 | import { JobUpdated } from '@src/domain/events/job-updated';
7 |
8 | @injectable()
9 | export class JobUpdatedEventHandler implements IEventHandler {
10 | public event = JobUpdated.name;
11 |
12 | constructor(@inject(TYPES.CassandraDb) private readonly _cassandraClient: Client) {}
13 |
14 | async handle(event: JobUpdated) {
15 | const query = 'UPDATE jobs SET title = ?, description = ?, status = ?, version = ? WHERE guid = ?';
16 |
17 | await this._cassandraClient.execute(
18 | query,
19 | [event.title, event.description, event.status, event.version, event.guid],
20 | { prepare: true }
21 | );
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/job/src/application/queries/definitions/get-all-jobs-query.ts:
--------------------------------------------------------------------------------
1 | import { IQuery } from '@cqrs-es/core';
2 |
3 | export class GetAllJobsQuery implements IQuery {}
4 |
--------------------------------------------------------------------------------
/packages/job/src/application/queries/definitions/job-response.ts:
--------------------------------------------------------------------------------
1 | import { JobStatus } from '@src/domain/status';
2 |
3 | export class JobQueryResponseModel {
4 | constructor(
5 | public readonly id: string,
6 | public readonly title: string,
7 | public readonly description: string,
8 | public readonly status: JobStatus,
9 | public readonly version: number
10 | ) {}
11 | }
12 |
--------------------------------------------------------------------------------
/packages/job/src/application/queries/handlers/get-all-jobs-query-handler.ts:
--------------------------------------------------------------------------------
1 | import { IQueryHandler } from '@cqrs-es/core';
2 | import { TYPES } from '@src/types';
3 | import { Client } from 'cassandra-driver';
4 | import { inject, injectable } from 'inversify';
5 |
6 | import { JobStatus } from '@src/domain/status';
7 |
8 | import { GetAllJobsQuery } from '../definitions/get-all-jobs-query';
9 | import { JobQueryResponseModel } from '../definitions/job-response';
10 |
11 | interface JobRow {
12 | id: string;
13 | title: string;
14 | description: string;
15 | status: string;
16 | version: number;
17 | }
18 |
19 | @injectable()
20 | export class GetAllJobsQueryHandler implements IQueryHandler {
21 | queryToHandle = GetAllJobsQuery.name;
22 |
23 | constructor(@inject(TYPES.CassandraDb) private readonly _cassandraClient: Client) {}
24 |
25 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
26 | async execute(_: GetAllJobsQuery) {
27 | const query = 'SELECT guid, title, description, status, version FROM jobs';
28 | const queryResult = await this._cassandraClient.execute(query);
29 | const resp: JobQueryResponseModel[] = queryResult.rows.map((row) => ({
30 | id: row['id'] as string,
31 | title: row['title'] as string,
32 | description: row['description'] as string,
33 | status: row['status'] as JobStatus,
34 | version: row['version'] as number,
35 | }));
36 | return resp;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/packages/job/src/config/main.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | API_PORT: process.env.API_PORT || 3000,
3 | MONGODB_URI: process.env.MONGODB_URI || 'mongodb://localhost:27017',
4 | DB_NAME: process.env.DB_NAME || 'job-dev',
5 | REDIS_URI: process.env.REDIS_URI || 'redis://localhost:6379',
6 | KAFKA_BROKER_LIST: process.env.KAFKA_BROKER_LIST || 'localhost:9092',
7 | KAFKA_CONSUMER_GROUP_ID: process.env.KAFKA_CONSUMER_GROUP_ID || 'cqrs-es-job',
8 | KAFKA_TOPICS_TO_SUBSCRIBE: process.env.KAFKA_TOPICS_TO_SUBSCRIBE || 'job.dev',
9 | CASSANDRA_HOSTS: process.env.CASSANDRA_HOSTS ? process.env.CASSANDRA_HOSTS.split(',') : ['localhost:9042'],
10 | CASSANDRA_DC: process.env.CASSANDRA_DC || 'datacenter1',
11 | CASSANDRA_KEYSPACE: process.env.CASSANDRA_KEYSPACE || 'cqrs_es_dev',
12 | };
13 |
--------------------------------------------------------------------------------
/packages/job/src/domain/events/job-archived.ts:
--------------------------------------------------------------------------------
1 | import { Event } from '@cqrs-es/core';
2 |
3 | import { JobStatus } from '../status';
4 |
5 | export class JobArchived extends Event {
6 | public eventName = JobArchived.name;
7 | public aggregateName = 'job';
8 | public status: JobStatus = JobStatus.ARCHIVED;
9 |
10 | constructor(public guid: string) {
11 | super(guid);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/job/src/domain/events/job-created.ts:
--------------------------------------------------------------------------------
1 | import { Event } from '@cqrs-es/core';
2 |
3 | import { JobStatus } from '../status';
4 |
5 | export class JobCreated extends Event {
6 | eventName = JobCreated.name;
7 | aggregateName = 'job';
8 |
9 | constructor(public guid: string, public title: string, public description: string, public status: JobStatus) {
10 | super(guid);
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/job/src/domain/events/job-updated.ts:
--------------------------------------------------------------------------------
1 | import { Event } from '@cqrs-es/core';
2 |
3 | import { JobStatus } from '../status';
4 |
5 | export class JobUpdated extends Event {
6 | eventName = JobUpdated.name;
7 | aggregateName = 'job';
8 |
9 | constructor(public guid: string, public title: string, public description: string, public status: JobStatus) {
10 | super(guid);
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/job/src/domain/job-event-store.interface.ts:
--------------------------------------------------------------------------------
1 | import { IEventStore } from '@cqrs-es/core';
2 |
3 | import { Job } from './job';
4 |
5 | export interface IJobEventStore extends IEventStore {}
6 |
--------------------------------------------------------------------------------
/packages/job/src/domain/job-repository.interface.ts:
--------------------------------------------------------------------------------
1 | import { IRepository } from '@cqrs-es/core';
2 |
3 | import { Job } from './job';
4 |
5 | export interface IJobRepository extends IRepository {}
6 |
--------------------------------------------------------------------------------
/packages/job/src/domain/job.ts:
--------------------------------------------------------------------------------
1 | import { AggregateRoot, ApplicationError } from '@cqrs-es/core';
2 | import { BAD_REQUEST } from 'http-status-codes';
3 |
4 | import { JobArchived } from './events/job-archived';
5 | import { JobCreated } from './events/job-created';
6 | import { JobUpdated } from './events/job-updated';
7 | import { JobStatus } from './status';
8 |
9 | export class Job extends AggregateRoot {
10 | private title: string;
11 | private description: string;
12 | private status: JobStatus = JobStatus.ACTIVE;
13 |
14 | constructor();
15 |
16 | constructor(guid: string, title: string, description: string);
17 |
18 | constructor(guid?: string, title?: string, description?: string) {
19 | super(guid);
20 |
21 | if (guid && title && description) {
22 | this.applyChange(new JobCreated(guid, title, description, this.status));
23 | }
24 | }
25 |
26 | updateInfo(title: string, description: string) {
27 | this.applyChange(new JobUpdated(this.guid, title, description, this.status));
28 | }
29 |
30 | archive() {
31 | if (this.status === JobStatus.ARCHIVED) {
32 | throw new ApplicationError(BAD_REQUEST, '5310', 'The job is already archived');
33 | }
34 | this.applyChange(new JobArchived(this.guid));
35 | }
36 |
37 | applyJobCreated(event: JobCreated) {
38 | this.guid = event.guid;
39 | this.title = event.title;
40 | this.description = event.description;
41 | }
42 |
43 | applyJobUpdated(event: JobUpdated) {
44 | this.title = event.title;
45 | this.description = event.description;
46 | }
47 |
48 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
49 | applyJobArchived(event: JobArchived) {
50 | this.status = JobStatus.ARCHIVED;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/packages/job/src/domain/status.ts:
--------------------------------------------------------------------------------
1 | export enum JobStatus {
2 | ACTIVE = 'ACTIVE',
3 | ARCHIVED = 'ARCHIVED',
4 | }
5 |
--------------------------------------------------------------------------------
/packages/job/src/infrastructure/commandBus/index.ts:
--------------------------------------------------------------------------------
1 | import { ICommand, ICommandBus, ICommandHandler } from '@cqrs-es/core';
2 | import { injectable } from 'inversify';
3 |
4 | @injectable()
5 | export class CommandBus implements ICommandBus {
6 | public handlers: Map> = new Map();
7 |
8 | public registerHandler(handler: ICommandHandler) {
9 | const targetCommand: string = handler.commandToHandle;
10 | if (this.handlers.has(targetCommand)) {
11 | return;
12 | }
13 | this.handlers.set(targetCommand, handler);
14 | }
15 |
16 | public async send(command: T) {
17 | if (this.handlers.has(command.constructor.name)) {
18 | return (this.handlers.get(command.constructor.name) as ICommandHandler).handle(command);
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/job/src/infrastructure/db/cassandra.ts:
--------------------------------------------------------------------------------
1 | import { Client } from 'cassandra-driver';
2 |
3 | export const createCassandraClient = (hosts: string[], dc: string, keyspace: string): Client => {
4 | const client: Client = new Client({
5 | contactPoints: hosts,
6 | localDataCenter: dc,
7 | keyspace,
8 | });
9 | return client;
10 | };
11 |
--------------------------------------------------------------------------------
/packages/job/src/infrastructure/db/mongodb.ts:
--------------------------------------------------------------------------------
1 | import config from '@config/main';
2 | import { MongoClientOptions, MongoClient, Db } from 'mongodb';
3 |
4 | export const createMongodbConnection = async (
5 | host: string,
6 | options: MongoClientOptions = {
7 | useNewUrlParser: true,
8 | useUnifiedTopology: true,
9 | }
10 | ): Promise => {
11 | return new Promise((resolve, reject) => {
12 | MongoClient.connect(host, options, (error, client) => {
13 | if (error) reject(error);
14 | resolve(client.db(config.DB_NAME));
15 | });
16 | });
17 | };
18 |
--------------------------------------------------------------------------------
/packages/job/src/infrastructure/event-store/job-event-store.ts:
--------------------------------------------------------------------------------
1 | import { EventStore, IEventBus } from '@cqrs-es/core';
2 | import { TYPES } from '@src/types';
3 | import { inject, injectable } from 'inversify';
4 | import { Db } from 'mongodb';
5 |
6 | import { IJobEventStore } from '@src/domain/job-event-store.interface';
7 |
8 | @injectable()
9 | export class JobEventStore extends EventStore implements IJobEventStore {
10 | constructor(@inject(TYPES.Db) private readonly db: Db, @inject(TYPES.EventBus) private readonly eventBus: IEventBus) {
11 | super(db.collection('job-events'), eventBus);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/job/src/infrastructure/eventbus/kafka.ts:
--------------------------------------------------------------------------------
1 | import { EventDescriptor, IEvent, IEventBus, IEventHandler, rehydrateEventFromDescriptor } from '@cqrs-es/core';
2 | import { TYPES } from '@src/types';
3 | import { classToPlain } from 'class-transformer';
4 | import { inject, injectable, multiInject } from 'inversify';
5 | import { Consumer, Producer } from 'kafkajs';
6 |
7 | @injectable()
8 | export class KafkaEventBus implements IEventBus {
9 | constructor(
10 | @multiInject(TYPES.Event) private readonly eventHandlers: IEventHandler[],
11 | @inject(TYPES.KafkaConsumer) private readonly _subscriber: Consumer,
12 | @inject(TYPES.KafkaProducer) private readonly _producer: Producer
13 | ) {}
14 |
15 | async publish(channel: string, eventDescriptor: EventDescriptor): Promise {
16 | const payload: string = JSON.stringify({ ...classToPlain(eventDescriptor) });
17 | await this._producer.send({
18 | topic: channel,
19 | messages: [
20 | {
21 | key: eventDescriptor.aggregateGuid,
22 | value: payload,
23 | },
24 | ],
25 | });
26 | }
27 |
28 | async subscribeEvents(): Promise {
29 | await this._subscriber.run({
30 | eachMessage: async ({ message, heartbeat }) => {
31 | if (message.value) {
32 | const eventDescriptor = JSON.parse(message.value.toString());
33 | const matchedHandlers: IEventHandler[] = this.eventHandlers.filter(
34 | (handler) => handler.event === eventDescriptor.eventName
35 | );
36 | await Promise.all(
37 | matchedHandlers.map((handler: IEventHandler) => {
38 | handler.handle(rehydrateEventFromDescriptor(eventDescriptor));
39 | })
40 | );
41 | await heartbeat();
42 | }
43 | },
44 | });
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/packages/job/src/infrastructure/module.ts:
--------------------------------------------------------------------------------
1 | import config from '@config/main';
2 | import { ICommandBus, IEventBus, IQuery, IQueryBus } from '@cqrs-es/core';
3 | import { TYPES } from '@src/types';
4 | import { Client } from 'cassandra-driver';
5 | import { AsyncContainerModule, interfaces } from 'inversify';
6 | import RedisClient, { Redis } from 'ioredis';
7 | import { Consumer, Kafka, Producer } from 'kafkajs';
8 | import { Db } from 'mongodb';
9 |
10 | import { IJobEventStore } from '@src/domain/job-event-store.interface';
11 | import { IJobRepository } from '@src/domain/job-repository.interface';
12 |
13 | import { CommandBus } from './commandBus';
14 | import { createCassandraClient } from './db/cassandra';
15 | import { createMongodbConnection } from './db/mongodb';
16 | import { JobEventStore } from './event-store/job-event-store';
17 | import { KafkaEventBus } from './eventbus/kafka';
18 | import { QueryBus } from './query-bus';
19 | import { JobRepository } from './repositories/job-repository';
20 |
21 | export const infrastructureModule = new AsyncContainerModule(async (bind: interfaces.Bind) => {
22 | const db: Db = await createMongodbConnection(config.MONGODB_URI);
23 | const cassandra: Client = createCassandraClient(
24 | config.CASSANDRA_HOSTS,
25 | config.CASSANDRA_DC,
26 | config.CASSANDRA_KEYSPACE
27 | );
28 |
29 | const kafka = new Kafka({ brokers: config.KAFKA_BROKER_LIST.split(',') });
30 | const kafkaProducer = kafka.producer();
31 | const kafkaConsumer = kafka.consumer({ groupId: config.KAFKA_CONSUMER_GROUP_ID });
32 | kafkaProducer.connect();
33 |
34 | bind(TYPES.Db).toConstantValue(db);
35 | bind(TYPES.CassandraDb).toConstantValue(cassandra);
36 | bind(TYPES.KafkaProducer).toConstantValue(kafkaProducer);
37 | bind(TYPES.KafkaConsumer).toConstantValue(kafkaConsumer);
38 | bind(TYPES.Redis).toConstantValue(new RedisClient(config.REDIS_URI));
39 | bind(TYPES.EventBus).to(KafkaEventBus);
40 | bind(TYPES.JobEventStore).to(JobEventStore).inSingletonScope();
41 | bind(TYPES.JobRepository).to(JobRepository).inSingletonScope();
42 | bind(TYPES.CommandBus).toConstantValue(new CommandBus());
43 | bind>(TYPES.QueryBus).toConstantValue(new QueryBus());
44 | });
45 |
--------------------------------------------------------------------------------
/packages/job/src/infrastructure/query-bus/index.ts:
--------------------------------------------------------------------------------
1 | import { IQuery, IQueryBus, IQueryHandler } from '@cqrs-es/core';
2 | import { injectable } from 'inversify';
3 |
4 | @injectable()
5 | export class QueryBus implements IQueryBus {
6 | public handlers: Map> = new Map();
7 |
8 | public registerHandler(handler: IQueryHandler) {
9 | const queryName = handler.queryToHandle;
10 | if (this.handlers.has(queryName)) {
11 | return;
12 | }
13 | this.handlers.set(queryName, handler);
14 | }
15 |
16 | public async execute(query: T) {
17 | if (this.handlers.has(query.constructor.name)) {
18 | return (this.handlers.get(query.constructor.name) as IQueryHandler).execute(query);
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/job/src/infrastructure/repositories/job-repository.ts:
--------------------------------------------------------------------------------
1 | import { EventSourcedRepository, IEventStore } from '@cqrs-es/core';
2 | import { TYPES } from '@src/types';
3 | import { inject, injectable } from 'inversify';
4 |
5 | import { Job } from '@src/domain/job';
6 | import { IJobRepository } from '@src/domain/job-repository.interface';
7 |
8 | @injectable()
9 | export class JobRepository extends EventSourcedRepository implements IJobRepository {
10 | constructor(@inject(TYPES.JobEventStore) private readonly eventstore: IEventStore) {
11 | super(eventstore, Job);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/job/src/startup.ts:
--------------------------------------------------------------------------------
1 | import '@src/api/http/controllers';
2 |
3 | import {
4 | ICommand,
5 | IQuery,
6 | ICommandHandler,
7 | ICommandBus,
8 | IQueryBus,
9 | IQueryHandler,
10 | IEventHandler,
11 | createWinstonLogger,
12 | } from '@cqrs-es/core';
13 | import { errorHandler } from '@src/api/http/middlewares/error-handler';
14 | import { JobCreatedEventHandler } from '@src/application/events/handlers/job-created-handler';
15 | import { TYPES } from '@src/types';
16 | import { Application, urlencoded, json } from 'express';
17 | import { Container } from 'inversify';
18 | import { InversifyExpressServer } from 'inversify-express-utils';
19 | import winston from 'winston';
20 |
21 | import { JobCreated } from '@domain/events/job-created';
22 | import { JobUpdated } from '@domain/events/job-updated';
23 | import { CreateJobCommandHandler } from '@src/application/commands/handlers/create-job-handler';
24 | import { UpdateJobCommandHandler } from '@src/application/commands/handlers/update-job-handler';
25 | import { JobUpdatedEventHandler } from '@src/application/events/handlers/job-updated-handler';
26 | import { GetAllJobsQueryHandler } from '@src/application/queries/handlers/get-all-jobs-query-handler';
27 |
28 | import { ArchiveJobCommandHandler } from './application/commands/handlers/archive-job-handler';
29 | import { JobArchivedEventHandler } from './application/events/handlers/job-archived-handler';
30 | import { JobArchived } from './domain/events/job-archived';
31 | import { infrastructureModule } from './infrastructure/module';
32 |
33 | const initialise = async () => {
34 | const container = new Container();
35 | const logger = createWinstonLogger('cqrs-es-job');
36 |
37 | await container.loadAsync(infrastructureModule);
38 |
39 | container.bind(TYPES.Logger).toConstantValue(logger);
40 | container.bind>(TYPES.Event).to(JobCreatedEventHandler);
41 | container.bind>(TYPES.Event).to(JobUpdatedEventHandler);
42 | container.bind>(TYPES.Event).to(JobArchivedEventHandler);
43 | container.bind>(TYPES.CommandHandler).to(CreateJobCommandHandler);
44 | container.bind>(TYPES.CommandHandler).to(UpdateJobCommandHandler);
45 | container.bind>(TYPES.CommandHandler).to(ArchiveJobCommandHandler);
46 | container.bind>(TYPES.QueryHandler).to(GetAllJobsQueryHandler);
47 |
48 | const commandBus = container.get(TYPES.CommandBus);
49 |
50 | container.getAll>(TYPES.CommandHandler).forEach((handler: ICommandHandler) => {
51 | commandBus.registerHandler(handler);
52 | });
53 |
54 | const queryBus = container.get(TYPES.QueryBus);
55 | container.getAll>(TYPES.QueryHandler).forEach((handler: IQueryHandler) => {
56 | queryBus.registerHandler(handler);
57 | });
58 |
59 | const server = new InversifyExpressServer(container);
60 |
61 | server.setConfig((app: Application) => {
62 | app.use(urlencoded({ extended: true }));
63 | app.use(json());
64 | });
65 |
66 | server.setErrorConfig((app: Application) => {
67 | app.use(errorHandler);
68 | });
69 |
70 | const apiServer = server.build();
71 | container.bind(TYPES.ApiServer).toConstantValue(apiServer);
72 |
73 | return container;
74 | };
75 |
76 | export { initialise };
77 |
--------------------------------------------------------------------------------
/packages/job/src/subscribers/index.ts:
--------------------------------------------------------------------------------
1 | import config from '@config/main';
2 | import { IEventBus } from '@cqrs-es/core';
3 | import * as dotenv from 'dotenv';
4 | dotenv.config();
5 | import 'reflect-metadata';
6 | import { Consumer } from 'kafkajs';
7 |
8 | import { initialise } from '../startup';
9 | import { TYPES } from '../types';
10 |
11 | (async () => {
12 | const container = await initialise();
13 | const kafkaConsumer = container.get(TYPES.KafkaConsumer);
14 | kafkaConsumer.connect();
15 |
16 | for (const topic of config.KAFKA_TOPICS_TO_SUBSCRIBE.split(',')) {
17 | await kafkaConsumer.subscribe({ topic });
18 | }
19 |
20 | const baseEventHandler = container.get(TYPES.EventBus);
21 | baseEventHandler.subscribeEvents();
22 | })();
23 |
--------------------------------------------------------------------------------
/packages/job/src/types.ts:
--------------------------------------------------------------------------------
1 | export const TYPES = {
2 | Db: Symbol('Db'),
3 | CassandraDb: Symbol('CassandraDb'),
4 | KafkaProducer: Symbol('KafkaProducer'),
5 | KafkaConsumer: Symbol('KafkaConsumer'),
6 | RedisSubscriber: Symbol('RedisSubscriber'),
7 | Redis: Symbol('Redis'),
8 | EventBus: Symbol('EventBus'),
9 | CommandBus: Symbol('CommandBus'),
10 | QueryBus: Symbol('QueryBus'),
11 | CommandHandler: Symbol('CommandHandler'),
12 | QueryHandler: Symbol('QueryHandler'),
13 | Event: Symbol('Event'),
14 | EventHandler: Symbol('EventHandler'),
15 | EventStore: Symbol('EventStore'),
16 | JobRepository: Symbol('JobRepository'),
17 | JobEventStore: Symbol('JobEventStore'),
18 | GetAllJobsQueryHandler: Symbol('GetAllJobsQueryHandler'),
19 | ApiServer: Symbol('ApiServer'),
20 | Logger: Symbol('Logger'),
21 | };
22 |
--------------------------------------------------------------------------------
/packages/job/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "rootDir": "src",
6 | "baseUrl": "src",
7 | "outDir": "dist",
8 | "paths": {
9 | "@src/*": [
10 | "*"
11 | ],
12 | "@common/*": [
13 | "common/*"
14 | ],
15 | "@commands/*": [
16 | "commands/*"
17 | ],
18 | "@config/*": [
19 | "config/*"
20 | ],
21 | "@constants/*": [
22 | "constants/*"
23 | ],
24 | "@domain/*": [
25 | "domain/*"
26 | ],
27 | "@infrastructure/*": [
28 | "infrastructure/*"
29 | ],
30 | "@api/*": [
31 | "api/*"
32 | ]
33 | },
34 | "types": [
35 | "reflect-metadata"
36 | ],
37 | },
38 | "include": ["src"],
39 | "exclude": [
40 | "node_modules"
41 | ],
42 | "references": [
43 | { "path": "../core" }
44 | ]
45 | }
--------------------------------------------------------------------------------
/setup/cassandra.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | until printf "" 2>>/dev/null >>/dev/tcp/cassandra/9042; do
3 | sleep 5;
4 | echo "Waiting for cassandra...";
5 | done
6 |
7 | echo "Initialising Cassandra ..."
8 | cqlsh cassandra -e "CREATE KEYSPACE IF NOT EXISTS cqrs_es_dev WITH replication = {'class': 'SimpleStrategy', 'replication_factor': '1'};"
9 | cqlsh cassandra -e "CREATE TABLE IF NOT EXISTS cqrs_es_dev.jobs (guid text PRIMARY KEY, title text, description text, status text, version int);"
10 |
--------------------------------------------------------------------------------
/setup/kafka.sh:
--------------------------------------------------------------------------------
1 | #!/bin/env sh
2 | kafka-topics --bootstrap-server kafka:29092 --list
3 |
4 | echo -e "Creating topics ..."
5 | kafka-topics --bootstrap-server kafka:29092 --create --if-not-exists --topic job --replication-factor 1 --partitions 1
6 | kafka-topics --bootstrap-server kafka:29092 --create --if-not-exists --topic application --replication-factor 1 --partitions 1
7 |
8 | echo -e "Successfully created topics:"
9 | kafka-topics --bootstrap-server kafka:29092 --list
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "commonjs",
5 | "lib": [
6 | "ESNext",
7 | "dom"
8 | ],
9 | "strict": true,
10 | "noImplicitAny": true,
11 | "strictPropertyInitialization": false,
12 | "moduleResolution": "node",
13 | "esModuleInterop": true,
14 | "experimentalDecorators": true,
15 | "emitDecoratorMetadata": true,
16 | "skipLibCheck": true,
17 | "forceConsistentCasingInFileNames": true
18 | },
19 | "exclude": [
20 | "node_modules"
21 | ]
22 | }
--------------------------------------------------------------------------------