├── .eslintrc ├── .github └── workflows │ └── workflow.yml ├── .gitignore ├── .npmignore ├── README.md ├── babel.config.js ├── manual-releases.md ├── package.json ├── prettier.config.js ├── src ├── domain │ └── todo-list │ │ ├── TodoList.spec.ts │ │ ├── TodoList.ts │ │ ├── TodoListCommandHandlers.ts │ │ ├── TodoListCommands.ts │ │ └── TodoListEvents.ts ├── framework │ ├── AggregateRoot.ts │ ├── Command.ts │ ├── Errors.ts │ ├── Event.ts │ ├── EventStore.ts │ ├── GivenWhenThen.ts │ ├── Guid.ts │ ├── IEventStore.ts │ ├── IMessage.ts │ ├── IMessageBus.ts │ ├── MessageBus.ts │ └── Repository.ts └── projections │ ├── TodoListProjection.spec.ts │ └── TodoListProjection.ts ├── tsconfig-build.json ├── tsconfig.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "extends": [ 7 | "airbnb-typescript", 8 | "airbnb/hooks", 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:prettier/recommended" 13 | ], 14 | "plugins": ["@typescript-eslint", "prettier"], 15 | "rules": { 16 | "import/no-extraneous-dependencies": ["error", {"devDependencies": ["**/*.spec.tsx", "**/*.spec.ts", "**/features/**/*.ts"]}], 17 | "prettier/prettier": "error", 18 | "react/jsx-filename-extension": 0, 19 | "import/prefer-default-export": 0, 20 | "@typescript-eslint/member-delimiter-style": 0, 21 | "@typescript-eslint/semi": 0, 22 | "@typescript-eslint/space-before-function-paren": 0, 23 | "no-underscore-dangle": 0, 24 | "@typescript-eslint/interface-name-prefix": 0, 25 | "@typescript-eslint/no-empty-interface": 0, 26 | "no-cond-assign": ["error", "except-parens"], 27 | "max-classes-per-file": 0, 28 | "lines-between-class-members": 0 29 | }, 30 | "env": { 31 | "browser": true, 32 | "jest": true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | test: 5 | runs-on: ubuntu-16.04 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: bahmutov/npm-install@v1 9 | - run: npm run type-check 10 | - run: npm run test 11 | - uses: paambaati/codeclimate-action@v2.6.0 12 | env: 13 | CC_TEST_REPORTER_ID: ab7786153e7183e5db0f5cfdec8b19bce91e78dcc4c482461f94a43b1e09c89b 14 | with: 15 | coverageLocations: | 16 | ${{github.workspace}}/coverage/jest/lcov.info:lcov 17 | debug: true 18 | # - name: Release 19 | # env: 20 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | # NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 22 | # run: npx semantic-release 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | coverage 4 | lib/ 5 | dist/ 6 | .cache 7 | .nyc_output 8 | yarn-error.log 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist/ 4 | .cache 5 | .github 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | # DDD, ES & CQRS w/ TS 7 | ## _Domain Driven Design, Event Sourcing & Command Query Responsibility Segregation with Typescript_ 8 | 9 | A reference repository that aims to reduce the learning curve for people trying to learn the above. 10 | 11 | ## About 12 | 13 | ES/CQRS is an architecture that separates the read and write models and uses events as the data store as opposed to a typical structured database. 14 | 15 | This approach allows you to build read models to cover use-cases as you need them, since you use events as the source of truth and not the latest snapshot of data. 16 | 17 | This means information is not lost and you are able to go back and forth in time to retroactively answer *business* questions. 18 | 19 | It is impossible to foresee future requirements and a structured data storage approach only answers today's requirements. An event-based storage approach allows you to replay those events. 20 | 21 | This is an incredible power and it is the *business* reason to do ES/CQRS. Here are some potential benefits: 22 | 23 | * Replay the events and create reports such as: "How many items do people add and remove from their cart before checking out?" and "How many invites do users send before a team becomes active?" 24 | * Quickly create new views of the same data for different business units 25 | * Distributed development where domain experts work on the domain side, and database experts work on the views side 26 | * Cheaper integration with other systems. Integration is built-in since external systems only have to subscribe to events. Integration is one of the biggest costs in enterprise software projects. 27 | 28 | You don't do ES/CQRS because it's sexy (even though it is!), you do it because it is an architecture that provides more business value over the long-term than the alternatives. 29 | 30 | However, ES/CQRS can be a little intimidating to get started with and the learning curve is a little on the steep side. This is the reason this repository exists, to make that learning curve a little less steep. 31 | 32 | 33 | ## The Architecture 34 | Here's a little discussion on the differences between ES/CQRS and other architectures. 35 | 36 | ### Non ES/CQRS Architectures 37 | A good majority of software architectures deal with a centralized store for state such as a database. Requests from clients set and retrieve state from the centralized store and the state is kept up to date. Here the read and write models and combined into that centralized store. 38 | 39 | The flow may look something like this in a synchronous model: 40 | ``` 41 | Make request (Client) > Execute business logic (Service) > Write to the database (Repository) > Give response to the user (Service) 42 | ``` 43 | 44 | Or like this in an asynchronous model: 45 | ``` 46 | Make write request (Client) > Queue request (Controller) > Return ack/nack to user (Controller) 47 | 48 | Read queue (Process) > Execute business logic (Service) > Write the database (Repository) 49 | 50 | Make read request (Client) > Execute read logic (Service) > Get values from database (Repository) 51 | ``` 52 | _Note that the request and response are separate in the above example but the data read/write to the database are unified._ 53 | 54 | ### ES/CQRS 55 | This architecture separates the write model from the read model such that the data stores are different for each side, which is necessarily asynchronous. 56 | 57 | Here's the flow for the read and write models: 58 | 59 | Write Model 60 | ``` 61 | Send Command (Client) > Handle Command (Command Handlers) > Execute business logic (Aggregate) > Raise events/exceptions (Aggregate) > Store events (Event Store) > Publish Event (Publisher) 62 | ``` 63 | 64 | Read Model 65 | ``` 66 | Subscribe to Events (Projection) > Build a read model as events come in (Projection) > persist in some data store (Projection) 67 | ``` 68 | _Note that you can also replay events from specific start/end times and subscribe to those, in this case the read model is on-demand as opposed to continuous_ 69 | 70 | 71 | ### Eventual Consistency 72 | 73 | [As per CAP theorem](https://en.wikipedia.org/wiki/CAP_theorem), it is impossible for a distributed data store to simultaneously provide more than two out of the following three guarantees: 74 | 75 | * Consistency: Every read receives the most recent write or an error 76 | * Availability: Every request receives a (non-error) response, without the guarantee that it contains the most recent write 77 | * Partition tolerance: The system continues to operate despite an arbitrary number of messages being dropped (or delayed) by the network between nodes 78 | 79 | Different data store approaches optimize for different axioms. 80 | 81 | You might consider that non ES/CQRS architectures are consistent, but in reality they are not. They are also eventually consistent. A request may come in to change state while another request comes in to read that same state. There is a probability that a user may get out of date information. However, because the processing and locking of databases happens so quickly, it minimizes the window in which this can eventual consistency happens. 82 | 83 | Event sourcing embraces the eventual consistency axiom and optimizes for availability and partition tolerance. This means conversations have to be had on a case-by-case basis for how to handle the up-to-dateness of information. For example, if the data on a report is 10m late this may be a non-issue and doesn't require any additional work, however requiring unique users may be a must-have, in which case you may need to have some sort of index that validates new user requests prior to writing events. 84 | 85 | ## Sam's Rough Learning Notes 86 | As I watched videos, read articles, and parsed code, I wrote down as much of my learning journey as I could, at the same time as writing the code for this repository. I've retrospectively tried to structure the notes a little. I hope you find these useful! 87 | 88 | #### Command > Domain Interaction 89 | 1. The command is responsible for loading the aggregate root from the repo (if needed) 90 | 1. the repo gets the aggregate root and replays all the events on it 91 | 1. The command acts on the aggregate root 92 | 1. The aggregate root does not mutate state at all. It validates then it performs an "applyChange" with the event in past tense 93 | 1. the aggregate root puts the event(s) in an "uncommittedEvents" list inside the aggregate root 94 | 1. The command tells the repository to save the aggregate root 95 | 1. the repository will save all the uncommitted events on a domain model 96 | 97 | #### Read Model Projection 98 | - subscribes to events and builds a view out of them continuously. This is different to snapshots 99 | 100 | #### Snapshots 101 | - used for replay performance and not projections (though they would help here too) 102 | - only consider after ~1,000 events 103 | 104 | #### Event Stores 105 | - every event for an aggregate increments by one for every event added 106 | - the event store throws a concurrency exception when the event version does not match the aggregate expected version 107 | - event stores are not trivial to implement and it's good to use a framework here 108 | 109 | ## Notes on commands: 110 | - they help debug issues if they are stored 111 | - they help with intelligence about user actions 112 | - they can be used to run the ultimate smoke test: Run all production commands on the new system and expect the logs to be the same for the unchanged parts. 113 | 114 | ## On uniqueness: 115 | - https://groups.google.com/d/msg/dddcqrs/aUltOB2a-3Y/0p0PQVNFONQJ 116 | - An elegant solution is to use a hash of the object as the aggregate id, which makes it easy to find an existing aggregate in the store. 117 | 118 | # Notes from [watching Greg's 6h video](https://www.youtube.com/watch?v=whCk1Q87_ZI) 119 | - Commands are always in the imperative tense. Events always in the past tense 120 | - CQRS means creating one object with all the commands on it, and another with all the queries on it 121 | - Allows you to version your commands separately from your queries, and allows you to deploy them to different places also 122 | - Queries are basically the read model it projections. They don't need a DB to scale. They need functions and that cheaper to scale. It's a functional database 123 | - The reason to have a command message vs a command handler, is that the handler can have DI like repos injected into it and the message portion can remain simple 124 | - Never hold logic in your command handler. Logic belongs in the domain only 125 | - Never handle transactions in a command. Instead create a TransactionHandler that can call a sequence of commands. See 1:14:00 in video. You can create a transactional handler that chains logging in top of the actual command 126 | - This is the pattern for doing cross cutting concerns like logging. Could be seen as a pipeline - Chain of responsibility - Can wrap in a unit of work 127 | - Command handlers are always a 1:1 mapping from incoming commands. A hashtable is sufficient 128 | - Can use annotations I think for for the chain of command 129 | - It's important to have 2 separate classes for commands and events even though they are typically identical bar the tense. It makes the language make sense. 130 | - You can reject a command but you can't reject an event as you are not part of that transaction. When you reason about this through language, the differentiation in the classes makes it easier to express as language 131 | - For example, may also have some bad events that need to be corrected with a command. When you try to read these back with a mixed tense, it becomes difficult 132 | - You typically don't raise events for failures, since domains are state transitions and a failure means no state transitions should have happened. Better to log these at the command level. And actually keep all logging at the command handler level using chain of command 133 | - May also want to keep logging out of the transaction since io creates latency 134 | - There are huge benefit to using ES from a business perspective. Like I may want to know: it seems that when people invite someone after they've written 2 specs they are more likely to stick around in the app. Because we don't lose any information, we can test that theory and come up with business intelligence. This cant be done retrospectively with a structural data approach. 135 | - Commands have behaviors. You don't replay them. Eg. You don't want to bill someone's credit card twice 136 | - You can change your domain model independently of the structural model 137 | - When you have a bug, you can relay all the events up to that bug, and that becomes your unit test "setup" portion 138 | - Integration model is built in from the start. It's a push model. Anyone interested in integration simply subscribes to the events 139 | - Research task based ui's 140 | - Start with adding events, this will already give you massive reporting value 141 | - Then separate your read model. This enables you to have lots of read models (different structural models for different needs) (edited) 142 | - Publish and send for the bus 143 | - Research sagas 144 | 145 | ## Useful Links: 146 | * CQRS FAQ - http://cqrs.nu/Faq - (seems to be offline, but [here's the cached version](https://webcache.googleusercontent.com/search?q=cache:http://cqrs.nu/Faq&strip=1&vwsrc=0)) 147 | * Greg Young Simple CQRS repo (.Net) - https://github.com/gregoryyoung/m-r/tree/master/SimpleCQRS 148 | * Node Aggregate Example - https://github.com/jamuhl/nodeCQRS/blob/master/domain/app/itemAggregate.js 149 | * Greg Young's simple CQRS example written using Node.js - https://github.com/JanVanRyswyck/node-m-r 150 | * DDD/CQRS Forums - https://groups.google.com/forum/#!forum/dddcqrs 151 | * CQRS Documents by Greg Young - https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf 152 | * GregYoung 8 CQRS Class - https://www.youtube.com/watch?v=whCk1Q87_ZI 153 | * Rough Notes about CQRS and ES - https://gist.github.com/jaceklaskowski/d267bf4176822293e95e 154 | * Greg Young - CQRS and Event Sourcing - Code on the Beach 2014 - https://www.youtube.com/watch?v=JHGkaShoyNs 155 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-react', 5 | '@babel/typescript', 6 | ], 7 | plugins: [['@babel/plugin-proposal-class-properties', {loose: true}]], 8 | } 9 | -------------------------------------------------------------------------------- /manual-releases.md: -------------------------------------------------------------------------------- 1 | # manual-releases 2 | 3 | This project has an automated release set up. So things are only released when 4 | there are useful changes in the code that justify a release. But sometimes 5 | things get messed up one way or another and we need to trigger the release 6 | ourselves. When this happens, simply bump the number below and commit that with 7 | the following commit message based on your needs: 8 | 9 | **Major** 10 | 11 | ``` 12 | fix(release): manually release a major version 13 | 14 | There was an issue with a major release, so this manual-releases.md 15 | change is to release a new major version. 16 | 17 | Reference: # 18 | 19 | BREAKING CHANGE: 20 | ``` 21 | 22 | **Minor** 23 | 24 | ``` 25 | feat(release): manually release a minor version 26 | 27 | There was an issue with a minor release, so this manual-releases.md 28 | change is to release a new minor version. 29 | 30 | Reference: # 31 | ``` 32 | 33 | **Patch** 34 | 35 | ``` 36 | fix(release): manually release a patch version 37 | 38 | There was an issue with a patch release, so this manual-releases.md 39 | change is to release a new patch version. 40 | 41 | Reference: # 42 | ``` 43 | 44 | The number of times we've had to do a manual release is: 3 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "event-sourcing-cqrs-with-typescript", 3 | "author": "Sam Hatoum", 4 | "contributors": [ 5 | "Joshua Ohlman", 6 | "Lukasz Gendecki", 7 | "Michal Baranowski" 8 | ], 9 | "version": "0.0.0-development", 10 | "description": "", 11 | "main": "lib/index.js", 12 | "directories": { 13 | "lib": "./lib" 14 | }, 15 | "scripts": { 16 | "test:unit:watch": "jest --watch", 17 | "test:unit": "jest --coverage", 18 | "test": "rm -rf coverage && npm run test:unit", 19 | "prepublish": "rm -rf lib && npm run build", 20 | "build:watch": "npm run build:js -- --watch", 21 | "coverage": "echo Coverage already collected using test runs. Github actions code-climate plugin needs this task :/", 22 | "type-check": "tsc --noEmit", 23 | "type-check:watch": "npm run type-check -- --watch", 24 | "build": "npm run build:types && npm run build:js", 25 | "build:types": "tsc -p tsconfig-build.json", 26 | "build:js": "babel src --out-dir lib --ignore 'src/**/*.spec.ts','src/**/*.spec.tsx' --extensions \".ts,.tsx\" --source-maps inline ", 27 | "start:example": "parcel ./example/public/index.html" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/xolvio/xspecs-ng.git" 32 | }, 33 | "files": [ 34 | "**/lib/*" 35 | ], 36 | "license": "MIT", 37 | "dependencies": { 38 | "uuid": "^8.0.0" 39 | }, 40 | "devDependencies": { 41 | "@babel/cli": "^7.2.3", 42 | "@babel/core": "^7.2.2", 43 | "@babel/node": "^7.2.2", 44 | "@babel/plugin-proposal-class-properties": "^7.8.3", 45 | "@babel/plugin-syntax-decorators": "^7.8.3", 46 | "@babel/preset-env": "^7.2.3", 47 | "@babel/preset-react": "^7.0.0", 48 | "@babel/preset-typescript": "^7.9.0", 49 | "@testing-library/dom": "^7.2.1", 50 | "@testing-library/jest-dom": "^5.1.0", 51 | "@testing-library/react": "^9.4.0", 52 | "@testing-library/user-event": "^7.1.2", 53 | "@types/jest": "^25.1.1", 54 | "@types/node": "~12.12.23", 55 | "@types/parcel-env": "^0.0.0", 56 | "@typescript-eslint/eslint-plugin": "^2.29.0", 57 | "@typescript-eslint/parser": "^2.29.0", 58 | "babel-jest": "^24.9.0", 59 | "browserslist": "^4.12.0", 60 | "concurrently": "^5.2.0", 61 | "cucumber": "^6.0.5", 62 | "cz-conventional-changelog": "3.1.0", 63 | "eslint": "^6.8.0", 64 | "eslint-config-airbnb": "^18.1.0", 65 | "eslint-config-airbnb-base": "^14.1.0", 66 | "eslint-config-airbnb-typescript": "^7.2.0", 67 | "eslint-config-prettier": "^6.10.1", 68 | "eslint-plugin-import": "^2.20.2", 69 | "eslint-plugin-jsx-a11y": "^6.2.3", 70 | "eslint-plugin-prettier": "^3.1.3", 71 | "eslint-plugin-react": "^7.19.0", 72 | "eslint-plugin-react-hooks": "^2.5.0", 73 | "expect": "^26.0.1", 74 | "husky": "^4.2.1", 75 | "jest": "^25.1.0", 76 | "lint-staged": "^10.0.7", 77 | "nyc": "^15.0.1", 78 | "parcel-bundler": "^1.12.4", 79 | "prettier": "^2.0.4", 80 | "react": "^16.13.1", 81 | "react-dom": "^16.13.1", 82 | "regenerator-runtime": "^0.13.5", 83 | "semantic-release": "^17.0.7", 84 | "sort-package-json": "^1.42.1", 85 | "testdouble": "^3.12.5", 86 | "ts-node": "^8.8.2", 87 | "typescript": "^3.8.3", 88 | "watch": "^1.0.2" 89 | }, 90 | "peerDependencies": { 91 | "react": ">= 16.8.0" 92 | }, 93 | "keywords": [ 94 | "react-hooks", 95 | "domain-driven-design", 96 | "clean-architecture", 97 | "cqrs" 98 | ], 99 | "husky": { 100 | "hooks": { 101 | "prepare-commit-msg": "exec < /dev/tty && git cz --hook || true", 102 | "pre-commit": "concurrently --success all \"yarn type-check\" \"lint-staged\"" 103 | } 104 | }, 105 | "lint-staged": { 106 | "src/*.ts*": [ 107 | "eslint --fix", 108 | "jest --findRelatedTests" 109 | ], 110 | "example/**/*.ts*": [ 111 | "eslint --fix", 112 | "jest --findRelatedTests" 113 | ] 114 | }, 115 | "jest": { 116 | "testPathIgnorePatterns": [ 117 | "/lib" 118 | ], 119 | "coverageDirectory": "/coverage/jest" 120 | }, 121 | "browserslist": [ 122 | "defaults" 123 | ], 124 | "config": { 125 | "commitizen": { 126 | "path": "./node_modules/cz-conventional-changelog" 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | bracketSpacing: false, 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/todo-list/TodoList.spec.ts: -------------------------------------------------------------------------------- 1 | import {GivenWhenThen} from '../../framework/GivenWhenThen' 2 | import {guid} from '../../framework/Guid' 3 | import { 4 | AddTodoToList, 5 | CreateTodoList, 6 | MarkTodoAsComplete, 7 | RenameTodoList, 8 | } from './TodoListCommands' 9 | import { 10 | TodoAddedToList, 11 | TodoListCreated, 12 | TodoListRenamed, 13 | TodoMarkedAsComplete, 14 | } from './TodoListEvents' 15 | import { 16 | DuplicateEntryError, 17 | MissingParameterError, 18 | NotFoundError, 19 | } from '../../framework/Errors' 20 | 21 | const GWT = GivenWhenThen(`${__dirname}/TodoList`) 22 | 23 | test('Create todoList', () => 24 | GWT((Given, When, Then) => { 25 | const todoListId = guid() 26 | 27 | When(new CreateTodoList(todoListId, 'bar')) 28 | Then(new TodoListCreated(todoListId, 'bar')) 29 | })) 30 | 31 | test('Rename todoList', () => 32 | GWT((Given, When, Then) => { 33 | const todoListId = guid() 34 | 35 | Given(new TodoListCreated(todoListId, 'foo')) 36 | When(new RenameTodoList(todoListId, 'bar', 1)) 37 | Then(new TodoListRenamed(todoListId, 'bar')) 38 | })) 39 | 40 | test('Rename todoList fail', () => 41 | GWT((Given, When, Then) => { 42 | const todoListId = guid() 43 | 44 | Given(new TodoListCreated(todoListId, 'foo')) 45 | When(new RenameTodoList(todoListId, '', 1)) 46 | expect((): void => { 47 | Then(new TodoListRenamed(todoListId, 'bar')) 48 | }).toThrow(MissingParameterError) 49 | })) 50 | 51 | test('Add todo to list', () => 52 | GWT((Given, When, Then) => { 53 | const todoListId = guid() 54 | 55 | Given(new TodoListCreated(todoListId, 'foo')) 56 | When(new AddTodoToList(todoListId, 'feed the cat', 1)) 57 | Then(new TodoAddedToList(todoListId, 'feed the cat')) 58 | })) 59 | 60 | test('Add todo to list fails for duplicate name todos', () => 61 | GWT((Given, When, Then) => { 62 | const todoListId = guid() 63 | 64 | Given( 65 | new TodoListCreated(todoListId, 'foo'), 66 | new TodoAddedToList(todoListId, 'feed the cat') 67 | ) 68 | When(new AddTodoToList(todoListId, 'feed the cat', 1)) 69 | expect((): void => { 70 | Then(new TodoAddedToList(todoListId, 'feed the cat')) 71 | }).toThrow(DuplicateEntryError) 72 | })) 73 | 74 | test('Mark todo as complete', () => 75 | GWT((Given, When, Then) => { 76 | const todoListId = guid() 77 | 78 | Given( 79 | new TodoListCreated(todoListId, 'foo'), 80 | new TodoAddedToList(todoListId, 'feed Bob') 81 | ) 82 | When(new MarkTodoAsComplete(todoListId, 'feed Bob', 1)) 83 | Then(new TodoMarkedAsComplete(todoListId, 'feed Bob')) 84 | })) 85 | 86 | test('Mark todo as complete fails', () => 87 | GWT((Given, When, Then) => { 88 | const todoListId = guid() 89 | 90 | Given(new TodoListCreated(todoListId, 'foo')) 91 | When(new MarkTodoAsComplete(todoListId, 'feed Bob', 1)) 92 | expect((): void => { 93 | Then(new TodoMarkedAsComplete(todoListId, 'feed Bob')) 94 | }).toThrow(NotFoundError) 95 | })) 96 | -------------------------------------------------------------------------------- /src/domain/todo-list/TodoList.ts: -------------------------------------------------------------------------------- 1 | import {AggregateRoot} from '../../framework/AggregateRoot' 2 | import { 3 | TodoAddedToList, 4 | TodoListCreated, 5 | TodoListRenamed, 6 | TodoMarkedAsComplete, 7 | } from './TodoListEvents' 8 | import { 9 | DuplicateEntryError, 10 | MissingParameterError, 11 | NotFoundError, 12 | } from '../../framework/Errors' 13 | 14 | class Todo { 15 | complete: boolean 16 | constructor(public readonly name: string) {} 17 | } 18 | 19 | export class TodoList extends AggregateRoot { 20 | private name: string 21 | private todos: Todo[] = [] 22 | 23 | constructor(guid: string, name: string) { 24 | super() 25 | this.applyChange(new TodoListCreated(guid, name)) 26 | } 27 | applyTodoListCreated(event: TodoListCreated): void { 28 | this._id = event.aggregateId 29 | this.name = event.name 30 | } 31 | 32 | rename(name: string): void { 33 | if (!name) throw new MissingParameterError('Must provide a name') 34 | this.applyChange(new TodoListRenamed(this._id, name)) 35 | } 36 | applyTodoListRenamed(event: TodoListRenamed): void { 37 | this._id = event.aggregateId 38 | this.name = event.name 39 | } 40 | 41 | addTodo(todoName: string): void { 42 | if (this.todos.find((todo) => todo.name === todoName)) { 43 | throw new DuplicateEntryError(`${todoName} already exists`) 44 | } 45 | this.applyChange(new TodoAddedToList(this._id, todoName)) 46 | } 47 | applyTodoAddedToList(event: TodoAddedToList): void { 48 | this.todos.push(new Todo(event.todoName)) 49 | } 50 | 51 | markTodoAsComplete(todoName: string): void { 52 | if (!this.todos.find((todo) => todo.name === todoName)) { 53 | throw new NotFoundError(`${todoName} not found`) 54 | } 55 | this.applyChange(new TodoMarkedAsComplete(this._id, todoName)) 56 | } 57 | applyTodoMarkedAsComplete(event: TodoMarkedAsComplete): void { 58 | const matchedTodo = this.todos.find( 59 | (todo: Todo) => todo.name === event.todoName 60 | ) 61 | matchedTodo.complete = true 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/domain/todo-list/TodoListCommandHandlers.ts: -------------------------------------------------------------------------------- 1 | import {IRepository} from '../../framework/Repository' 2 | import {TodoList} from './TodoList' 3 | import { 4 | AddTodoToList, 5 | CreateTodoList, 6 | MarkTodoAsComplete, 7 | RenameTodoList, 8 | } from './TodoListCommands' 9 | 10 | export class TodoListCommandHandlers { 11 | constructor(private _repository: IRepository) {} 12 | 13 | handleCreateTodoList(command: CreateTodoList): void { 14 | const todoList = new TodoList(command.aggregateId, command.name) 15 | this._repository.save(todoList, -1) 16 | } 17 | 18 | handleRenameTodoList(command: RenameTodoList): void { 19 | const todoList = this._repository.getById(command.aggregateId) 20 | todoList.rename(command.name) 21 | this._repository.save(todoList, command.expectedAggregateVersion) 22 | } 23 | 24 | handleAddTodoToList(command: AddTodoToList): void { 25 | const todoList = this._repository.getById(command.aggregateId) 26 | todoList.addTodo(command.todoName) 27 | this._repository.save(todoList, command.expectedAggregateVersion) 28 | } 29 | 30 | handleMarkTodoAsComplete(command: MarkTodoAsComplete): void { 31 | const todoList = this._repository.getById(command.aggregateId) 32 | todoList.markTodoAsComplete(command.todoName) 33 | this._repository.save(todoList, command.expectedAggregateVersion) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/domain/todo-list/TodoListCommands.ts: -------------------------------------------------------------------------------- 1 | import {Command} from '../../framework/Command' 2 | 3 | export class CreateTodoList extends Command { 4 | constructor( 5 | public readonly aggregateId: string, 6 | public readonly name: string 7 | ) { 8 | super(-1) 9 | } 10 | } 11 | 12 | export class AddTodoToList extends Command { 13 | constructor( 14 | public readonly aggregateId: string, 15 | public readonly todoName: string, 16 | public readonly expectedAggregateVersion: number 17 | ) { 18 | super(expectedAggregateVersion) 19 | } 20 | } 21 | 22 | export class MarkTodoAsComplete extends Command { 23 | constructor( 24 | public readonly aggregateId: string, 25 | public readonly todoName: string, 26 | public readonly expectedAggregateVersion: number 27 | ) { 28 | super(expectedAggregateVersion) 29 | } 30 | } 31 | 32 | export class RenameTodoList extends Command { 33 | constructor( 34 | public readonly aggregateId: string, 35 | public readonly name: string, 36 | public readonly expectedAggregateVersion: number 37 | ) { 38 | super(expectedAggregateVersion) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/domain/todo-list/TodoListEvents.ts: -------------------------------------------------------------------------------- 1 | import {Event} from '../../framework/Event' 2 | 3 | export class TodoListCreated extends Event { 4 | constructor( 5 | public readonly aggregateId: string, 6 | public readonly name: string 7 | ) { 8 | super(aggregateId) 9 | } 10 | } 11 | 12 | export class TodoAddedToList extends Event { 13 | constructor( 14 | public readonly aggregateId: string, 15 | public readonly todoName: string 16 | ) { 17 | super(aggregateId) 18 | } 19 | } 20 | 21 | export class TodoMarkedAsComplete extends Event { 22 | constructor( 23 | public readonly aggregateId: string, 24 | public readonly todoName: string 25 | ) { 26 | super(aggregateId) 27 | } 28 | } 29 | 30 | export class TodoListRenamed extends Event { 31 | constructor( 32 | public readonly aggregateId: string, 33 | public readonly name: string 34 | ) { 35 | super(aggregateId) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/framework/AggregateRoot.ts: -------------------------------------------------------------------------------- 1 | import {Event} from './Event' 2 | 3 | export abstract class AggregateRoot { 4 | private _changes: Event[] = [] 5 | protected _id: string 6 | 7 | get id(): string { 8 | return this._id 9 | } 10 | 11 | private _version: number 12 | 13 | public getUncommittedChanges(): Event[] { 14 | return this._changes 15 | } 16 | 17 | markChangesAsCommitted(): void { 18 | this._changes.length = 0 19 | } 20 | 21 | loadFromHistory(history: Event[]): void { 22 | history.forEach((event) => { 23 | this.applyChangeInternal(event, false) 24 | }) 25 | } 26 | 27 | protected applyChange(event: Event): void { 28 | this.applyChangeInternal(event, true) 29 | } 30 | 31 | private applyChangeInternal(event: Event, isNew = false): void { 32 | if (!this[`apply${event.constructor.name}`]) { 33 | throw new Error( 34 | `No handler found for ${event.constructor.name}. Be sure to define a method called apply${event.constructor.name} on the aggregate.` 35 | ) 36 | } 37 | 38 | this[`apply${event.constructor.name}`](event) 39 | this._version += 1 40 | 41 | if (isNew) { 42 | this._changes.push(event) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/framework/Command.ts: -------------------------------------------------------------------------------- 1 | import {IMessage} from './IMessage' 2 | 3 | export class Command implements IMessage { 4 | constructor(public readonly expectedAggregateVersion: number) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/framework/Errors.ts: -------------------------------------------------------------------------------- 1 | export class ConcurrencyViolationError extends Error { 2 | constructor(message?: string) { 3 | super(message) 4 | Object.setPrototypeOf(this, new.target.prototype) 5 | } 6 | } 7 | 8 | export class MissingParameterError extends Error { 9 | constructor(message?: string) { 10 | super(message) 11 | Object.setPrototypeOf(this, new.target.prototype) 12 | } 13 | } 14 | 15 | export class NotFoundError extends Error { 16 | constructor(message?: string) { 17 | super(message) 18 | Object.setPrototypeOf(this, new.target.prototype) 19 | } 20 | } 21 | 22 | export class DuplicateEntryError extends Error { 23 | constructor(message?: string) { 24 | super(message) 25 | Object.setPrototypeOf(this, new.target.prototype) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/framework/Event.ts: -------------------------------------------------------------------------------- 1 | import {IMessage} from './IMessage' 2 | 3 | export class Event implements IMessage { 4 | constructor(public readonly aggregateId: string) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/framework/EventStore.ts: -------------------------------------------------------------------------------- 1 | import {IEventStore} from './IEventStore' 2 | import {IMessageBus} from './IMessageBus' 3 | import {Event} from './Event' 4 | import {ConcurrencyViolationError} from './Errors' 5 | 6 | class EventDescriptor { 7 | constructor( 8 | public readonly aggregateId: string, 9 | public readonly event: Event, 10 | public readonly version: number 11 | ) {} 12 | } 13 | 14 | export class EventStore implements IEventStore { 15 | events = {} 16 | 17 | constructor(private messageBus: IMessageBus) {} 18 | 19 | getEventsForAggregate(aggregateId: string): Event[] { 20 | return this.events[aggregateId].map( 21 | (eventDescriptor) => eventDescriptor.event 22 | ) 23 | } 24 | 25 | saveEvents( 26 | aggregateId: string, 27 | newEvents: Event[], 28 | expectedAggregateVersion: number 29 | ): void { 30 | if (!this.events[aggregateId]) { 31 | this.events[aggregateId] = [] 32 | } 33 | 34 | const lastEventDescriptor = this.getLastEventDescriptor(aggregateId) 35 | if ( 36 | this.events[aggregateId].length > 0 && 37 | lastEventDescriptor.version !== expectedAggregateVersion 38 | ) { 39 | throw new ConcurrencyViolationError( 40 | 'An operation has been performed on an aggregate root that is out of date.' 41 | ) 42 | } 43 | let i = 0 44 | newEvents.forEach((event: Event) => { 45 | i += 1 46 | this.events[aggregateId].push(new EventDescriptor(aggregateId, event, i)) 47 | }) 48 | 49 | this.publish(newEvents) 50 | } 51 | 52 | private getLastEventDescriptor(aggregateId: string): EventDescriptor { 53 | return this.events[aggregateId][this.events[aggregateId].length - 1] 54 | } 55 | 56 | private publish(events: Event[]): void { 57 | events.forEach((event: Event) => this.messageBus.publish(event)) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/framework/GivenWhenThen.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable @typescript-eslint/no-var-requires 2 | import {Event} from './Event' 3 | import {Command} from './Command' 4 | import {MessageBus} from './MessageBus' 5 | import {EventStore} from './EventStore' 6 | import {Repository} from './Repository' 7 | 8 | export function GivenWhenThen(aggregatePath: string): Function { 9 | return (cb: Function): Function => { 10 | const dir = aggregatePath.substring(0, aggregatePath.lastIndexOf('/')) 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | function req(moduleName, extract = false): any { 13 | if (extract) 14 | // eslint-disable-next-line global-require,import/no-dynamic-require 15 | return require(`${dir}/${moduleName}`)[`${moduleName}`] 16 | // eslint-disable-next-line global-require,import/no-dynamic-require 17 | return require(`${dir}/${moduleName}`) 18 | } 19 | 20 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 21 | // @ts-ignore 22 | const aggregateName = aggregatePath.substring( 23 | aggregatePath.lastIndexOf('/') + 1 24 | ) 25 | 26 | const Aggregate = req(aggregateName, true) 27 | const AggregateCommandHandlers = req( 28 | `${aggregateName}CommandHandlers`, 29 | true 30 | ) 31 | // eslint-disable-next-line @typescript-eslint/no-var-requires,global-require,import/no-dynamic-require 32 | const AggregateCommands = require(`${dir}/${aggregateName}Commands`) 33 | 34 | const messageBus = new MessageBus() 35 | const eventStore = new EventStore(messageBus) 36 | const repository = new Repository(eventStore, Aggregate) 37 | 38 | messageBus.registerCommandHandlers( 39 | AggregateCommands, 40 | new AggregateCommandHandlers(repository) 41 | ) 42 | 43 | function Given(...events: Event[]): void { 44 | events.forEach((event) => { 45 | eventStore.saveEvents(event.aggregateId, [event], 1) 46 | }) 47 | } 48 | 49 | let whenCallback 50 | 51 | function When(...commands: Command[]): void { 52 | whenCallback = (): void => { 53 | commands.forEach((command) => messageBus.send(command)) 54 | } 55 | } 56 | 57 | function Then(...expectedEvents: Event[]): void { 58 | expectedEvents.forEach((expectedEvent) => { 59 | messageBus.registerEventHandler( 60 | expectedEvent.constructor, 61 | (actualEvent) => { 62 | expect(actualEvent).toEqual(expectedEvent) 63 | } 64 | ) 65 | }) 66 | whenCallback() 67 | } 68 | 69 | return cb(Given, When, Then, messageBus) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/framework/Guid.ts: -------------------------------------------------------------------------------- 1 | import {v4 as uuidv4} from 'uuid' 2 | 3 | export function guid(): string { 4 | return uuidv4() 5 | } 6 | -------------------------------------------------------------------------------- /src/framework/IEventStore.ts: -------------------------------------------------------------------------------- 1 | import {Event} from './Event' 2 | 3 | export interface IEventStore { 4 | saveEvents( 5 | aggregateId: string, 6 | events: Event[], 7 | expectedVersion: number 8 | ): void 9 | getEventsForAggregate(aggregateId: string): Event[] 10 | } 11 | -------------------------------------------------------------------------------- /src/framework/IMessage.ts: -------------------------------------------------------------------------------- 1 | // this is so we can pop both commands and events on a message bus 2 | export interface IMessage {} 3 | -------------------------------------------------------------------------------- /src/framework/IMessageBus.ts: -------------------------------------------------------------------------------- 1 | import {IMessage} from './IMessage' 2 | import {Command} from './Command' 3 | import {Event} from './Event' 4 | 5 | export interface IMessageBus { 6 | registerEventHandler( 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | event: any, 9 | handler: (e: IMessage) => void 10 | ): void 11 | registerCommandHandlers( 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | commands: any, 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | handlers: any 16 | ): void 17 | send(command: T): void 18 | publish(event: T): void 19 | } 20 | -------------------------------------------------------------------------------- /src/framework/MessageBus.ts: -------------------------------------------------------------------------------- 1 | import {IMessageBus} from './IMessageBus' 2 | import {IMessage} from './IMessage' 3 | import {Command} from './Command' 4 | import {Event} from './Event' 5 | 6 | export class MessageBus implements IMessageBus { 7 | private _eventHandlerFor = {} 8 | private _commandHandlersFor = {} 9 | 10 | registerEventHandler( 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | event: any, 13 | handler: (e: IMessage) => void 14 | ): void { 15 | if (!this._eventHandlerFor[event.name]) 16 | this._eventHandlerFor[event.name] = [] 17 | this._eventHandlerFor[event.name].push(handler) 18 | } 19 | 20 | registerCommandHandlers( 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | commands: any, 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | commandHandler: any 25 | ): void { 26 | Object.keys(commands).forEach((commandName) => { 27 | if (!commandHandler[`handle${commandName}`]) 28 | throw new Error( 29 | `Could not find handle${commandName} in ${commandHandler.constructor.name}.` 30 | ) 31 | this._commandHandlersFor[commandName] = commandHandler 32 | }) 33 | } 34 | 35 | send(command: T): void { 36 | const commandName = command.constructor.name 37 | if (!this._commandHandlersFor[commandName]) 38 | throw new Error(`No handler registered for ${commandName}`) 39 | 40 | this._commandHandlersFor[commandName][`handle${commandName}`](command) 41 | } 42 | 43 | publish(event: T): void { 44 | const eventName = event.constructor.name 45 | if (this._eventHandlerFor[eventName]) 46 | this._eventHandlerFor[eventName].forEach((handler) => handler(event)) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/framework/Repository.ts: -------------------------------------------------------------------------------- 1 | import {IEventStore} from './IEventStore' 2 | import {AggregateRoot} from './AggregateRoot' 3 | 4 | export interface IRepository { 5 | save(T, expectedVersion: number): void 6 | getById(id: string): T 7 | } 8 | 9 | export class Repository implements IRepository { 10 | constructor( 11 | private readonly _storage: IEventStore, 12 | private Type: new () => T 13 | ) {} 14 | 15 | save(T, expectedVersion: number): void { 16 | this._storage.saveEvents(T.id, T.getUncommittedChanges(), expectedVersion) 17 | T.markChangesAsCommitted() 18 | } 19 | 20 | getById(id: string): T { 21 | const domainObject = new this.Type() as T 22 | const history = this._storage.getEventsForAggregate(id) 23 | domainObject.loadFromHistory(history) 24 | return domainObject 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/projections/TodoListProjection.spec.ts: -------------------------------------------------------------------------------- 1 | import {GivenWhenThen} from '../framework/GivenWhenThen' 2 | import { 3 | TodoAddedToList, 4 | TodoListCreated, 5 | } from '../domain/todo-list/TodoListEvents' 6 | import {TodoListProjection} from './TodoListProjection' 7 | 8 | const GWT = GivenWhenThen(`${__dirname}/../domain/todo-list/TodoList`) 9 | 10 | test('Collected multiple todo lists', () => 11 | GWT((Given, When, Then, messageBus) => { 12 | const todoList = new TodoListProjection(messageBus) 13 | 14 | Given( 15 | new TodoListCreated('1234', 'foo'), 16 | new TodoListCreated('2345', 'bar'), 17 | new TodoListCreated('3456', 'baz') 18 | ) 19 | 20 | expect(todoList.todoLists['1234'].name).toEqual('foo') 21 | expect(todoList.todoLists['2345'].name).toEqual('bar') 22 | expect(todoList.todoLists['3456'].name).toEqual('baz') 23 | })) 24 | 25 | test('Todo lists contain added todo items', () => 26 | GWT((Given, When, Then, messageBus) => { 27 | const todoList = new TodoListProjection(messageBus) 28 | 29 | Given( 30 | new TodoListCreated('1234', 'foo'), 31 | new TodoAddedToList('1234', 'buy biscuits'), 32 | new TodoAddedToList('1234', 'buy peanuts') 33 | ) 34 | 35 | expect(todoList.todoLists['1234'].todos[0].name).toEqual('buy biscuits') 36 | expect(todoList.todoLists['1234'].todos[1].name).toEqual('buy peanuts') 37 | })) 38 | -------------------------------------------------------------------------------- /src/projections/TodoListProjection.ts: -------------------------------------------------------------------------------- 1 | import {IMessageBus} from '../framework/IMessageBus' 2 | import { 3 | TodoAddedToList, 4 | TodoListCreated, 5 | } from '../domain/todo-list/TodoListEvents' 6 | 7 | export class TodoListProjection { 8 | public todoLists = {} 9 | 10 | constructor(private messageBus: IMessageBus) { 11 | messageBus.registerEventHandler(TodoListCreated, (e) => { 12 | const event = e as TodoListCreated 13 | this.todoLists[event.aggregateId] = event 14 | }) 15 | messageBus.registerEventHandler(TodoAddedToList, (e) => { 16 | const event = e as TodoAddedToList 17 | if (!this.todoLists[event.aggregateId].todos) { 18 | this.todoLists[event.aggregateId].todos = [] 19 | } 20 | this.todoLists[event.aggregateId].todos.push({name: event.todoName}) 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "emitDeclarationOnly": true 6 | }, 7 | "include": [ 8 | "src/**/*" 9 | ], 10 | "exclude": [ 11 | "src/*.spec.tsx", 12 | "src/*.spec.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "sourceMap": true, 6 | "outDir": "./lib/", 7 | "esModuleInterop": true, 8 | "jsx": "react" 9 | }, 10 | "exclude": [ 11 | "./node_modules" 12 | ] 13 | } 14 | --------------------------------------------------------------------------------