├── .changeset ├── README.md └── config.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── biome.json ├── package-lock.json ├── package.json ├── specs ├── UUID │ └── UUID.spec.ts ├── aggregate │ ├── Aggregate.spec.ts │ ├── FakeUserAggregate.ts │ ├── FakeUserAggregateData.ts │ ├── FakeUserEvents.ts │ ├── LogUsernameChangedCommand.ts │ ├── LogUsernameChangedCommandHandler.ts │ ├── OtherEvent.ts │ ├── UsernameChangedEventHandler.ts │ └── UsernameUpdatedEvent.ts ├── bus │ ├── command │ │ ├── CommandHandler.spec.ts │ │ ├── FakeCommand.ts │ │ ├── FakeCommandHandler.ts │ │ └── FakeCommandHandlerWithReturnedValue.ts │ ├── commandBus │ │ ├── CommandBus.spec.ts │ │ ├── FakeInvalidCommandHandler.ts │ │ ├── FakeUpdateNameCommand.ts │ │ ├── FakeUpdateNameCommandHandler.ts │ │ ├── FakeUpdateNameWithReturnedValueCommand.ts │ │ └── FakeUpdateNameWithReturnedValueCommandHandler.ts │ ├── query │ │ ├── FakeResponse.ts │ │ ├── FakeViewCurrentNameQuery.ts │ │ ├── FakeViewCurrentNameQueryHandler.ts │ │ └── QueryHandler.spec.ts │ └── queryBus │ │ └── QueryBus.spec.ts ├── entity │ ├── Entity.spec.ts │ ├── FakeUserData.ts │ └── FakeUserEntity.ts ├── errors │ ├── AnyDomainError.ts │ ├── DomainError.spec.ts │ ├── InternalServerError.ts │ └── TechnicalError.spec.ts ├── logger │ └── FakeLogger.ts ├── result │ └── Result.spec.ts ├── useCase │ ├── FakePresenter.ts │ ├── FakePresenterPort.ts │ ├── FakeRequest.ts │ ├── FakeUseCase.ts │ ├── FakeViewModel.ts │ └── IUseCase.spec.ts └── valueObjects │ ├── Money.ts │ ├── OtherValueObject.ts │ ├── SomeInformation.ts │ └── ValueObject.spec.ts ├── src ├── aggregate │ └── Aggregate.ts ├── bus │ ├── Bus.ts │ ├── IMessageHandler.ts │ ├── Message.ts │ ├── command │ │ ├── Command.ts │ │ ├── CommandHandler.ts │ │ └── ICommandHandler.ts │ ├── middleware │ │ ├── CommandLoggerMiddleware.ts │ │ ├── Middleware.ts │ │ └── QueryLoggerMiddleware.ts │ └── query │ │ ├── IResponse.ts │ │ ├── Query.ts │ │ └── QueryHandler.ts ├── domainEvent │ ├── DomainEvent.ts │ ├── IEventHandler.ts │ ├── Metadata.ts │ └── Payload.ts ├── entity │ └── Entity.ts ├── errors │ ├── CustomError.ts │ ├── DomainError.ts │ └── TechnicalError.ts ├── eventBus │ ├── EventBus.ts │ ├── EventBusPort.ts │ ├── EventLoggingMiddleware.ts │ └── IEventMiddleware.ts ├── index.ts ├── logger │ └── Logger.ts ├── result │ └── Result.ts ├── useCase │ └── IUseCase.ts └── valueObject │ ├── ValueObject.ts │ └── uuid │ ├── UUID.ts │ ├── UUIDData.ts │ └── UUIDFactory.ts ├── tsconfig.json └── vitest.config.mts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4.1.1 12 | - uses: actions/setup-node@v4.0.2 13 | with: 14 | node-version: 20.x 15 | cache: "npm" 16 | 17 | - name: Install dependencies 18 | run: npm ci 19 | 20 | - name: Linting 21 | run: npm run lint 22 | 23 | - name: Tests 24 | run: npm run test:coverage 25 | 26 | - name: Upload results to Codecov 27 | uses: codecov/codecov-action@v4 28 | with: 29 | token: ${{ secrets.CODECOV_TOKEN }} 30 | 31 | - name: Build 32 | run: npm run build 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | .idea/ 3 | dist/ 4 | .log 5 | coverage/ 6 | .context/ 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @evyweb/simple-ddd-toolkit 2 | 3 | ## 0.21.1 4 | 5 | ### Patch Changes 6 | 7 | - entity setters are now protected (thanks to @swenhancer) 8 | 9 | ## 0.21.0 10 | 11 | ### Minor Changes 12 | 13 | - biome migration and update deps 14 | 15 | ## 0.20.2 16 | 17 | ### Patch Changes 18 | 19 | - biome migration 20 | 21 | ## 0.20.1 22 | 23 | ### Patch Changes 24 | 25 | - fix outdated docs 26 | 27 | ## 0.20.0 28 | 29 | ### Minor Changes 30 | 31 | - simplify API (breaking changes expected) 32 | 33 | ## 0.19.0 34 | 35 | ### Minor Changes 36 | 37 | - decoupling event bus from middleware 38 | - add payload for domain events 39 | 40 | ## 0.18.1 41 | 42 | ### Patch Changes 43 | 44 | - remove unnecessary async/await before dispatchEventsAsync 45 | 46 | ## 0.18.0 47 | 48 | ### Minor Changes 49 | 50 | - add dispatch events async functions for eventual consistency 51 | 52 | ## 0.17.0 53 | 54 | ### Minor Changes 55 | 56 | - update CI and badges 57 | 58 | ## 0.16.0 59 | 60 | ### Minor Changes 61 | 62 | - merge command and query bus in bus 63 | 64 | ## 0.15.1 65 | 66 | ### Patch Changes 67 | 68 | - fix command and query registration issues 69 | 70 | ## 0.15.0 71 | 72 | ### Minor Changes 73 | 74 | - simplify command and query handlers 75 | 76 | ## 0.14.1 77 | 78 | ### Patch Changes 79 | 80 | - fix event bus port 81 | 82 | ## 0.14.0 83 | 84 | ### Minor Changes 85 | 86 | - add dispatch events on event bus 87 | 88 | ## 0.13.2 89 | 90 | ### Patch Changes 91 | 92 | - add missing tags 93 | 94 | ## 0.13.1 95 | 96 | ### Patch Changes 97 | 98 | - use port for event bus 99 | 100 | ## 0.13.0 101 | 102 | ### Minor Changes 103 | 104 | - remove containers 105 | 106 | ## 0.12.1 107 | 108 | ### Patch Changes 109 | 110 | - fix returned instance on rebind 111 | 112 | ## 0.12.0 113 | 114 | ### Minor Changes 115 | 116 | - add container to manage dependencies 117 | 118 | ## 0.11.2 119 | 120 | ### Patch Changes 121 | 122 | - fix command loggin middleware 123 | 124 | ## 0.11.1 125 | 126 | ### Patch Changes 127 | 128 | - fix domain events logs display 129 | 130 | ## 0.11.0 131 | 132 | ### Minor Changes 133 | 134 | - add event handlers 135 | 136 | ## 0.10.0 137 | 138 | ### Minor Changes 139 | 140 | - simplify query and command handling 141 | 142 | ## 0.9.0 143 | 144 | ### Minor Changes 145 | 146 | - commands can now have an optional returned value 147 | 148 | ## 0.8.1 149 | 150 | ### Patch Changes 151 | 152 | - add missing type for aggregate id 153 | 154 | ## 0.8.0 155 | 156 | ### Minor Changes 157 | 158 | - simplify event creation 159 | 160 | ## 0.7.0 161 | 162 | ### Minor Changes 163 | 164 | - add tag name to errors 165 | 166 | ## 0.6.1 167 | 168 | ### Patch Changes 169 | 170 | - change imports + command query implementation 171 | 172 | ## 0.6.0 173 | 174 | ### Minor Changes 175 | 176 | - enforce value objects immutability 177 | 178 | ## 0.5.1 179 | 180 | ### Patch Changes 181 | 182 | - fix result pattern issues 183 | 184 | ## 0.5.0 185 | 186 | ### Minor Changes 187 | 188 | - fix minor issues 189 | 190 | ## 0.4.2 191 | 192 | ### Patch Changes 193 | 194 | - fix result pattern types 195 | 196 | ## 0.4.1 197 | 198 | ### Patch Changes 199 | 200 | - fix package content 201 | 202 | ## 0.4.0 203 | 204 | ### Minor Changes 205 | 206 | - update publish scripts and registry 207 | 208 | ## 0.3.0 209 | 210 | ### Minor Changes 211 | 212 | - update imports and some boyscout rules 213 | 214 | ## 0.2.0 215 | 216 | ### Minor Changes 217 | 218 | - aa61380: chore(tsconfig): update rules 219 | 220 | ## 0.1.0 221 | 222 | ### Minor Changes 223 | 224 | - 7d3e73d: chore: add base ci cd setup 225 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Evyweb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple DDD Toolkit 🛠️ 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/%40evyweb%2Fsimple-ddd-toolkit.svg?style=flat)]() 4 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/evyweb/simple-ddd-toolkit/main.yml) 5 | [![codecov](https://codecov.io/gh/Evyweb/simple-ddd-toolkit/graph/badge.svg?token=A3Z8UCNHDY)](https://codecov.io/gh/Evyweb/simple-ddd-toolkit) 6 | 7 | ![NPM Downloads](https://img.shields.io/npm/dm/%40evyweb%2Fsimple-ddd-toolkit) 8 | [![NPM Downloads](https://img.shields.io/npm/dt/%40evyweb%2Fsimple-ddd-toolkit.svg?style=flat)]() 9 | 10 | A simple Domain Driven Design Toolkit created to help developers understand how to implement DDD concepts. It also contains some useful stuff not directly related to DDD, like a command bus or result pattern. 11 | 12 | This toolkit is not a library, it's just a set of classes and interfaces that you can use, and it has no dependency. 13 | 14 | 🚧🦺 15 | **WARNING: This toolkit is still under development and not intended to be used in production. All classes and interfaces are subject to change and may provoke breaking changes**. 16 | 🦺🚧 17 | ## Installation 18 | 19 | ```bash 20 | npm install --save @evyweb/simple-ddd-toolkit 21 | ``` 22 | 23 | ## Features 24 | 25 | - [x] Aggregate - A cluster of domain objects that can be treated as a single unit 26 | - [x] Command - A request to perform an action or change the state of the system 27 | - [x] Command handler - Processes commands and updates the system's state 28 | - [x] Command bus - Routes commands to the appropriate command handlers 29 | - [x] Domain events - Notifications about changes in the domain 30 | - [x] Entity - An object with a distinct identity that runs throughout its lifecycle 31 | - [x] Errors - Custom error types for domain and technical errors 32 | - [x] Event bus - Decouples components by allowing them to communicate through events 33 | - [x] Middleware - Adds additional behavior to commands and queries 34 | - [x] Query - A request for data from the system 35 | - [x] Query handler - Processes queries and returns the requested information 36 | - [x] Query bus - Routes queries to the appropriate query handlers 37 | - [x] Result - A pattern to handle errors and success cases in a more explicit way 38 | - [x] Use case - Encapsulates application-specific business rules 39 | - [x] Value object - An object that measures, quantifies, or describes aspects of a domain 40 | 41 | # Value Object 42 | 43 | ## What is a value object? 44 | 45 | In the book "**Implementing Domain-Driven Design**" by **Vaughn Vernon**, a `Value Object` is described as an object that measures, quantifies or describes certain aspects of a domain without having a conceptual identity. 46 | 47 | The key characteristics of a value object include: 48 | 49 | - **Immutability**: Once created, it cannot be altered. Any change to the properties of a value object should result in the creation of a new object. 50 | - **Equality Based on Attributes**: Two instances of value objects are considered equal not based on their identity, but if all their properties have the same values. 51 | - **Replaceability**: They can be replaced by other instances with the same values without disrupting the integrity of the domain. 52 | - **Lack of Identity**: Value objects do not have a distinct identity that distinguishes them. 53 | - **Design by Contract**: They can validate conditions that must be true throughout the lifetime of the object (e.g., an email address must contain an "@" symbol). 54 | 55 | ## Some examples of value objects 56 | 57 | - `Address` 58 | - `Email` 59 | - `PhoneNumber` 60 | - `DateRange` 61 | - `Color` 62 | - `Weight` 63 | - `Height` 64 | - `Temperature` 65 | - `Money` 66 | - etc... 67 | 68 | ## How is a value object different from an entity? 69 | 70 | An `Entity` is an object that has a distinct identity that runs throughout its lifecycle. It is defined by its attributes and its identity. An entity can be mutable, and its identity is not based on its attributes. 71 | 72 | A `Value Object`, on the other hand, is defined by its attributes and not by its identity. It is immutable and can be replaced by another instance with the same values without disrupting the integrity of the domain. 73 | 74 | ## Let's consider an example 75 | 76 | We can say that a `Color` is defined by its `red`, `green`, and `blue` values which are numbers. 77 | 78 | ### Immutability 79 | 80 | Once a `Color` object is created, it cannot be altered. Any change to the properties of a `Color` object should result in the creation of a new color. 81 | 82 | ### Equality Based on Attributes 83 | 84 | That's the combination of the `red`, `green`, and `blue` values that define a `color`. 85 | 86 | Two colors are considered equal if they have the same amount of `red`, `green`, and `blue`. 87 | 88 | ### Replaceability 89 | 90 | A `Color` object can be replaced by another instance with the same values. 91 | 92 | ### Lack of Identity 93 | 94 | A `Color` object does not have a distinct identity that distinguishes it. 95 | 96 | Note that it can depend on the context. For example, in a graphic design application, a color may have an identity if it is used to represent a specific color in a palette. 97 | But let's consider the `Color` object as a value object for this example. 98 | 99 | ### Design by Contract 100 | 101 | A `Color` object can validate conditions that must be true throughout its lifetime. For example, the `red`, `green`, and `blue` values must be between 0 and 255. 102 | 103 | ## How to implement this value object 104 | 105 | We can create the color value object by extending the `ValueObject` class provided by the `simple-ddd-toolkit` package. 106 | 107 | ```typescript 108 | import { ValueObject } from "@evyweb/simple-ddd-toolkit"; 109 | 110 | export class Color extends ValueObject<{ red: number; green: number; blue: number }> {} 111 | ``` 112 | 113 | You can also create a type or an interface to define the `red`, `green`, and `blue` values. 114 | 115 | ```typescript 116 | interface RGBColor { 117 | red: number; 118 | green: number; 119 | blue: number; 120 | } 121 | 122 | export class Color extends ValueObject {} 123 | ``` 124 | 125 | By default, you will not be able to create an instance of the `Color` class because its constructor is protected. 126 | 127 | ```typescript 128 | const color = new Color({ red: 255, green: 0, blue: 0 }); // Error 129 | ``` 130 | 131 | To create a new instance of the `Color` class, you need to create a **static factory method** that will validate the `red`, `green`, and `blue` values before creating the instance. 132 | 133 | A possible implementation to do that can be: 134 | 135 | ```typescript 136 | import { ValueObject } from "@evyweb/simple-ddd-toolkit"; 137 | 138 | interface RGBColor { 139 | red: number; 140 | green: number; 141 | blue: number; 142 | } 143 | 144 | export class Color extends ValueObject { 145 | static create({ red, green, blue }: RGBColor): Color { 146 | // Validate the red, green, and blue values here 147 | Color.validateRGBColorFormat(red); 148 | Color.validateRGBColorFormat(green); 149 | Color.validateRGBColorFormat(blue); 150 | 151 | return new Color({ red, green, blue }); 152 | } 153 | 154 | private static validateRGBColorFormat(value: number): void { 155 | if (value < 0 || value > 255) { 156 | throw new Error("RGB color value must be between 0 and 255."); 157 | } 158 | } 159 | } 160 | ``` 161 | 162 | Now you can create a new instance of the `Color` class using the `create` method. 163 | 164 | ```typescript 165 | const color = Color.create({ red: 255, green: 0, blue: 0 }); 166 | ``` 167 | 168 | By using a factory method, you can ensure that the `Color` object is created with valid values. 169 | 170 | You can also easily create different static factory methods to create colors based on different criteria. 171 | 172 | ```typescript 173 | const color1 = Color.fromRGB({ red: 255, green: 0, blue: 0 }); 174 | const color2 = Color.fromHEX("#FF0000"); 175 | ``` 176 | 177 | Note that the `fromRGB` and `fromHEX` methods are just static method names, you can choose any name that makes sense for you. 178 | The important thing is that they are static factory methods that create a `Color` object and validate the input values before creating the object. 179 | 180 | The word 'from' is a common convention to indicate that the method creates an object from a specific format. 181 | 182 | Now that the value object is created, you can use the `equals` method provided by the `ValueObject` class, you can compare two `Color` objects. 183 | 184 | ```typescript 185 | const color1 = Color.fromRGB({ red: 255, green: 0, blue: 0 }); 186 | const color2 = Color.fromHEX("#FF0000"); 187 | 188 | console.log(color1.equals(color2)); // true 189 | ``` 190 | 191 | Here is the full implementation of the `Color` class: 192 | 193 | ```typescript 194 | import { ValueObject } from "@evyweb/simple-ddd-toolkit"; 195 | 196 | interface RGBColor { 197 | red: number; 198 | green: number; 199 | blue: number; 200 | } 201 | 202 | export class Color extends ValueObject { 203 | static fromRGB({ red, green, blue }: RGBColor): Color { 204 | Color.validateRGBColorFormat(red); 205 | Color.validateRGBColorFormat(green); 206 | Color.validateRGBColorFormat(blue); 207 | 208 | return new Color({ red, green, blue }); 209 | } 210 | 211 | static fromHEX(hexValue: string): Color { 212 | Color.validateHexColorFormat(hexValue); 213 | 214 | return Color.fromRGB({ 215 | red: parseInt(hexValue.substring(1, 3), 16), 216 | green: parseInt(hexValue.substring(3, 5), 16), 217 | blue: parseInt(hexValue.substring(5, 7), 16), 218 | }); 219 | } 220 | 221 | private static validateRGBColorFormat(value: number): void { 222 | if (value < 0 || value > 255) { 223 | throw new Error("RGB color value must be between 0 and 255."); 224 | } 225 | } 226 | 227 | private static validateHexColorFormat(hex: string) { 228 | if (!/^#[0-9A-F]{6}$/i.test(hex)) { 229 | throw new Error("Invalid HEX color format."); 230 | } 231 | } 232 | } 233 | ``` 234 | 235 | When the color object is created, it is automatically immutable. You will not be able to change the `red`, `green`, and `blue` values of the color object. 236 | 237 | ## Get the value(s) of the value object 238 | 239 | You can retrieve the `red`, `green`, and `blue` values of the color object using the `get` method provided by the `ValueObject` class. 240 | 241 | ```typescript 242 | const color = Color.fromRGB({ red: 255, green: 255, blue: 255 }); 243 | 244 | color.get("red"); // 255 245 | color.get("green"); // 255 246 | color.get("blue"); // 255 247 | ``` 248 | 249 | You will get autocomplete suggestions for the `get` method based on the properties of the `Color` class. 250 | 251 | ## Update the value(s) of the value object 252 | 253 | As mentioned earlier, a value object is immutable. You cannot change the `red`, `green`, and `blue` values of the color object directly. 254 | You will need to create a new color object with the updated values. 255 | 256 | ```typescript 257 | const color = Color.fromRGB({ red: 255, green: 255, blue: 255 }); 258 | const newColor = color.removeRed(); 259 | 260 | class Color extends ValueObject { 261 | // ... 262 | removeRed(): Color { 263 | return Color.fromRGB({ 264 | red: 0, 265 | green: this.get("green"), 266 | blue: this.get("blue"), 267 | }); 268 | } 269 | } 270 | ``` 271 | 272 | In this example, the `removeRed` method creates a new color object with the `red` value set to 0 and the `green` and `blue` values copied from the original color object. 273 | 274 | ## Using directly the constructor (not recommended) 275 | 276 | If you want to use a constructor instead of a static factory method, you can simply make the constructor public. 277 | 278 | ```typescript 279 | export class Color extends ValueObject { 280 | constructor({ red, green, blue }: RGBColor) { 281 | // Validate the red, green, and blue values here 282 | super({ red, green, blue }); 283 | } 284 | 285 | // Other methods 286 | } 287 | ``` 288 | 289 | Now you can create a new instance of the `Color` class using the constructor directly. 290 | 291 | ```typescript 292 | const color = new Color({ red: 255, green: 0, blue: 0 }); 293 | ``` 294 | 295 | However, it is highly recommended to use static factory methods instead of constructors to create value objects. This way, you can ensure that the value object is always created with valid values. 296 | 297 | ## Nested values (not recommended) 298 | 299 | It is recommended to avoid nested values in the value object. 300 | Try to keep the value object as flat as possible and simple to use. 301 | If you need to store complex data, consider creating a separate value object for that data. 302 | 303 | # Result Pattern 304 | 305 | The `Result` pattern is a way to handle errors and success cases in a more explicit way. 306 | 307 | It is a simple pattern that consists of two possible outcomes: `Ok` and `Fail`. 308 | 309 | The `Ok` outcome represents a successful operation and contains the result of the operation. 310 | 311 | The `Fail` outcome represents a failed operation and contains an error object that describes the reason for the failure. 312 | 313 | ## How to use the Result pattern 314 | 315 | The `Result` class provided by the `simple-ddd-toolkit` package can be used to create `Ok` and `Fail` outcomes. 316 | 317 | ```typescript 318 | import { Result } from "@evyweb/simple-ddd-toolkit"; 319 | 320 | const successResult = Result.ok("Operation successful"); 321 | const errorResult = Result.fail(new Error("Operation failed")); 322 | ``` 323 | 324 | You can check if the result is successful using the `isOk` method. 325 | 326 | ```typescript 327 | if (currentResult.isOk()) { 328 | console.log(currentResult.getValue()); 329 | } 330 | ``` 331 | 332 | You can check if the result is a failure using the `isFail` method. 333 | 334 | ```typescript 335 | if (currentResult.isFail()) { 336 | console.log(currentResult.getError()); 337 | } 338 | ``` 339 | 340 | Note that you cannot have both a value and an error in the same result object. It is either an `Ok` outcome with a value or a `Fail` outcome with an error. 341 | 342 | ## Combine factory methods with result pattern 343 | 344 | Note that you can `combine the value object factory method with the result pattern` to return a result object that contains the created value object or an error. 345 | It can be useful to handle validation errors when creating the value object. 346 | 347 | ```typescript 348 | interface RGBColor { 349 | red: number; 350 | green: number; 351 | blue: number; 352 | } 353 | 354 | class InvalidRGBColorError extends Error { 355 | constructor() { 356 | super("Invalid RGB color format."); 357 | } 358 | } 359 | 360 | export class Color extends ValueObject { 361 | static fromRGB(rgbColor: RGBColor): Result { 362 | if (Color.isInvalidRGBColor(rgbColor)) { 363 | return Result.fail(new InvalidRGBColorError()); 364 | } 365 | 366 | return Result.ok(new Color(rgbColor)); 367 | } 368 | 369 | private static isInvalidRGBColor(rgbColor: RGBColor): boolean { 370 | return rgbColor.red < 0 || rgbColor.red > 255 || 371 | rgbColor.green < 0 || rgbColor.green > 255 || 372 | rgbColor.blue < 0 || rgbColor.blue > 255; 373 | } 374 | } 375 | 376 | const colorCreation = Color.fromRGB({ red: 255, green: 255, blue: 255 }); 377 | if (colorCreation.isOk()) { 378 | // Do something with the color 379 | console.log(colorCreation.getValue()) 380 | } else { 381 | // Handle the error 382 | console.error(colorCreation.getError()) 383 | } 384 | ``` 385 | 386 | Note the return type of the `fromRGB` method: `Result`. 387 | 388 | That means that the `fromRGB` method can return an `Ok` outcome with a `Color` object or a `Fail` outcome with an `InvalidRGBColorError` object. 389 | 390 | # Errors 391 | 392 | In the `simple-ddd-toolkit`, errors are represented as classes that extend the `Error` class. 393 | 394 | To help you create more explicit errors, we gave you 3 classes `TechnicalError`, `DomainError` and `CustomError` that you can extend to create your custom errors. 395 | Note: `TechnicalError` and `DomainError` extend the `CustomError` class. 396 | 397 | This way, you can create different types of errors based on the context in which they occur and react differently if the error is a technical error or a domain error. 398 | 399 | ## How to create a domain error 400 | 401 | To create a domain error, you can extend the `DomainError` class provided by the `simple-ddd-toolkit` package. 402 | 403 | ```typescript 404 | import { DomainError } from "@evyweb/simple-ddd-toolkit"; 405 | 406 | export class AnyDomainError extends DomainError { 407 | public readonly __TAG = "AnyDomainError"; 408 | 409 | constructor() { 410 | super("Any domain related error message"); // Can be also a translation key 411 | } 412 | } 413 | ``` 414 | 415 | When you will create the error object, you will have access to two helpers methods `isDomainError` and `isTechnicalError` to check the type of the error more easily. 416 | 417 | ```typescript 418 | const error = new AnyDomainError(); 419 | 420 | if (error.isDomainError()) { 421 | // Handle domain error 422 | } else if (error.isTechnicalError()) { 423 | // Handle technical error 424 | } 425 | ``` 426 | 427 | These are helper methods that allow you to quickly determine if an error is a domain error or a technical error. 428 | 429 | ## How to create a technical error 430 | 431 | To create a technical error, you can extend the `TechnicalError` class provided by the `simple-ddd-toolkit` package. 432 | 433 | ```typescript 434 | import { TechnicalError } from "@evyweb/simple-ddd-toolkit"; 435 | 436 | export class AnyTechnicalError extends TechnicalError { 437 | public readonly __TAG = "AnyTechnicalError"; 438 | 439 | constructor() { 440 | super("Any technical related error message"); // Can be also a translation key 441 | } 442 | } 443 | ``` 444 | 445 | When you will create the error object, you will have access to two helpers methods `isDomainError` and `isTechnicalError` to check the type of the error more easily. 446 | 447 | ```typescript 448 | const error = new AnyTechnicalError(); 449 | 450 | if (error.isDomainError()) { 451 | // Handle domain error 452 | } else if (error.isTechnicalError()) { 453 | // Handle technical error 454 | } 455 | ``` 456 | 457 | ## How to create a custom error 458 | 459 | The `CustomError` class provided by the `simple-ddd-toolkit` package can be used to create custom errors. 460 | 461 | ```typescript 462 | import { CustomError } from "@evyweb/simple-ddd-toolkit"; 463 | 464 | export class AnyCustomError extends CustomError { 465 | public readonly __TAG = "AnyCustomError"; 466 | 467 | constructor() { 468 | super("Any custom error message"); // Can be also a translation key 469 | } 470 | 471 | isDomainError(): boolean { 472 | return false; 473 | } 474 | 475 | isTechnicalError(): boolean { 476 | return true; 477 | } 478 | } 479 | ``` 480 | 481 | When you define the custom error class, you will need to: 482 | 1. Define a `__TAG` property to identify the error type 483 | 2. Override the `isDomainError` and `isTechnicalError` methods to specify the type of error 484 | 485 | # Entity 486 | 487 | An `Entity` is an object encapsulating domain logic and data, and it has a distinct identity that runs throughout its lifecycle. 488 | 489 | "We design a domain concept as an `Entity` when we care about its individuality, when distinguishing it from all other objects in a system is a mandatory constraint. 490 | An entity is a unique thing and is capable of being changed continuously over a long period of time" - Vaughn Vernon 491 | 492 | ### Difference between `Entity` and `Value Object` 493 | 494 | It is the unique identity and mutability that distinguishes an `Entity` from a `Value Object`. 495 | 496 | ### Identity 497 | 498 | Value objects can serve as holders of unique identity. They are immutable, which ensures identity stability, and any behavior specific to the kind of identity is centralized. 499 | 500 | ## Some examples of entities 501 | 502 | - `User` 503 | - `Order` 504 | - `Product` 505 | - `Customer` 506 | - `Account` 507 | - `Invoice` 508 | 509 | ## How to implement an entity 510 | 511 | To create an entity, you can extend the `Entity` class provided by the `simple-ddd-toolkit` package. 512 | 513 | ```typescript 514 | import { Entity } from "@evyweb/simple-ddd-toolkit"; 515 | import { UUID } from "@evyweb/simple-ddd-toolkit"; 516 | 517 | interface UserData { 518 | id: UUID; 519 | name: string; 520 | } 521 | 522 | export class User extends Entity { 523 | static create(userData: UserData): User { 524 | // Validation rules here 525 | return new User(userData); 526 | } 527 | } 528 | ``` 529 | 530 | Here `UUID` is a type that represents a universally unique identifier. It is exposed by the `simple-ddd-toolkit` package as a ready to use Value Object. 531 | 532 | Just like the `ValueObject` class, the `Entity` class has a protected constructor, which means you cannot create an instance of the `User` class directly. 533 | 534 | ```typescript 535 | const user = new User({ id: UUID.create(), name: "John Doe" }); // Error 536 | ``` 537 | 538 | To create a new instance of the `User` class, you need to use a static factory method. 539 | 540 | ```typescript 541 | const user = User.create({ id: UUID.create(), name: "John Doe" }); 542 | ``` 543 | 544 | This way, you can ensure that the entity is created with valid data. 545 | 546 | Similarly to the `ValueObject` class, the `Entity` factory functions can be combined with the `Result` pattern to handle validation errors. 547 | 548 | ```typescript 549 | import { Result, DomainError } from "@evyweb/simple-ddd-toolkit"; 550 | import { UUID } from "@evyweb/simple-ddd-toolkit"; 551 | 552 | interface UserData { 553 | id: UUID; 554 | name: string; 555 | } 556 | 557 | class InvalidUserNameError extends DomainError { 558 | constructor() { 559 | super("Username cannot contain special characters."); 560 | } 561 | } 562 | 563 | export class User extends Entity { 564 | static create(userData: UserData): Result { 565 | if (User.isInvalidUserName(userData.name)) { 566 | return Result.fail(new InvalidUserNameError()); 567 | } 568 | 569 | return Result.ok(new User(userData)); 570 | } 571 | 572 | private static isInvalidUserName(name: string): boolean { 573 | return /[^a-zA-Z0-9]/.test(name); 574 | } 575 | } 576 | ``` 577 | 578 | In this example, the `create` method returns a `Result` object that contains either a `User` entity or an `InvalidUserNameError` error. 579 | 580 | ## Get the properties of an entity 581 | 582 | You can retrieve the `id` and `name` values of the user entity using the `get` method provided by the `Entity` class. 583 | 584 | ```typescript 585 | const user = User.create({ id: UUID.create(), name: "John Doe" }); 586 | 587 | user.get("id"); // UUID 588 | user.get("name"); // 'John Doe' 589 | ``` 590 | 591 | Note that the `id` returned by the `get` method is a `UUID` value object. 592 | To get the actual value of the `UUID` object, you can use the `get('value')` method provided by the `UUID` class. 593 | 594 | ```typescript 595 | const userId = user.get("id").get("value"); 596 | ``` 597 | 598 | You can also use the shortcut method `id()` to get the `id` value directly. 599 | 600 | ```typescript 601 | const userId = user.id(); // Similar to user.get('id').get('value') 602 | ``` 603 | 604 | ## Update entity properties 605 | 606 | An entity is mutable, which means you can change its properties. 607 | Note that the setter method is protected, so you can only update the entity properties using the methods provided by the entity class. 608 | 609 | ```typescript 610 | class User extends Entity { 611 | // ... 612 | renameTo(newName: string): void { 613 | this.set("name", newName); 614 | } 615 | // ... 616 | } 617 | 618 | const user = User.create({ id: UUID.create(), name: "John Doe" }); 619 | 620 | user.renameTo("Jane Doe"); 621 | ``` 622 | 623 | In this example, the `name` property of the user entity is updated to 'Jane Doe'. 624 | 625 | ## Identity comparison 626 | 627 | Entities are compared based on their identity, not their attributes. 628 | 629 | ```typescript 630 | const userId = UUID.create(); 631 | const user1 = User.create({ id: userId, name: "John Doe" }); 632 | const user2 = User.create({ id: userId, name: "Jane Doe" }); 633 | 634 | console.log(user1.equals(user2)); // true 635 | ``` 636 | 637 | In this example, even though the `name` properties of the two user entities are different, they are considered as the same because they have the same identities. 638 | 639 | ## toObject() helper method 640 | 641 | You can convert an entity to a plain JavaScript object using the `toObject` method provided by the `Entity` class. 642 | 643 | ```typescript 644 | const user = User.create({ id: UUID.create(), name: "John Doe" }); 645 | 646 | user.toObject(); // { id: '...', name: 'John Doe' } 647 | ``` 648 | 649 | The `toObject` method returns an object with the properties of the entity. 650 | 651 | # Aggregate 652 | 653 | An `Aggregate` is a cluster of domain objects that can be treated as a single unit. 654 | 655 | It is an important concept in Domain-Driven Design (DDD) that helps to maintain consistency and integrity in the domain model. 656 | 657 | An aggregate has the following characteristics: 658 | 659 | - **Root Entity**: An aggregate has a root entity that acts as the entry point to the aggregate. The root entity is responsible for maintaining the consistency of the aggregate. 660 | - **Boundary**: An aggregate defines a boundary within which all domain objects are consistent with each other. The root entity enforces the consistency of the aggregate by controlling access to its internal objects. 661 | - **Transaction**: An aggregate is treated as a single unit in a transaction. All changes to the aggregate are made atomically, ensuring that the aggregate remains in a consistent state. 662 | - **Identity**: An aggregate has a unique identity that distinguishes it from other aggregates in the system. 663 | - **Encapsulation**: An aggregate encapsulates its internal objects and exposes only the root entity to the outside world. 664 | - **Invariants**: An aggregate enforces invariants that must be true for the aggregate to be in a valid state. 665 | 666 | ## How to implement an aggregate 667 | 668 | To create an aggregate, you can extend the `Aggregate` class provided by the `simple-ddd-toolkit` package. 669 | 670 | ```typescript 671 | import { Aggregate, UUID } from "@evyweb/simple-ddd-toolkit"; 672 | 673 | interface OrderItem { 674 | productId: string; 675 | quantity: number; 676 | } 677 | 678 | interface OrderData { 679 | id: UUID; 680 | items: OrderItem[]; 681 | date: Date; 682 | } 683 | 684 | export class Order extends Aggregate { 685 | static create(orderData: OrderData): Order { 686 | // Validation rules here 687 | return new Order(orderData); 688 | } 689 | 690 | addItem(productId: string, quantity: number): void { 691 | if (this.get("items").length >= 10) { 692 | throw new Error("An order cannot contain more than 10 items."); 693 | } 694 | const item = {productId, quantity}; 695 | this.get("items").push(item); 696 | } 697 | } 698 | 699 | const order = await orderRepository.getById("order-id"); 700 | order.addItem("product1", 2); 701 | orderRepository.save(order); 702 | ``` 703 | 704 | In this example, the `Order` class extends the `Aggregate` class and defines a `create` method to create a new order. 705 | 706 | The `addItem` method adds a new item to the order. It checks if the order already contains 10 items and throws an error if the limit is reached. 707 | 708 | The `Order` class can be used to create and manage orders in the domain model. 709 | 710 | The `Order` is then saved to the repository using the `save` method provided by the repository. 711 | 712 | ## Domain events 713 | 714 | An aggregate can emit domain events to notify other parts of the system about changes in its state. 715 | 716 | ### How to create a domain event 717 | 718 | To create a domain event, you can extend the `DomainEvent` class provided by the `simple-ddd-toolkit` package. 719 | 720 | ```typescript 721 | import { DomainEvent } from "@evyweb/simple-ddd-toolkit"; 722 | import { v4 as uuidv4 } from 'uuid'; 723 | 724 | export class ProductAddedToOrderEvent extends DomainEvent { 725 | public readonly __TAG = "ProductAddedToOrderEvent"; 726 | 727 | constructor( 728 | public readonly orderId: string, 729 | public readonly productId: string, 730 | public readonly quantity: number 731 | ) { 732 | super({ 733 | eventId: uuidv4(), 734 | metadata: { 735 | orderId, 736 | productId, 737 | quantity: quantity.toString() 738 | } 739 | }); 740 | } 741 | } 742 | ``` 743 | 744 | In this example, the `ProductAddedToOrderEvent` class extends the `DomainEvent` class and defines the metadata for the event. 745 | 746 | When creating a `DomainEvent`, the following data are available and can be provided in the constructor: 747 | 748 | - **eventId**: A unique identifier for the event. Required when creating a domain event. 749 | - **__TAG**: The type of the event, must be defined as a property in the class. 750 | - **occurredOn**: The date and time when the event occurred. Generated automatically if not provided. 751 | - **metadata**: Additional data related to the event. Empty by default. 752 | - **payload**: The data related to the event. Empty by default 753 | 754 | ### Emitting domain events 755 | 756 | To emit domain events from an aggregate, you need to add the events to the queue first. 757 | To do so, you can use the `addEvent` method provided by the `Aggregate` class. 758 | 759 | ```typescript 760 | const order = await orderRepository.getById("order-id"); 761 | order.addItem("product1", 2); 762 | 763 | order.addEvent(new ProductAddedToOrderEvent(order.id(), "product1", 2)); 764 | 765 | orderRepository.save(order); 766 | 767 | eventBus.dispatchEvents(order.getEvents()); 768 | ``` 769 | 770 | Then you need to dispatch the events to the event bus using the `dispatchEvents` method provided by the event bus. 771 | You need to inject the event bus into the commandHandler to be able to use it. 772 | 773 | ```typescript 774 | import { CommandHandler, EventBus, Command, DomainEvent } from "@evyweb/simple-ddd-toolkit"; 775 | 776 | export class AddProductToOrderCommandHandler extends CommandHandler< 777 | AddProductToOrderCommand, 778 | void 779 | > { 780 | constructor( 781 | private readonly orderRepository: OrderRepository, 782 | private readonly eventBus: EventBus 783 | ) { 784 | super(); 785 | } 786 | 787 | async handle(command: AddProductToOrderCommand): Promise { 788 | const order = await this.orderRepository.getById(command.orderId); 789 | order.addItem(command.productId, command.quantity); 790 | 791 | order.addEvent( 792 | new ProductAddedToOrderEvent(order.id(), command.productId, command.quantity) 793 | ); 794 | 795 | this.orderRepository.save(order); 796 | this.eventBus.dispatchEvents(order.getEvents()); 797 | } 798 | } 799 | ``` 800 | 801 | In this example, the `AddProductToOrderCommandHandler` class injects the event bus and dispatches the events after saving the order. 802 | 803 | The event bus is responsible for dispatching the events to the appropriate event handlers. 804 | 805 | # Commands 806 | 807 | A `Command` is a request to perform an action or change the state of the system. 808 | 809 | It encapsulates the data required to perform the action and is sent to a `Command Handler` to execute the action. 810 | 811 | The `Command Handler` is responsible for processing the command and updating the system's state accordingly. 812 | 813 | ## How to create a command 814 | 815 | To create a command, you can extend the `Command` class provided by the `simple-ddd-toolkit` package. 816 | 817 | ```typescript 818 | import { Command } from "@evyweb/simple-ddd-toolkit"; 819 | 820 | export class CreateCharacterCommand extends Command { 821 | public readonly __TAG = "CreateCharacterCommand"; 822 | public readonly name: string; 823 | 824 | constructor(name: string) { 825 | super(); 826 | this.name = name; 827 | } 828 | } 829 | ``` 830 | It is important to define the `__TAG` property for each command. This tag is used to identify the command and to register the corresponding command handler. 831 | 832 | ### How to create a command handler 833 | 834 | To create a command handler, you can extend the `CommandHandler` class provided by the `simple-ddd-toolkit` package. 835 | 836 | ```typescript 837 | import { CommandHandler } from "@evyweb/simple-ddd-toolkit"; 838 | 839 | export class CreateCharacterCommandHandler extends CommandHandler< 840 | CreateCharacterCommand, 841 | void 842 | > { 843 | public readonly __TAG = "CreateCharacterCommandHandler"; 844 | 845 | async handle(command: CreateCharacterCommand): Promise { 846 | // Process the command here 847 | } 848 | } 849 | ``` 850 | 851 | It is important to define the `__TAG` property for each command handler. This tag is used to identify the command handler and must match the pattern `{CommandName}Handler` where `{CommandName}` is the `__TAG` property of the command. 852 | 853 | Most of the time, a command handler will not return anything, so the second type parameter of the `CommandHandler` class is `void`. 854 | But it can return a value if needed (e.g., the id of the created element). 855 | 856 | ### How to dispatch a command 857 | 858 | Commands are dispatched to the appropriate command handler using a `Command Bus`. 859 | 860 | To dispatch a command, you can use the `execute` method provided by the command bus. 861 | 862 | ```typescript 863 | const command = new CreateCharacterCommand(name); 864 | await commandBus.execute(command); 865 | ``` 866 | 867 | The command bus is responsible for routing the command to the correct command handler and executing the handler. 868 | 869 | ### Registering command handlers 870 | 871 | To register a command handler with the command bus, you can use the `register` method provided by the command bus. 872 | 873 | ```typescript 874 | import { Bus, Command } from "@evyweb/simple-ddd-toolkit"; 875 | 876 | const commandBus = new Bus(); 877 | commandBus.register(() => new CreateCharacterCommandHandler()); 878 | ``` 879 | 880 | Make sure to use the `__TAG` property of the command as the key when registering the command handler. For example, if the command's `__TAG` is "CreateCharacterCommand", then the key should be "CreateCharacterCommand". 881 | 882 | You can also use an ioc container (like inversify or simple-ddd-toolkit) to resolve the command handler. 883 | 884 | ```typescript 885 | commandBus.register(() => container.get(DI.CreateCharacterCommandHandler)); 886 | ``` 887 | 888 | # Query 889 | 890 | A `Query` is a request for data from the system. 891 | 892 | It encapsulates the data required to retrieve information and is sent to a `Query Handler` to fetch the data. 893 | 894 | The `Query Handler` is responsible for processing the query and returning the requested information. 895 | 896 | ## How to create a query 897 | 898 | To create a query, you can extend the `Query` class provided by the `simple-ddd-toolkit` package. 899 | 900 | ```typescript 901 | import { Query } from "@evyweb/simple-ddd-toolkit"; 902 | 903 | export class LoadCharacterCreationDialogQuery extends Query { 904 | public readonly __TAG = "LoadCharacterCreationDialogQuery"; 905 | } 906 | ``` 907 | 908 | It is important to define the `__TAG` property for each query. This tag is used to identify the query and to register the corresponding query handler. 909 | 910 | ### How to create a query handler 911 | 912 | To create a query handler, you can extend the `QueryHandler` class provided by the `simple-ddd-toolkit` package. 913 | 914 | ```typescript 915 | import { QueryHandler, IResponse } from "@evyweb/simple-ddd-toolkit"; 916 | 917 | export class LoadCharacterCreationDialogQueryHandler extends QueryHandler< 918 | LoadCharacterCreationDialogQuery, 919 | LoadCharacterCreationDialogResponse 920 | > { 921 | public readonly __TAG = "LoadCharacterCreationDialogQueryHandler"; 922 | 923 | async handle( 924 | _query: LoadCharacterCreationDialogQuery 925 | ): Promise { 926 | // Data can be fetched from a database, an API, or any other source 927 | return { 928 | title: "Add a new character", 929 | subTitle: "Fill out the form to create a new character.", 930 | form: { 931 | avatar: { 932 | label: "Avatar", 933 | required: false, 934 | value: "/images/avatars/default.png", 935 | }, 936 | name: { 937 | label: "Name *", 938 | placeholder: "Character name", 939 | required: true, 940 | value: "", 941 | }, 942 | submit: { 943 | label: "Validate", 944 | }, 945 | cancel: { 946 | label: "Cancel", 947 | }, 948 | }, 949 | }; 950 | } 951 | } 952 | ``` 953 | 954 | It is important to define the `__TAG` property for each query handler. This tag is used to identify the query handler and must match the pattern `{QueryName}Handler` where `{QueryName}` is the `__TAG` property of the query. 955 | 956 | The `LoadCharacterCreationDialogQueryHandler` class extends the `QueryHandler` class and defines the response type as `LoadCharacterCreationDialogResponse`. 957 | The response is also known as a ViewModel. 958 | 959 | ```typescript 960 | interface CharacterCreationFormViewModel { 961 | avatar: { 962 | label: string; 963 | required: boolean; 964 | value: string; 965 | }; 966 | name: { 967 | label: string; 968 | placeholder: string; 969 | required: boolean; 970 | value: string; 971 | }; 972 | submit: { 973 | label: string; 974 | }; 975 | cancel: { 976 | label: string; 977 | }; 978 | } 979 | 980 | export interface LoadCharacterCreationDialogResponse extends IResponse { 981 | title: string; 982 | subTitle: string; 983 | form: CharacterCreationFormViewModel; 984 | } 985 | ``` 986 | 987 | ### How to dispatch a query 988 | 989 | Queries are dispatched to the appropriate query handler using a `Query Bus`. 990 | 991 | To dispatch a query, you can use the `execute` method provided by the query bus. 992 | 993 | ```typescript 994 | const query = new LoadCharacterCreationDialogQuery(); 995 | const response = await queryBus.execute(query); 996 | ``` 997 | 998 | The query bus is responsible for routing the query to the correct query handler and executing the handler. 999 | 1000 | ### Registering query handlers 1001 | 1002 | To register a query handler with the query bus, you can use the `register` method provided by the query bus. 1003 | 1004 | ```typescript 1005 | import { Bus, Query } from "@evyweb/simple-ddd-toolkit"; 1006 | 1007 | const queryBus = new Bus(); 1008 | queryBus.register(() => new LoadCharacterCreationDialogQueryHandler()); 1009 | ``` 1010 | 1011 | **Make sure to use the `__TAG` property of the query as the key when registering the query handler.** 1012 | 1013 | You can also use an ioc container (like inversify or simple-ddd-toolkit) to resolve the query handler. 1014 | 1015 | ```typescript 1016 | queryBus.register(() => container.get(DI.LoadCharacterCreationDialogQueryHandler)); 1017 | ``` 1018 | 1019 | # Middleware 1020 | 1021 | Middleware is a way to add additional behavior to commands and queries without modifying the core logic. 1022 | 1023 | It allows you to intercept commands and queries before they are processed by the command or query handler. 1024 | 1025 | ## How to create middleware 1026 | 1027 | You can create middlewares for both commands and queries by implementing the `Middleware` and `Middleware` interfaces provided by the `simple-ddd-toolkit` package. 1028 | 1029 | ### Command middleware 1030 | 1031 | ```typescript 1032 | import { Middleware, Command } from "@evyweb/simple-ddd-toolkit"; 1033 | import { Logger } from "@evyweb/simple-ddd-toolkit"; 1034 | 1035 | export class CommandLoggerMiddleware implements Middleware { 1036 | constructor( 1037 | private readonly logger: Logger, 1038 | private readonly middlewareId: string 1039 | ) {} 1040 | 1041 | async execute(command: Command, next: (command: Command) => Promise): Promise { 1042 | const date = new Date().toISOString(); 1043 | this.logger.log( 1044 | `[${date}][${this.middlewareId}][${command.__TAG}] - ${JSON.stringify( 1045 | command 1046 | )}` 1047 | ); 1048 | return next(command); 1049 | } 1050 | } 1051 | ``` 1052 | 1053 | ### Query middleware 1054 | 1055 | ```typescript 1056 | import { Middleware, Query } from "@evyweb/simple-ddd-toolkit"; 1057 | import { Logger } from "@evyweb/simple-ddd-toolkit"; 1058 | 1059 | export class QueryLoggerMiddleware implements Middleware { 1060 | constructor( 1061 | private readonly logger: Logger, 1062 | private readonly middlewareId: string 1063 | ) {} 1064 | 1065 | execute(query: Query, next: (query: Query) => Promise): Promise { 1066 | const date = new Date().toISOString(); 1067 | this.logger.log( 1068 | `[${date}][${this.middlewareId}][${query.__TAG}] - ${JSON.stringify( 1069 | query 1070 | )}` 1071 | ); 1072 | return next(query); 1073 | } 1074 | } 1075 | ``` 1076 | 1077 | In these examples, the `CommandLoggerMiddleware` and `QueryLoggerMiddleware` classes log the command or query data before passing it to the next middleware or the command/query handler. 1078 | 1079 | ## How to register middleware 1080 | 1081 | To register middleware with the command bus or query bus, you can use the `use` method provided by the bus. 1082 | 1083 | ```typescript 1084 | commandBus.use(new CommandLoggerMiddleware(logger, "CommandLoggerMiddleware")); 1085 | queryBus.use(new QueryLoggerMiddleware(logger, "QueryLoggerMiddleware")); 1086 | ``` 1087 | 1088 | **Middleware will be executed in the order they are registered.** 1089 | 1090 | But you can also use an ioc container to resolve the middleware. 1091 | 1092 | ```typescript 1093 | commandBus.use(container.get(DI.CommandLoggerMiddleware)); 1094 | queryBus.use(container.get(DI.QueryLoggerMiddleware)); 1095 | ``` 1096 | 1097 | # Event Bus 1098 | 1099 | The `Event Bus` is a way to decouple components in a system by allowing them to communicate through events. 1100 | 1101 | It provides a mechanism for publishing and subscribing to events, allowing different parts of the system to react to changes without being tightly coupled. 1102 | 1103 | ## How to register event handlers 1104 | 1105 | Similarly to the command bus and query bus, the event bus is responsible for routing events to the appropriate event handlers. 1106 | 1107 | ```typescript 1108 | import { EventBus } from "@evyweb/simple-ddd-toolkit"; 1109 | 1110 | const eventBus = new EventBus(); 1111 | eventBus.on("ConversationCreatedEvent", () => new CreateDefaultPostEventHandler()); 1112 | ``` 1113 | 1114 | You can also use an ioc container to resolve the event handler. 1115 | 1116 | ```typescript 1117 | eventBus.on( 1118 | "ConversationCreatedEvent", 1119 | () => container.get(DI.CreateDefaultPostEventHandler) 1120 | ); 1121 | ``` 1122 | 1123 | You can group all the event types in a single file to avoid typos or to group them by domain. 1124 | 1125 | ```typescript 1126 | export const EventTypes = { 1127 | ConversationCreatedEvent: "ConversationCreatedEvent", 1128 | PostCreatedEvent: "PostCreatedEvent", 1129 | // ... 1130 | }; 1131 | 1132 | eventBus.on( 1133 | EventTypes.ConversationCreatedEvent, 1134 | () => new CreateDefaultPostEventHandler() 1135 | ); 1136 | ``` 1137 | 1138 | ### How to create an event handler 1139 | 1140 | To create an event handler, you can implement the `IEventHandler` interface provided by the `simple-ddd-toolkit` package. 1141 | 1142 | ```typescript 1143 | import { IEventHandler, DomainEvent, Command, Bus } from "@evyweb/simple-ddd-toolkit"; 1144 | 1145 | export class CreateDefaultPostEventHandler implements IEventHandler { 1146 | public readonly __TAG = "CreateDefaultPostEventHandler"; 1147 | 1148 | constructor(private readonly commandBus: Bus) {} 1149 | 1150 | async handle(event: ConversationCreatedEvent): Promise { 1151 | const { conversationId, characterId, postId, userId, participantsIds } = 1152 | event.metadata; 1153 | const command = new CreateDefaultPostCommand( 1154 | conversationId, 1155 | userId, 1156 | characterId, 1157 | postId, 1158 | participantsIds 1159 | ); 1160 | await this.commandBus.execute(command); 1161 | } 1162 | } 1163 | ``` 1164 | 1165 | In this example, the `CreateDefaultPostEventHandler` class implements the `IEventHandler` interface and defines the `handle` method to process the event. 1166 | 1167 | The event handler can execute commands, queries, or any other logic based on the event data. 1168 | 1169 | Here the event handler creates a default post when a conversation is created. 1170 | 1171 | **It's recommended to define a `__TAG` for each event handler to easily identify them. However, it's not mandatory.** 1172 | 1173 | ## How to dispatch the events 1174 | 1175 | To dispatch events to the event bus, you can use the `dispatch` and `dispatchAsync` methods provided by the event bus. 1176 | 1177 | ```typescript 1178 | const event = new ProductAddedToOrderEvent(order.id(), "product1", 2); 1179 | 1180 | // Use dispatch if you want to wait for the event to be processed before continuing (also present on aggregates) 1181 | await eventBus.dispatch(event); 1182 | 1183 | // if you want to dispatch events without blocking the user, prefer dispatchAsync (also present on aggregates) 1184 | // Don't use: "eventBus.dispatch(event);" without await 1185 | 1186 | // Use: 1187 | eventBus.dispatchAsync(event); 1188 | ``` 1189 | 1190 | The `dispatch` method has to be executed with an `await`, as it is synchronous and will wait for the event to be processed before continuing. 1191 | 1192 | The `dispatchAsync` method is asynchronous, which means it will dispatch the event without waiting for it to be processed. 1193 | 1194 | Using `dispatchAsync` can be useful when you want to dispatch events without blocking the main thread. For example, when you want to dispatch events in the background. 1195 | You can use for eventual consistency. 1196 | 1197 | ### Notes: 1198 | 1199 | Under the hood, the `dispatchAsync` method uses the `setImmediate` function to dispatch events asynchronously. 1200 | 1201 | #### Why not use `Promise.resolve().then(() => eventBus.dispatch(event))` or `process.nextTick(() => eventBus.dispatch(event))` or remove the `await` from `eventBus.dispatch(event)`? 1202 | 1203 | The `setImmediate` function is a more reliable way to dispatch events asynchronously because it ensures that the event is dispatched after the current phase of the event loop has completed. Tasks added via `setImmediate` are placed in the **macrotask queue** (specifically in the "check" phase), whereas tasks from `Promise.resolve` or `process.nextTick` are placed in the **microtask queue**. 1204 | 1205 | This distinction is crucial because the **microtask queue** is processed before the event loop moves to the next phase, which means that using `Promise.resolve` or `process.nextTick` can introduce unintended blocking if the tasks are computationally expensive or numerous. In contrast, `setImmediate` ensures that the current phase of the event loop (including microtasks) is entirely finished before the event is dispatched, reducing the risk of blocking or performance degradation. 1206 | 1207 | #### Why not use `setTimeout(() => eventBus.dispatch(event), 0)`? 1208 | 1209 | While `setTimeout` is similar to `setImmediate`, it schedules the task in the **timer queue**, which is processed after a minimum delay and only after the next phase of the event loop. `setImmediate` schedules the task in the **check queue**, allowing it to be executed earlier than a `setTimeout` task. 1210 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true 10 | } 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "formatWithErrors": false, 15 | "indentStyle": "space", 16 | "indentWidth": 4, 17 | "lineWidth": 120 18 | }, 19 | "javascript": { 20 | "formatter": { 21 | "quoteStyle": "single", 22 | "trailingCommas": "es5", 23 | "semicolons": "always" 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@evyweb/simple-ddd-toolkit", 3 | "version": "0.21.1", 4 | "description": "A simple Typescript Domain Driven Design Toolkit to help you create your aggregates, domain events, command handlers and other stuff.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist/" 10 | ], 11 | "scripts": { 12 | "build": "tsup src/index.ts --format cjs,esm --dts", 13 | "lint": "tsc --noEmit && biome check src/ specs/", 14 | "lint:fix": "tsc --noEmit && biome check --write src/ specs/", 15 | "format": "biome format --write src/ specs/", 16 | "test": "vitest run", 17 | "test:coverage": "vitest run --coverage", 18 | "changeset": "npx changeset", 19 | "changeset:version": "npx changeset version", 20 | "publish:package": "npm run build && npx changeset publish" 21 | }, 22 | "keywords": [ 23 | "ddd", 24 | "domain", 25 | "driven", 26 | "design", 27 | "toolkit", 28 | "simple", 29 | "typescript" 30 | ], 31 | "author": "Evyweb", 32 | "license": "MIT", 33 | "repository": { 34 | "type": "git", 35 | "url": "https://github.com/Evyweb/simple-ddd-toolkit.git" 36 | }, 37 | "publishConfig": { 38 | "access": "public" 39 | }, 40 | "devDependencies": { 41 | "@biomejs/biome": "1.9.4", 42 | "@changesets/cli": "^2.29.2", 43 | "@types/node": "^22.14.1", 44 | "@vitest/coverage-v8": "^3.1.2", 45 | "rimraf": "^5.0.5", 46 | "ts-node": "^10.9.2", 47 | "tsup": "^8.4.0", 48 | "typescript": "^5.8.3", 49 | "vitest": "3.1.2" 50 | }, 51 | "overrides": { 52 | "glob": "^10.0.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /specs/UUID/UUID.spec.ts: -------------------------------------------------------------------------------- 1 | import { UUID } from '@/valueObject/uuid/UUID'; 2 | import { Uuid, UuidFrom } from '@/valueObject/uuid/UUIDFactory'; 3 | 4 | describe('UUID', () => { 5 | it('should be able to create a valid UUID', () => { 6 | // Arrange 7 | const UUID_FORMAT = /^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/; 8 | 9 | // Act 10 | const id = Uuid('123e4567-e89b-12d3-a456-426614174000'); 11 | 12 | // Assert 13 | expect(id.get('value')).toMatch(UUID_FORMAT); 14 | }); 15 | 16 | it('should be able to create a UUID from a value', () => { 17 | // Arrange 18 | const value = '123e4567-e89b-12d3-a456-426614174000'; 19 | 20 | // Act 21 | const id = UuidFrom(value); 22 | 23 | // Assert 24 | expect(id.get('value')).toBe(value); 25 | }); 26 | 27 | describe('When 2 UUIDs are created with the same value', () => { 28 | it('should return true when comparing them', () => { 29 | // Arrange 30 | const id1 = UuidFrom('123e4567-e89b-12d3-a456-426614174000'); 31 | const id2 = UuidFrom('123e4567-e89b-12d3-a456-426614174000'); 32 | 33 | // Act 34 | const equality = id1.equals(id2); 35 | 36 | // Assert 37 | expect(equality).toBe(true); 38 | }); 39 | }); 40 | 41 | describe('When 2 UUIDs are created with different values', () => { 42 | it('should return false when comparing them', () => { 43 | // Arrange 44 | const id1 = UuidFrom('123e4567-e89b-12d3-a456-426614174000'); 45 | const id2 = UuidFrom('123e4567-e89b-12d3-a456-426614174001'); 46 | 47 | // Act 48 | const equality = id1.equals(id2); 49 | 50 | // Assert 51 | expect(equality).toBe(false); 52 | }); 53 | }); 54 | 55 | describe('When a UUID is created with an invalid value', () => { 56 | it('should throw an error', () => { 57 | // Arrange 58 | const value = 'invalid-uuid'; 59 | 60 | // Act 61 | const idCreation = () => UuidFrom(value); 62 | 63 | // Assert 64 | expect(idCreation).toThrowError('Invalid UUID'); 65 | }); 66 | }); 67 | 68 | describe('When a UUID has a valid value', () => { 69 | it('should return true', () => { 70 | // Arrange 71 | const value = '123e4567-e89b-12d3-a456-426614174000'; 72 | 73 | // Act 74 | const result = UuidFrom(value); 75 | 76 | // Assert 77 | expect(UUID.isValid(result.get('value'))).toBe(true); 78 | }); 79 | }); 80 | 81 | describe('[isNew]', () => { 82 | describe('When a UUID is newly created', () => { 83 | it('should return true', () => { 84 | // Act 85 | const uuid = Uuid('123e4567-e89b-12d3-a456-426614174000'); 86 | 87 | // Assert 88 | expect(uuid.isNew()).toBe(true); 89 | }); 90 | }); 91 | 92 | describe('When a UUID is created with a value', () => { 93 | it('should return false', () => { 94 | expect(UuidFrom('123e4567-e89b-12d3-a456-426614174000').isNew()).toBe(false); 95 | expect(Uuid('123e4567-e89b-12d3-a456-426614174000', false).isNew()).toBe(false); 96 | }); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /specs/aggregate/Aggregate.spec.ts: -------------------------------------------------------------------------------- 1 | import { Bus } from '@/bus/Bus'; 2 | import type { Command } from '@/bus/command/Command'; 3 | import { EventBus } from '@/eventBus/EventBus'; 4 | import { EventLoggingMiddleware } from '@/eventBus/EventLoggingMiddleware'; 5 | import { UuidFrom } from '@/valueObject/uuid/UUIDFactory'; 6 | import { FakeLogger } from '../logger/FakeLogger'; 7 | import { FakeUserAggregate } from './FakeUserAggregate'; 8 | import { FakeUserEvents } from './FakeUserEvents'; 9 | import { LogUsernameChangedCommandHandler } from './LogUsernameChangedCommandHandler'; 10 | import { OtherEvent } from './OtherEvent'; 11 | import { UsernameChangedEventHandler } from './UsernameChangedEventHandler'; 12 | import { UsernameUpdatedEvent } from './UsernameUpdatedEvent'; 13 | 14 | describe('Aggregate', () => { 15 | let eventBus: EventBus; 16 | let logger: FakeLogger; 17 | let commandBus: Bus; 18 | 19 | beforeEach(() => { 20 | logger = new FakeLogger(); 21 | eventBus = new EventBus(); 22 | eventBus.use(new EventLoggingMiddleware(logger)); 23 | commandBus = new Bus(); 24 | }); 25 | 26 | describe('When dispatching domain events', () => { 27 | describe('When a handler has been registered', () => { 28 | let aggregate: FakeUserAggregate; 29 | 30 | beforeEach(() => { 31 | commandBus.register(() => new LogUsernameChangedCommandHandler(logger)); 32 | eventBus.on(FakeUserEvents.USER_NAME_UPDATED, () => new UsernameChangedEventHandler(commandBus)); 33 | 34 | aggregate = FakeUserAggregate.create({ 35 | id: UuidFrom('15e4c6b3-0b0a-4b1a-9b0a-9b0a9b0a9b0a'), 36 | name: 'John Doe', 37 | }); 38 | 39 | aggregate.addEvent(new UsernameUpdatedEvent(aggregate.id(), 'John Doe', 'Jane Doe')); 40 | aggregate.addEvent( 41 | new OtherEvent({ 42 | eventId: '266e27fe-1c3f-4be6-8646-358e830544d4', 43 | }) 44 | ); 45 | }); 46 | 47 | describe('When the dispatch is done by the event bus', () => { 48 | it('should trigger the registered handler', () => { 49 | // Arrange 50 | const events = aggregate.getEvents(); 51 | 52 | // Act 53 | eventBus.dispatchEvents(events); 54 | 55 | // Assert 56 | expect(logger.messages).toHaveLength(2); 57 | 58 | const [firstMessage, secondMessage] = logger.messages; 59 | expect(firstMessage).toEqual( 60 | `[2024-01-28T01:06:59.782Z] Event "USER_NAME_UPDATED" occurred with ID "266e27fe-1c3f-4be6-8646-358e830544d4". Payload: {"userId":"15e4c6b3-0b0a-4b1a-9b0a-9b0a9b0a9b0a","newName":"Jane Doe"} - Metadata: {"oldName":"John Doe"}` 61 | ); 62 | expect(secondMessage).toEqual( 63 | `User "John Doe" with ID: "15e4c6b3-0b0a-4b1a-9b0a-9b0a9b0a9b0a" has a new name: "Jane Doe"` 64 | ); 65 | }); 66 | }); 67 | 68 | describe('When the dispatch is asynchronous', () => { 69 | beforeEach(() => { 70 | vi.useFakeTimers(); 71 | }); 72 | 73 | afterEach(() => { 74 | vi.useRealTimers(); 75 | }); 76 | 77 | describe('When the dispatch is done by the event bus', () => { 78 | it('should trigger the registered handler just after the execution', async () => { 79 | // Arrange 80 | const events = aggregate.getEvents(); 81 | eventBus.dispatchEventsAsync(events); 82 | 83 | // Act 84 | vi.runAllTimers(); 85 | 86 | // Assert 87 | expect(logger.messages).toHaveLength(2); 88 | 89 | const [firstMessage, secondMessage] = logger.messages; 90 | expect(firstMessage).toEqual( 91 | `[2024-01-28T01:06:59.782Z] Event "USER_NAME_UPDATED" occurred with ID "266e27fe-1c3f-4be6-8646-358e830544d4". Payload: {"userId":"15e4c6b3-0b0a-4b1a-9b0a-9b0a9b0a9b0a","newName":"Jane Doe"} - Metadata: {"oldName":"John Doe"}` 92 | ); 93 | expect(secondMessage).toEqual( 94 | `User "John Doe" with ID: "15e4c6b3-0b0a-4b1a-9b0a-9b0a9b0a9b0a" has a new name: "Jane Doe"` 95 | ); 96 | }); 97 | }); 98 | 99 | describe('When the dispatch is done by the aggregate', () => { 100 | it('should trigger the registered handler just after the execution', () => { 101 | // Arrange 102 | aggregate.dispatchEventsAsync(eventBus); 103 | 104 | // Act 105 | vi.runAllTimers(); 106 | 107 | // Assert 108 | expect(logger.messages).toHaveLength(2); 109 | 110 | const [firstMessage, secondMessage] = logger.messages; 111 | expect(firstMessage).toEqual( 112 | `[2024-01-28T01:06:59.782Z] Event "USER_NAME_UPDATED" occurred with ID "266e27fe-1c3f-4be6-8646-358e830544d4". Payload: {"userId":"15e4c6b3-0b0a-4b1a-9b0a-9b0a9b0a9b0a","newName":"Jane Doe"} - Metadata: {"oldName":"John Doe"}` 113 | ); 114 | expect(secondMessage).toEqual( 115 | `User "John Doe" with ID: "15e4c6b3-0b0a-4b1a-9b0a-9b0a9b0a9b0a" has a new name: "Jane Doe"` 116 | ); 117 | }); 118 | }); 119 | }); 120 | }); 121 | 122 | describe('When no handler has been registered', () => { 123 | it('should not do anything', () => { 124 | // Arrange 125 | const aggregate = FakeUserAggregate.create({ 126 | id: UuidFrom('15e4c6b3-0b0a-4b1a-9b0a-9b0a9b0a9b0a'), 127 | name: 'John Doe', 128 | }); 129 | 130 | aggregate.addEvent(new UsernameUpdatedEvent(aggregate.id(), 'John Doe', 'Jane Doe')); 131 | 132 | // Act 133 | aggregate.dispatchEvents(eventBus); 134 | 135 | // Assert 136 | expect(logger.messages).toHaveLength(0); 137 | }); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /specs/aggregate/FakeUserAggregate.ts: -------------------------------------------------------------------------------- 1 | import { Aggregate } from '@/aggregate/Aggregate'; 2 | import type { FakeUserData } from '../entity/FakeUserData'; 3 | import type { FakeUserAggregateData } from './FakeUserAggregateData'; 4 | 5 | export class FakeUserAggregate extends Aggregate { 6 | static create(fakeUserData: FakeUserData): FakeUserAggregate { 7 | return new FakeUserAggregate(fakeUserData); 8 | } 9 | 10 | updateName(newName: string): void { 11 | this.set('name', newName); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /specs/aggregate/FakeUserAggregateData.ts: -------------------------------------------------------------------------------- 1 | import type { UUID } from '@/valueObject/uuid/UUID'; 2 | 3 | export interface FakeUserAggregateData { 4 | id: UUID; 5 | name: string; 6 | } 7 | -------------------------------------------------------------------------------- /specs/aggregate/FakeUserEvents.ts: -------------------------------------------------------------------------------- 1 | export enum FakeUserEvents { 2 | USER_NAME_UPDATED = 'USER_NAME_UPDATED', 3 | } 4 | -------------------------------------------------------------------------------- /specs/aggregate/LogUsernameChangedCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@/bus/command/Command'; 2 | 3 | export class LogUsernameChangedCommand extends Command { 4 | readonly __TAG = 'LogUsernameChangedCommand'; 5 | 6 | constructor( 7 | public readonly userId: string, 8 | public readonly oldName: string, 9 | public readonly newName: string 10 | ) { 11 | super(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /specs/aggregate/LogUsernameChangedCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from '@/bus/command/CommandHandler'; 2 | import type { Logger } from '@/logger/Logger'; 3 | import type { LogUsernameChangedCommand } from './LogUsernameChangedCommand'; 4 | 5 | export class LogUsernameChangedCommandHandler extends CommandHandler { 6 | readonly __TAG = 'LogUsernameChangedCommandHandler'; 7 | 8 | constructor(private readonly logger: Logger) { 9 | super(); 10 | } 11 | 12 | async handle({ userId, oldName, newName }: LogUsernameChangedCommand): Promise { 13 | this.logger.log(`User "${oldName}" with ID: "${userId}" has a new name: "${newName}"`); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /specs/aggregate/OtherEvent.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from '@/domainEvent/DomainEvent'; 2 | 3 | export class OtherEvent extends DomainEvent { 4 | readonly __TAG: string = 'OtherEvent'; 5 | } 6 | -------------------------------------------------------------------------------- /specs/aggregate/UsernameChangedEventHandler.ts: -------------------------------------------------------------------------------- 1 | import type { Bus } from '@/bus/Bus'; 2 | import type { Command } from '@/bus/command/Command'; 3 | import type { IEventHandler } from '@/domainEvent/IEventHandler'; 4 | import { LogUsernameChangedCommand } from './LogUsernameChangedCommand'; 5 | import type { UsernameUpdatedEvent } from './UsernameUpdatedEvent'; 6 | 7 | export class UsernameChangedEventHandler implements IEventHandler { 8 | public readonly __TAG = 'UsernameChangedEventHandler'; 9 | 10 | constructor(private readonly commandBus: Bus) {} 11 | 12 | async handle(event: UsernameUpdatedEvent) { 13 | const command = new LogUsernameChangedCommand( 14 | event.payload.userId as string, 15 | event.metadata.oldName as string, 16 | event.payload.newName as string 17 | ); 18 | await this.commandBus.execute(command); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /specs/aggregate/UsernameUpdatedEvent.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from '@/domainEvent/DomainEvent'; 2 | import { UuidFrom } from '@/valueObject/uuid/UUIDFactory'; 3 | import { FakeUserEvents } from './FakeUserEvents'; 4 | 5 | export class UsernameUpdatedEvent extends DomainEvent { 6 | readonly __TAG: string = FakeUserEvents.USER_NAME_UPDATED; 7 | 8 | constructor(userId: string, oldName: string, newName: string) { 9 | super({ 10 | eventId: UuidFrom('266e27fe-1c3f-4be6-8646-358e830544d4').get('value'), 11 | occurredOn: new Date(Date.UTC(2024, 0, 28, 1, 6, 59, 782)), 12 | payload: { 13 | userId, 14 | newName, 15 | }, 16 | metadata: { 17 | oldName, 18 | }, 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /specs/bus/command/CommandHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import { FakeLogger } from '../../logger/FakeLogger'; 2 | import { FakeCommand } from './FakeCommand'; 3 | import { FakeCommandHandler } from './FakeCommandHandler'; 4 | import { FakeCommandHandlerWithReturnedValue } from './FakeCommandHandlerWithReturnedValue'; 5 | 6 | describe('CommandHandler', () => { 7 | it('should correctly execute the command', async () => { 8 | // Arrange 9 | const logger = new FakeLogger(); 10 | const commandHandler = new FakeCommandHandler(logger); 11 | 12 | // Act 13 | await commandHandler.handle(new FakeCommand('fakeName')); 14 | 15 | // Assert 16 | expect(logger.messages).toHaveLength(1); 17 | const [message] = logger.messages; 18 | expect(message).toEqual('fakeName'); 19 | }); 20 | 21 | describe('When the command handler returns no value', () => { 22 | it('should not return any value', async () => { 23 | // Arrange 24 | const commandHandler = new FakeCommandHandler(new FakeLogger()); 25 | 26 | // Act 27 | const result = await commandHandler.handle(new FakeCommand('fakeName')); 28 | 29 | // Assert 30 | expect(result).toBeUndefined(); 31 | }); 32 | }); 33 | 34 | describe('When the command handler returns a value', () => { 35 | it('should correctly return the value', async () => { 36 | // Arrange 37 | const commandHandler = new FakeCommandHandlerWithReturnedValue(new FakeLogger()); 38 | 39 | // Act 40 | const result = await commandHandler.handle(new FakeCommand('fakeName')); 41 | 42 | // Assert 43 | expect(result).toBe(true); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /specs/bus/command/FakeCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@/bus/command/Command'; 2 | 3 | export class FakeCommand extends Command { 4 | public readonly __TAG = 'FakeCommand'; 5 | 6 | constructor(public readonly name: string) { 7 | super(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /specs/bus/command/FakeCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from '@/bus/command/CommandHandler'; 2 | import type { Logger } from '@/logger/Logger'; 3 | import type { FakeCommand } from './FakeCommand'; 4 | 5 | export class FakeCommandHandler extends CommandHandler { 6 | public readonly __TAG = 'FakeCommandHandler'; 7 | 8 | constructor(private readonly logger: Logger) { 9 | super(); 10 | } 11 | 12 | async handle(command: FakeCommand): Promise { 13 | this.logger.log(command.name); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /specs/bus/command/FakeCommandHandlerWithReturnedValue.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from '@/bus/command/CommandHandler'; 2 | import type { Logger } from '@/logger/Logger'; 3 | import type { FakeCommand } from './FakeCommand'; 4 | 5 | export class FakeCommandHandlerWithReturnedValue extends CommandHandler { 6 | public readonly __TAG = 'FakeCommandHandlerWithReturnedValue'; 7 | 8 | constructor(private readonly logger: Logger) { 9 | super(); 10 | } 11 | 12 | async handle(command: FakeCommand): Promise { 13 | this.logger.log(command.name); 14 | return Promise.resolve(true); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /specs/bus/commandBus/CommandBus.spec.ts: -------------------------------------------------------------------------------- 1 | import { Bus } from '@/bus/Bus'; 2 | import type { Command } from '@/bus/command/Command'; 3 | import { CommandLoggerMiddleware } from '@/bus/middleware/CommandLoggerMiddleware'; 4 | import { FakeLogger } from '../../logger/FakeLogger'; 5 | import { FakeInvalidCommandHandler } from './FakeInvalidCommandHandler'; 6 | import { FakeUpdateNameCommand } from './FakeUpdateNameCommand'; 7 | import { FakeUpdateNameCommandHandler } from './FakeUpdateNameCommandHandler'; 8 | import { FakeUpdateNameWithReturnedValueCommand } from './FakeUpdateNameWithReturnedValueCommand'; 9 | import { FakeUpdateNameWithReturnedValueCommandHandler } from './FakeUpdateNameWithReturnedValueCommandHandler'; 10 | 11 | describe('[CommandBus]', () => { 12 | beforeEach(() => { 13 | vi.useFakeTimers().setSystemTime(new Date('2024-02-01')); 14 | }); 15 | 16 | describe('When the command has been registered', () => { 17 | it('should call the corresponding command handler', async () => { 18 | // Arrange 19 | const logger = new FakeLogger(); 20 | const commandBus = new Bus(); 21 | commandBus.register(() => new FakeUpdateNameCommandHandler(logger)); 22 | 23 | // Act 24 | await commandBus.execute(new FakeUpdateNameCommand('NEW NAME')); 25 | 26 | // Assert 27 | expect(logger.messages).toHaveLength(1); 28 | expect(logger.messages[0]).toBe('NEW NAME'); 29 | }); 30 | 31 | describe('When the command does not return a value', () => { 32 | it('should not return any value', async () => { 33 | // Arrange 34 | const logger = new FakeLogger(); 35 | const commandBus = new Bus(); 36 | commandBus.register(() => new FakeUpdateNameCommandHandler(logger)); 37 | 38 | // Act 39 | const result = await commandBus.execute(new FakeUpdateNameCommand('NEW NAME')); 40 | 41 | // Assert 42 | expect(result).toBeUndefined(); 43 | }); 44 | }); 45 | 46 | describe('When the command returns a value', () => { 47 | it('should return the correct value', async () => { 48 | // Arrange 49 | const logger = new FakeLogger(); 50 | const commandBus = new Bus(); 51 | commandBus.use(new CommandLoggerMiddleware(logger, 'Middleware')); 52 | commandBus.register(() => new FakeUpdateNameWithReturnedValueCommandHandler(logger)); 53 | 54 | // Act 55 | const result = await commandBus.execute(new FakeUpdateNameWithReturnedValueCommand('NEW NAME')); 56 | 57 | // Assert 58 | expect(result).toEqual('NEW NAME'); 59 | }); 60 | }); 61 | 62 | describe('When the handler does not have a __TAG property', () => { 63 | it('should throw an error', () => { 64 | // Arrange 65 | const commandBus = new Bus(); 66 | 67 | // Act 68 | const registration = () => commandBus.register(() => new FakeInvalidCommandHandler()); 69 | 70 | // Assert 71 | expect(registration).toThrowError('The handler must have a __TAG property to be registered.'); 72 | }); 73 | }); 74 | }); 75 | 76 | describe('When the command has not been registered', () => { 77 | it('should throw an error', async () => { 78 | // Arrange 79 | const command = new FakeUpdateNameCommand('NEW NAME'); 80 | const commandBus = new Bus(); 81 | 82 | const errorMessage = `No handler registered for ${command.__TAG}. Please check the __TAG property of both command and handler.`; 83 | 84 | // Act & Assert 85 | await expect(() => commandBus.execute(command)).rejects.toThrow(errorMessage); 86 | }); 87 | }); 88 | 89 | describe('When the command bus has middlewares', () => { 90 | it('should pass through the middlewares when executing the command', async () => { 91 | // Arrange 92 | const command = new FakeUpdateNameCommand('NEW NAME'); 93 | const logger = new FakeLogger(); 94 | const commandBus = new Bus(); 95 | commandBus.register(() => new FakeUpdateNameCommandHandler(logger)); 96 | 97 | commandBus.use(new CommandLoggerMiddleware(logger, 'Middleware 1')); 98 | commandBus.use(new CommandLoggerMiddleware(logger, 'Middleware 2')); 99 | 100 | // Act 101 | await commandBus.execute(command); 102 | 103 | // Assert 104 | expect(logger.messages).toHaveLength(3); 105 | expect(logger.messages[0]).toEqual( 106 | '[2024-02-01T00:00:00.000Z][Middleware 1][FakeUpdateNameCommand] - {"__TAG":"FakeUpdateNameCommand","name":"NEW NAME"}' 107 | ); 108 | expect(logger.messages[1]).toEqual( 109 | '[2024-02-01T00:00:00.000Z][Middleware 2][FakeUpdateNameCommand] - {"__TAG":"FakeUpdateNameCommand","name":"NEW NAME"}' 110 | ); 111 | expect(logger.messages[2]).toEqual('NEW NAME'); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /specs/bus/commandBus/FakeInvalidCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@/bus/command/Command'; 2 | import type { ICommandHandler } from '@/bus/command/ICommandHandler'; 3 | import type { FakeUpdateNameCommand } from './FakeUpdateNameCommand'; 4 | 5 | export class FakeInvalidCommand extends Command { 6 | readonly __TAG: string = ''; 7 | 8 | constructor(public readonly name: string) { 9 | super(); 10 | } 11 | } 12 | 13 | export class FakeInvalidCommandHandler implements ICommandHandler { 14 | readonly __TAG: string = ''; 15 | 16 | handle(command: FakeInvalidCommand): Promise { 17 | return Promise.resolve(command.__TAG); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /specs/bus/commandBus/FakeUpdateNameCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@/bus/command/Command'; 2 | 3 | export class FakeUpdateNameCommand extends Command { 4 | readonly __TAG: string = 'FakeUpdateNameCommand'; 5 | 6 | constructor(public readonly name: string) { 7 | super(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /specs/bus/commandBus/FakeUpdateNameCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from '@/bus/command/CommandHandler'; 2 | import type { FakeLogger } from '../../logger/FakeLogger'; 3 | import type { FakeUpdateNameCommand } from './FakeUpdateNameCommand'; 4 | 5 | export class FakeUpdateNameCommandHandler extends CommandHandler { 6 | readonly __TAG: string = 'FakeUpdateNameCommandHandler'; 7 | 8 | constructor(private readonly logger: FakeLogger) { 9 | super(); 10 | } 11 | 12 | async handle(command: FakeUpdateNameCommand): Promise { 13 | this.logger.log(command.name); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /specs/bus/commandBus/FakeUpdateNameWithReturnedValueCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@/bus/command/Command'; 2 | 3 | export class FakeUpdateNameWithReturnedValueCommand extends Command { 4 | readonly __TAG: string = 'FakeUpdateNameWithReturnedValueCommand'; 5 | 6 | constructor(public readonly name: string) { 7 | super(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /specs/bus/commandBus/FakeUpdateNameWithReturnedValueCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import type { ICommandHandler } from '@/bus/command/ICommandHandler'; 2 | import type { FakeLogger } from '../../logger/FakeLogger'; 3 | import type { FakeUpdateNameCommand } from './FakeUpdateNameCommand'; 4 | 5 | export class FakeUpdateNameWithReturnedValueCommandHandler implements ICommandHandler { 6 | readonly __TAG = 'FakeUpdateNameWithReturnedValueCommandHandler'; 7 | 8 | constructor(private readonly logger: FakeLogger) {} 9 | 10 | async handle(command: FakeUpdateNameCommand): Promise { 11 | this.logger.log(command.name); 12 | return Promise.resolve(command.name); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /specs/bus/query/FakeResponse.ts: -------------------------------------------------------------------------------- 1 | export interface FakeResponse { 2 | upperCaseName: string; 3 | } 4 | -------------------------------------------------------------------------------- /specs/bus/query/FakeViewCurrentNameQuery.ts: -------------------------------------------------------------------------------- 1 | import { Query } from '@/bus/query/Query'; 2 | 3 | export class FakeViewCurrentNameQuery extends Query { 4 | public readonly __TAG = 'FakeViewCurrentNameQuery'; 5 | 6 | constructor(public readonly name: string) { 7 | super(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /specs/bus/query/FakeViewCurrentNameQueryHandler.ts: -------------------------------------------------------------------------------- 1 | import { QueryHandler } from '@/bus/query/QueryHandler'; 2 | import type { FakeResponse } from './FakeResponse'; 3 | import type { FakeViewCurrentNameQuery } from './FakeViewCurrentNameQuery'; 4 | 5 | export class FakeViewCurrentNameQueryHandler extends QueryHandler { 6 | public readonly __TAG = 'FakeViewCurrentNameQueryHandler'; 7 | 8 | handle(query: FakeViewCurrentNameQuery): Promise { 9 | return Promise.resolve({ upperCaseName: query.name.toUpperCase() }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /specs/bus/query/QueryHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import { FakeViewCurrentNameQuery } from './FakeViewCurrentNameQuery'; 2 | import { FakeViewCurrentNameQueryHandler } from './FakeViewCurrentNameQueryHandler'; 3 | 4 | describe('IQueryHandler', () => { 5 | it('should correctly execute the query', async () => { 6 | // Arrange 7 | const query = new FakeViewCurrentNameQuery('Fake Name'); 8 | const queryHandler = new FakeViewCurrentNameQueryHandler(); 9 | 10 | // Act 11 | const response = await queryHandler.handle(query); 12 | 13 | // Assert 14 | expect(response.upperCaseName).toEqual('FAKE NAME'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /specs/bus/queryBus/QueryBus.spec.ts: -------------------------------------------------------------------------------- 1 | import { Bus } from '@/bus/Bus'; 2 | import { QueryLoggerMiddleware } from '@/bus/middleware/QueryLoggerMiddleware'; 3 | import type { Query } from '@/bus/query/Query'; 4 | import { FakeLogger } from '../../logger/FakeLogger'; 5 | import type { FakeResponse } from '../query/FakeResponse'; 6 | import { FakeViewCurrentNameQuery } from '../query/FakeViewCurrentNameQuery'; 7 | import { FakeViewCurrentNameQueryHandler } from '../query/FakeViewCurrentNameQueryHandler'; 8 | 9 | describe('[QueryBus]', () => { 10 | beforeEach(() => { 11 | vi.useFakeTimers().setSystemTime(new Date('2024-02-01')); 12 | }); 13 | 14 | describe('When a handler is registered for a query type', () => { 15 | it('should execute the corresponding query handler', async () => { 16 | // Arrange 17 | const queryBus = new Bus(); 18 | const query = new FakeViewCurrentNameQuery('Current name'); 19 | 20 | queryBus.register(() => new FakeViewCurrentNameQueryHandler()); 21 | 22 | // Act 23 | const response = await queryBus.execute(query); 24 | 25 | // Assert 26 | expect(response.upperCaseName).toEqual('CURRENT NAME'); 27 | }); 28 | }); 29 | 30 | describe('When a handler is not registered for a query type', () => { 31 | it('should throw an error', async () => { 32 | // Arrange 33 | const queryBus = new Bus(); 34 | const query = new FakeViewCurrentNameQuery('Current name'); 35 | 36 | // Act & Assert 37 | await expect(queryBus.execute(query)).rejects.toThrow('No handler registered for FakeViewCurrentNameQuery'); 38 | }); 39 | }); 40 | 41 | describe('When the query bus has middlewares', () => { 42 | it('should pass through the middlewares when executing the query', async () => { 43 | // Arrange 44 | const queryBus = new Bus(); 45 | const logger = new FakeLogger(); 46 | const query = new FakeViewCurrentNameQuery('Current name'); 47 | 48 | queryBus.register(() => new FakeViewCurrentNameQueryHandler()); 49 | 50 | queryBus.use(new QueryLoggerMiddleware(logger, 'Middleware 1')); 51 | queryBus.use(new QueryLoggerMiddleware(logger, 'Middleware 2')); 52 | 53 | // Act 54 | const response = await queryBus.execute(query); 55 | 56 | // Assert 57 | expect(logger.messages).toHaveLength(2); 58 | expect(logger.messages[0]).toEqual( 59 | '[2024-02-01T00:00:00.000Z][Middleware 1][FakeViewCurrentNameQuery] - {"__TAG":"FakeViewCurrentNameQuery","name":"Current name"}' 60 | ); 61 | expect(logger.messages[1]).toEqual( 62 | '[2024-02-01T00:00:00.000Z][Middleware 2][FakeViewCurrentNameQuery] - {"__TAG":"FakeViewCurrentNameQuery","name":"Current name"}' 63 | ); 64 | expect(response.upperCaseName).toEqual('CURRENT NAME'); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /specs/entity/Entity.spec.ts: -------------------------------------------------------------------------------- 1 | import { UUID } from '@/valueObject/uuid/UUID'; 2 | import { UuidFrom } from '@/valueObject/uuid/UUIDFactory'; 3 | import { FakeUserEntity } from './FakeUserEntity'; 4 | 5 | describe('Entity', () => { 6 | describe('When an entity is created', () => { 7 | it('should have the correct values', () => { 8 | // Arrange 9 | const id = UuidFrom('123e4567-e89b-12d3-a456-426614174000'); 10 | 11 | // Act 12 | const entity = FakeUserEntity.create({ id, name: 'fakeName' }); 13 | 14 | // Assert 15 | expect(entity.id()).toEqual('123e4567-e89b-12d3-a456-426614174000'); 16 | expect(entity.get('id')).toEqual(id); 17 | expect(entity.get('name')).toEqual('fakeName'); 18 | }); 19 | }); 20 | 21 | describe('When two entities are compared', () => { 22 | describe('And they have the same id', () => { 23 | it('should return true', () => { 24 | // Arrange 25 | const id = UuidFrom('123e4567-e89b-12d3-a456-426614174000'); 26 | const entity1 = FakeUserEntity.create({ 27 | id, 28 | name: 'fakeName1', 29 | }); 30 | const entity2 = FakeUserEntity.create({ 31 | id, 32 | name: 'fakeName2', 33 | }); 34 | 35 | // Act 36 | const areEquals = entity1.equals(entity2); 37 | 38 | // Assert 39 | expect(areEquals).toBe(true); 40 | }); 41 | }); 42 | 43 | describe('And they have different ids', () => { 44 | it('should return false', () => { 45 | // Arrange 46 | const id1 = UuidFrom('11111111-1111-1111-1111-111111111111'); 47 | const entity1 = FakeUserEntity.create({ 48 | id: id1, 49 | name: 'fakeName1', 50 | }); 51 | 52 | const id2 = UuidFrom('22222222-2222-2222-2222-222222222222'); 53 | const entity2 = FakeUserEntity.create({ 54 | id: id2, 55 | name: 'fakeName2', 56 | }); 57 | 58 | // Act 59 | const areEquals = entity1.equals(entity2); 60 | 61 | // Assert 62 | expect(areEquals).toBe(false); 63 | }); 64 | }); 65 | }); 66 | 67 | describe('When an entity is updated', () => { 68 | it('should have the correct values', () => { 69 | // Arrange 70 | const id = UuidFrom('00000000-0000-0000-0000-000000000000'); 71 | const entity = FakeUserEntity.create({ id, name: 'fakeName' }); 72 | 73 | // Act 74 | entity.set('id', UuidFrom('11111111-1111-1111-1111-111111111111')); 75 | entity.set('name', 'newFakeName'); 76 | 77 | // Assert 78 | expect(entity.get('id').get('value')).toEqual('11111111-1111-1111-1111-111111111111'); 79 | expect(entity.get('name')).toEqual('newFakeName'); 80 | }); 81 | }); 82 | 83 | describe('When an entity is converted to an object', () => { 84 | it('should have the correct values', () => { 85 | // Arrange 86 | const id = UuidFrom('00000000-0000-0000-0000-000000000000'); 87 | const entity = FakeUserEntity.create({ id, name: 'fakeName' }); 88 | 89 | // Act 90 | const userObject = entity.toObject(); 91 | 92 | // Assert 93 | expect(userObject.id).toEqual(id); 94 | expect(userObject.name).toEqual('fakeName'); 95 | }); 96 | }); 97 | 98 | describe('When ID is new', () => { 99 | it('should return true', () => { 100 | // Arrange 101 | const id = UUID.create('123e4567-e89b-12d3-a456-426614174000'); 102 | const entity = FakeUserEntity.create({ id, name: 'fakeName' }); 103 | 104 | // Act 105 | const isNew = entity.isNew(); 106 | 107 | // Assert 108 | expect(isNew).toBe(true); 109 | }); 110 | }); 111 | 112 | describe('When ID is not new', () => { 113 | it('should return false', () => { 114 | // Arrange 115 | const id = UUID.createFrom('123e4567-e89b-12d3-a456-426614174000'); 116 | const entity = FakeUserEntity.create({ id, name: 'fakeName' }); 117 | 118 | // Act 119 | const isNew = entity.isNew(); 120 | 121 | // Assert 122 | expect(isNew).toBe(false); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /specs/entity/FakeUserData.ts: -------------------------------------------------------------------------------- 1 | import type { UUID } from '@/valueObject/uuid/UUID'; 2 | 3 | export interface FakeUserData { 4 | id: UUID; 5 | name: string; 6 | } 7 | -------------------------------------------------------------------------------- /specs/entity/FakeUserEntity.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '@/entity/Entity'; 2 | import type { FakeUserData } from './FakeUserData'; 3 | 4 | export class FakeUserEntity extends Entity { 5 | static create(fakeUserData: FakeUserData): FakeUserEntity { 6 | // Validation rules here 7 | return new FakeUserEntity(fakeUserData); 8 | } 9 | 10 | // For testing purpose only 11 | set(key: keyof FakeUserData, value: FakeUserData[typeof key]): void { 12 | super.set(key, value); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /specs/errors/AnyDomainError.ts: -------------------------------------------------------------------------------- 1 | import { DomainError } from '@/errors/DomainError'; 2 | 3 | export class AnyDomainError extends DomainError { 4 | readonly __TAG: string = 'AnyDomainError'; 5 | 6 | constructor() { 7 | super('Any domain related error message'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /specs/errors/DomainError.spec.ts: -------------------------------------------------------------------------------- 1 | import { AnyDomainError } from './AnyDomainError'; 2 | 3 | describe('[DomainError]', () => { 4 | it('should not be identified as a technical error', () => { 5 | // Act 6 | const error = new AnyDomainError(); 7 | 8 | // Assert 9 | expect(error.isTechnicalError()).toBe(false); 10 | }); 11 | 12 | it('should be identified as a domain error', () => { 13 | // Act 14 | const error = new AnyDomainError(); 15 | 16 | // Assert 17 | expect(error.isDomainError()).toBe(true); 18 | }); 19 | 20 | it('should have a tag name', () => { 21 | // Act 22 | const error = new AnyDomainError(); 23 | 24 | // Assert 25 | expect(error.__TAG).toEqual('AnyDomainError'); 26 | }); 27 | 28 | it('should have a message', () => { 29 | // Act 30 | const error = new AnyDomainError(); 31 | 32 | // Assert 33 | expect(error.message).toEqual('Any domain related error message'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /specs/errors/InternalServerError.ts: -------------------------------------------------------------------------------- 1 | import { TechnicalError } from '@/errors/TechnicalError'; 2 | 3 | export class InternalServerError extends TechnicalError { 4 | readonly __TAG: string = 'InternalServerError'; 5 | 6 | constructor() { 7 | super('Something went wrong'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /specs/errors/TechnicalError.spec.ts: -------------------------------------------------------------------------------- 1 | import { InternalServerError } from './InternalServerError'; 2 | 3 | describe('[TechnicalError]', () => { 4 | it('should not be identified as a domain error', () => { 5 | // Act 6 | const error = new InternalServerError(); 7 | 8 | // Assert 9 | expect(error.isDomainError()).toBe(false); 10 | }); 11 | 12 | it('should be identified as a technical error', () => { 13 | // Act 14 | const error = new InternalServerError(); 15 | 16 | // Assert 17 | expect(error.isTechnicalError()).toBe(true); 18 | }); 19 | 20 | it('should have a tag name', () => { 21 | // Act 22 | const error = new InternalServerError(); 23 | 24 | // Assert 25 | expect(error.__TAG).toEqual('InternalServerError'); 26 | }); 27 | 28 | it('should have a message', () => { 29 | // Act 30 | const error = new InternalServerError(); 31 | 32 | // Assert 33 | expect(error.message).toEqual('Something went wrong'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /specs/logger/FakeLogger.ts: -------------------------------------------------------------------------------- 1 | import type { Logger } from '@/logger/Logger'; 2 | 3 | export class FakeLogger implements Logger { 4 | public readonly messages: string[] = []; 5 | 6 | log(message: string): void { 7 | this.messages.push(message); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /specs/result/Result.spec.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@/result/Result'; 2 | 3 | describe('Result', () => { 4 | describe('When an "ok" Result is created', () => { 5 | it('should contain the value passed to the static "ok" method', () => { 6 | // Arrange 7 | const value = 'test value'; 8 | 9 | // Act 10 | const result = Result.ok(value); 11 | 12 | // Assert 13 | expect(result.getValue()).toEqual('test value'); 14 | }); 15 | 16 | it('should throw an error instead of accessing the error', () => { 17 | // Arrange 18 | const value = 'test value'; 19 | 20 | // Act 21 | const result = Result.ok(value); 22 | 23 | // Assert 24 | expect(() => result.getError()).toThrow(); 25 | }); 26 | 27 | it('should indicate an "ok" status', () => { 28 | // Arrange 29 | const value = 'test value'; 30 | 31 | // Act 32 | const result = Result.ok(value); 33 | 34 | // Assert 35 | expect(result.isOk()).toEqual(true); 36 | }); 37 | 38 | it('should not indicate a failure status', () => { 39 | // Arrange 40 | const value = 'test value'; 41 | 42 | // Act 43 | const result = Result.ok(value); 44 | 45 | // Assert 46 | expect(result.isFail()).toEqual(false); 47 | }); 48 | }); 49 | 50 | describe('When a "failure" Result is created', () => { 51 | it('should contain the error passed to the static "fail" method', () => { 52 | // Arrange 53 | const error = new Error('test error'); 54 | 55 | // Act 56 | const result = Result.fail(error); 57 | 58 | // Assert 59 | expect(result.getError()).toEqual(new Error('test error')); 60 | }); 61 | 62 | it('should throw an error instead of accessing the value', () => { 63 | // Arrange 64 | const error = new Error('test error'); 65 | 66 | // Act 67 | const result = Result.fail(error); 68 | 69 | // Assert 70 | expect(() => result.getValue()).toThrow(); 71 | }); 72 | 73 | it('should not indicate an "ok" status', () => { 74 | // Arrange 75 | const error = new Error('test error'); 76 | 77 | // Act 78 | const result = Result.fail(error); 79 | 80 | // Assert 81 | expect(result.isOk()).toEqual(false); 82 | }); 83 | 84 | it('should indicate a "failure" status', () => { 85 | // Arrange 86 | const error = new Error('test error'); 87 | 88 | // Act 89 | const result = Result.fail(error); 90 | 91 | // Assert 92 | expect(result.isFail()).toEqual(true); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /specs/useCase/FakePresenter.ts: -------------------------------------------------------------------------------- 1 | import type { FakePresenterPort } from './FakePresenterPort'; 2 | import type { FakeViewModel } from './FakeViewModel'; 3 | 4 | export class FakePresenter implements FakePresenterPort { 5 | viewModel: FakeViewModel = { newName: '' }; 6 | 7 | presentName(name: string): void { 8 | this.viewModel.newName = name.toUpperCase(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /specs/useCase/FakePresenterPort.ts: -------------------------------------------------------------------------------- 1 | export interface FakePresenterPort { 2 | presentName(name: string): void; 3 | } 4 | -------------------------------------------------------------------------------- /specs/useCase/FakeRequest.ts: -------------------------------------------------------------------------------- 1 | export interface FakeRequest { 2 | name: string; 3 | } 4 | -------------------------------------------------------------------------------- /specs/useCase/FakeUseCase.ts: -------------------------------------------------------------------------------- 1 | import type { IUseCase } from '@/useCase/IUseCase'; 2 | import type { FakePresenterPort } from './FakePresenterPort'; 3 | import type { FakeRequest } from './FakeRequest'; 4 | 5 | export class FakeUseCase implements IUseCase { 6 | constructor(private readonly presenter: FakePresenterPort) {} 7 | 8 | async execute(request: FakeRequest): Promise { 9 | this.presenter.presentName(request.name); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /specs/useCase/FakeViewModel.ts: -------------------------------------------------------------------------------- 1 | export interface FakeViewModel { 2 | newName: string; 3 | } 4 | -------------------------------------------------------------------------------- /specs/useCase/IUseCase.spec.ts: -------------------------------------------------------------------------------- 1 | import { FakeCommand } from '../bus/command/FakeCommand'; 2 | import { FakePresenter } from './FakePresenter'; 3 | import { FakeUseCase } from './FakeUseCase'; 4 | 5 | describe('When a use case is executed', () => { 6 | it('should correctly execute the use case', async () => { 7 | // Arrange 8 | const presenter = new FakePresenter(); 9 | const useCase = new FakeUseCase(presenter); 10 | 11 | // Act 12 | await useCase.execute(new FakeCommand('Fake Name')); 13 | 14 | // Assert 15 | expect(presenter.viewModel.newName).toEqual('FAKE NAME'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /specs/valueObjects/Money.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from '@/valueObject/ValueObject'; 2 | 3 | interface MoneyData { 4 | amount: number; 5 | currency: string; 6 | } 7 | 8 | export class Money extends ValueObject { 9 | static create(moneyData: MoneyData) { 10 | // Validation rules here 11 | return new Money(moneyData); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /specs/valueObjects/OtherValueObject.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from '@/valueObject/ValueObject'; 2 | 3 | export class OtherValueObject extends ValueObject { 4 | public static create(): OtherValueObject { 5 | return new OtherValueObject(null); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /specs/valueObjects/SomeInformation.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from '@/valueObject/ValueObject'; 2 | 3 | interface SomeInformationData { 4 | name: string | null; 5 | information: { 6 | author: string; 7 | year?: string; 8 | } | null; 9 | } 10 | 11 | export class SomeInformation extends ValueObject { 12 | static create(data: SomeInformationData): SomeInformation { 13 | // Validation rules here 14 | return new SomeInformation(data); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /specs/valueObjects/ValueObject.spec.ts: -------------------------------------------------------------------------------- 1 | import { Money } from './Money'; 2 | import { OtherValueObject } from './OtherValueObject'; 3 | import { SomeInformation } from './SomeInformation'; 4 | 5 | describe('[ValueObject]', () => { 6 | describe('When 2 value objects have same values', () => { 7 | describe('When the values are simple', () => { 8 | it('should return true', () => { 9 | // Arrange 10 | const valueObject1 = Money.create({ amount: 5, currency: '€' }); 11 | const valueObject2 = Money.create({ amount: 5, currency: '€' }); 12 | 13 | // Act 14 | const result = valueObject1.equals(valueObject2); 15 | 16 | // Assert 17 | expect(result).toEqual(true); 18 | }); 19 | }); 20 | 21 | describe('When the values are null', () => { 22 | it('should return true', () => { 23 | // Arrange 24 | const valueObject1 = OtherValueObject.create(); 25 | const valueObject2 = OtherValueObject.create(); 26 | 27 | // Act 28 | const result = valueObject1.equals(valueObject2); 29 | 30 | // Assert 31 | expect(result).toEqual(true); 32 | }); 33 | }); 34 | 35 | describe('When the values are more complex', () => { 36 | it('should return true', () => { 37 | // Arrange 38 | const valueObject1 = SomeInformation.create({ 39 | name: 'fakeName1', 40 | information: { 41 | author: 'fakeName1', 42 | year: '2019-05-01', 43 | }, 44 | }); 45 | const valueObject2 = SomeInformation.create({ 46 | name: 'fakeName1', 47 | information: { 48 | author: 'fakeName1', 49 | year: '2019-05-01', 50 | }, 51 | }); 52 | 53 | // Act 54 | const result = valueObject1.equals(valueObject2); 55 | 56 | // Assert 57 | expect(result).toEqual(true); 58 | }); 59 | }); 60 | }); 61 | 62 | describe('When trying to mutate a nested property', () => { 63 | it('should throw an error', () => { 64 | // Arrange 65 | const valueObject = SomeInformation.create({ 66 | name: 'fakeName1', 67 | information: { 68 | author: 'fakeName1', 69 | year: '2019-05-01', 70 | }, 71 | }); 72 | 73 | // Act & Assert 74 | expect(() => { 75 | const information = valueObject.get('information') as { 76 | author: string; 77 | year?: string; 78 | }; 79 | information.author = 'fakeName2'; 80 | }).toThrowError(`Cannot assign to read only property 'author'`); 81 | }); 82 | }); 83 | 84 | describe(`When 2 value objects don't have the same values`, () => { 85 | describe('When the values are simple', () => { 86 | it('should return true', () => { 87 | // Arrange 88 | const valueObject1 = Money.create({ amount: 5, currency: '€' }); 89 | const valueObject2 = Money.create({ 90 | amount: 10, 91 | currency: '$', 92 | }); 93 | 94 | // Act 95 | const result = valueObject1.equals(valueObject2); 96 | 97 | // Assert 98 | expect(result).toEqual(false); 99 | }); 100 | }); 101 | 102 | describe('When the values are more complex', () => { 103 | it('should return false', () => { 104 | // Arrange 105 | const valueObject1 = SomeInformation.create({ 106 | name: 'fakeName1', 107 | information: { 108 | author: 'fakeName1', 109 | year: '2019-05-01', 110 | }, 111 | }); 112 | 113 | const valueObject2 = SomeInformation.create({ 114 | name: 'fakeName1', 115 | information: { 116 | author: 'fakeName1', 117 | year: '2019-05-02', 118 | }, 119 | }); 120 | 121 | // Act 122 | const result = valueObject1.equals(valueObject2); 123 | 124 | // Assert 125 | expect(result).toEqual(false); 126 | }); 127 | }); 128 | 129 | describe('When a value is missing', () => { 130 | it('should return false', () => { 131 | // Arrange 132 | const valueObject1 = SomeInformation.create({ 133 | name: 'fakeName1', 134 | information: { 135 | author: 'fakeName1', 136 | year: '2019-05-01', 137 | }, 138 | }); 139 | 140 | const valueObject2 = SomeInformation.create({ 141 | name: 'fakeName1', 142 | information: { 143 | author: 'fakeName1', 144 | }, 145 | }); 146 | 147 | // Act 148 | const result = valueObject1.equals(valueObject2); 149 | 150 | // Assert 151 | expect(result).toEqual(false); 152 | }); 153 | }); 154 | 155 | describe('When comparing to not existing object', () => { 156 | it('should return false', () => { 157 | // Arrange 158 | const valueObject1 = SomeInformation.create({ 159 | name: 'fakeName1', 160 | information: { 161 | author: 'fakeName1', 162 | year: '2019-05-01', 163 | }, 164 | }); 165 | 166 | const valueObject2 = undefined as unknown as SomeInformation; 167 | 168 | // Act 169 | const result = valueObject1.equals(valueObject2); 170 | 171 | // Assert 172 | expect(result).toEqual(false); 173 | }); 174 | }); 175 | }); 176 | 177 | describe('When comparing an object with a primitive value', () => { 178 | it('should return false', () => { 179 | // Arrange 180 | const valueObject1 = SomeInformation.create({ 181 | name: 'fakeName1', 182 | information: { 183 | author: 'fakeName1', 184 | year: '2019-05-01', 185 | }, 186 | }); 187 | 188 | const primitiveValue = 'fakeName1'; 189 | 190 | // Act 191 | const result = valueObject1.equals(primitiveValue as unknown as SomeInformation); 192 | 193 | // Assert 194 | expect(result).toEqual(false); 195 | }); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /src/aggregate/Aggregate.ts: -------------------------------------------------------------------------------- 1 | import type { DomainEvent } from '@/domainEvent/DomainEvent'; 2 | import { Entity } from '@/entity/Entity'; 3 | import type { EventBusPort } from '@/eventBus/EventBusPort'; 4 | import type { UUID } from '@/valueObject/uuid/UUID'; 5 | 6 | export abstract class Aggregate extends Entity { 7 | private domainEvents: DomainEvent[] = []; 8 | 9 | addEvent(domainEvent: DomainEvent): void { 10 | this.domainEvents.push(domainEvent); 11 | } 12 | 13 | clearEvents(): void { 14 | this.domainEvents = []; 15 | } 16 | 17 | async dispatchEvents(bus: EventBusPort): Promise { 18 | for (const event of this.domainEvents) { 19 | await bus.dispatch(event); 20 | } 21 | this.clearEvents(); 22 | } 23 | 24 | dispatchEventsAsync(bus: EventBusPort): void { 25 | setImmediate(async () => { 26 | await this.dispatchEvents(bus); 27 | }); 28 | } 29 | 30 | getEvents(): DomainEvent[] { 31 | const eventsToDispatch = [...this.domainEvents]; 32 | this.clearEvents(); 33 | return eventsToDispatch; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/bus/Bus.ts: -------------------------------------------------------------------------------- 1 | import type { IMessageHandler } from '@/bus/IMessageHandler'; 2 | import type { Message } from '@/bus/Message'; 3 | import type { Middleware } from '@/bus/middleware/Middleware'; 4 | 5 | export class Bus { 6 | private handlers: Map IMessageHandler> = new Map(); 7 | private middlewares: Middleware[] = []; 8 | 9 | register(handlerFactory: () => IMessageHandler): void { 10 | const handler = handlerFactory(); 11 | if (!handler.__TAG) { 12 | throw new Error('The handler must have a __TAG property to be registered.'); 13 | } 14 | this.handlers.set(handler.__TAG, handlerFactory as () => IMessageHandler); 15 | } 16 | 17 | use(middleware: Middleware): void { 18 | this.middlewares.push(middleware as Middleware); 19 | } 20 | 21 | async execute(message: M): Promise { 22 | const handlerName = `${message.__TAG}Handler`; 23 | const handlerFactory = this.handlers.get(handlerName); 24 | 25 | if (!handlerFactory) { 26 | throw new Error( 27 | `No handler registered for ${message.__TAG}. Please check the __TAG property of both command and handler.` 28 | ); 29 | } 30 | 31 | const handler = handlerFactory(); 32 | 33 | const executeHandler = (finalMessage: M): Promise => { 34 | return handler.handle(finalMessage) as Promise; 35 | }; 36 | 37 | const middlewareChain = this.middlewares.reduceRight<(message: M) => Promise>( 38 | (next, middleware) => (msg) => middleware.execute(msg, next), 39 | executeHandler 40 | ); 41 | 42 | return middlewareChain(message); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/bus/IMessageHandler.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from '@/bus/Message'; 2 | 3 | export interface IMessageHandler { 4 | __TAG: string; 5 | 6 | handle(message: M): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/bus/Message.ts: -------------------------------------------------------------------------------- 1 | export type Message = { 2 | __TAG: string; 3 | }; 4 | -------------------------------------------------------------------------------- /src/bus/command/Command.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from '@/bus/Message'; 2 | 3 | export abstract class Command implements Message { 4 | public abstract readonly __TAG: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/bus/command/CommandHandler.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '@/bus/command/Command'; 2 | 3 | export abstract class CommandHandler { 4 | public abstract readonly __TAG: string; 5 | 6 | public abstract handle(command: TCommand): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/bus/command/ICommandHandler.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '@/bus/command/Command'; 2 | 3 | export interface ICommandHandler { 4 | readonly __TAG: string; 5 | 6 | handle(command: TCommand): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/bus/middleware/CommandLoggerMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '@/bus/command/Command'; 2 | import type { Logger } from '@/logger/Logger'; 3 | import type { Middleware } from './Middleware'; 4 | 5 | export class CommandLoggerMiddleware implements Middleware { 6 | constructor( 7 | private readonly logger: Logger, 8 | private readonly middlewareId: string 9 | ) {} 10 | 11 | async execute(command: Command, next: (command: Command) => Promise): Promise { 12 | const date = new Date().toISOString(); 13 | this.logger.log(`[${date}][${this.middlewareId}][${command.__TAG}] - ${JSON.stringify(command)}`); 14 | return next(command); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/bus/middleware/Middleware.ts: -------------------------------------------------------------------------------- 1 | export interface Middleware { 2 | execute: (message: T, next: (message: T) => Promise) => Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/bus/middleware/QueryLoggerMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type { Middleware } from '@/bus/middleware/Middleware'; 2 | import type { Query } from '@/bus/query/Query'; 3 | import type { Logger } from '@/logger/Logger'; 4 | 5 | export class QueryLoggerMiddleware implements Middleware { 6 | constructor( 7 | private readonly logger: Logger, 8 | private readonly middlewareId: string 9 | ) {} 10 | 11 | execute(query: Query, next: (query: Query) => Promise): Promise { 12 | const date = new Date().toISOString(); 13 | this.logger.log(`[${date}][${this.middlewareId}][${query.__TAG}] - ${JSON.stringify(query)}`); 14 | return next(query); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/bus/query/IResponse.ts: -------------------------------------------------------------------------------- 1 | export type Response = object; 2 | -------------------------------------------------------------------------------- /src/bus/query/Query.ts: -------------------------------------------------------------------------------- 1 | export abstract class Query { 2 | public abstract readonly __TAG: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/bus/query/QueryHandler.ts: -------------------------------------------------------------------------------- 1 | import type { Query } from '@/bus/query/Query'; 2 | 3 | export abstract class QueryHandler { 4 | public abstract readonly __TAG: string; 5 | 6 | public abstract handle(query: TQuery): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/domainEvent/DomainEvent.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from '@/bus/Message'; 2 | import type { Metadata } from '@/domainEvent/Metadata'; 3 | import type { Payload } from '@/domainEvent/Payload'; 4 | 5 | interface DomainEventData { 6 | eventId: string; 7 | occurredOn?: Date; 8 | payload?: Payload; 9 | metadata?: Metadata; 10 | } 11 | 12 | export abstract class DomainEvent implements Message { 13 | abstract readonly __TAG: string; 14 | 15 | public readonly eventId: string; 16 | public readonly occurredOn: Date; 17 | public readonly payload: Payload; 18 | public readonly metadata: Metadata; 19 | 20 | public constructor(eventData: DomainEventData) { 21 | this.eventId = eventData.eventId; 22 | this.occurredOn = eventData?.occurredOn || new Date(); 23 | this.metadata = eventData?.metadata || {}; 24 | this.payload = eventData?.payload || {}; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/domainEvent/IEventHandler.ts: -------------------------------------------------------------------------------- 1 | import type { DomainEvent } from '@/domainEvent/DomainEvent'; 2 | 3 | export interface IEventHandler { 4 | readonly __TAG: string; 5 | 6 | handle(event: T): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/domainEvent/Metadata.ts: -------------------------------------------------------------------------------- 1 | export type Metadata = Record; 2 | -------------------------------------------------------------------------------- /src/domainEvent/Payload.ts: -------------------------------------------------------------------------------- 1 | export type Payload = Record; 2 | -------------------------------------------------------------------------------- /src/entity/Entity.ts: -------------------------------------------------------------------------------- 1 | import type { UUID } from '@/valueObject/uuid/UUID'; 2 | 3 | export abstract class Entity { 4 | private readonly data: EntityData; 5 | 6 | protected constructor(entityData: EntityData) { 7 | this.data = entityData; 8 | } 9 | 10 | id(): string { 11 | return this.data.id.get('value'); 12 | } 13 | 14 | get(key: Key): EntityData[Key] { 15 | return this.data[key]; 16 | } 17 | 18 | protected set(key: Key, value: EntityData[Key]): void { 19 | this.data[key] = value; 20 | } 21 | 22 | equals(other: Entity): boolean { 23 | return this.data.id.get('value') === other.data.id.get('value'); 24 | } 25 | 26 | isNew(): boolean { 27 | return this.get('id').isNew(); 28 | } 29 | 30 | toObject(): Readonly { 31 | return Object.freeze(this.data); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/errors/CustomError.ts: -------------------------------------------------------------------------------- 1 | export abstract class CustomError extends Error { 2 | public abstract readonly __TAG: string; 3 | 4 | protected constructor(message?: string) { 5 | super(message); 6 | } 7 | 8 | abstract isDomainError(): boolean; 9 | 10 | abstract isTechnicalError(): boolean; 11 | } 12 | -------------------------------------------------------------------------------- /src/errors/DomainError.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from './CustomError'; 2 | 3 | export abstract class DomainError extends CustomError { 4 | isDomainError(): boolean { 5 | return true; 6 | } 7 | 8 | isTechnicalError(): boolean { 9 | return false; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/errors/TechnicalError.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from './CustomError'; 2 | 3 | export abstract class TechnicalError extends CustomError { 4 | isDomainError(): boolean { 5 | return false; 6 | } 7 | 8 | isTechnicalError(): boolean { 9 | return true; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/eventBus/EventBus.ts: -------------------------------------------------------------------------------- 1 | import type { DomainEvent } from '@/domainEvent/DomainEvent'; 2 | import type { IEventHandler } from '@/domainEvent/IEventHandler'; 3 | import type { EventBusPort } from './EventBusPort'; 4 | import type { IEventMiddleware } from './IEventMiddleware'; 5 | 6 | type ChainFunction = (event: DomainEvent) => Promise; 7 | 8 | export class EventBus implements EventBusPort { 9 | private middlewares: IEventMiddleware[] = []; 10 | private readonly handlers: Record IEventHandler)[]> = {}; 11 | 12 | use(middleware: IEventMiddleware): void { 13 | this.middlewares.push(middleware); 14 | } 15 | 16 | on(eventType: string, handler: () => IEventHandler): void { 17 | if (!this.handlers[eventType]) { 18 | this.handlers[eventType] = []; 19 | } 20 | this.handlers[eventType].push(handler); 21 | } 22 | 23 | async dispatch(domainEvent: DomainEvent): Promise { 24 | const handlers = this.handlers[domainEvent.__TAG]; 25 | 26 | const executeHandlers: ChainFunction = async (event: DomainEvent) => { 27 | if (!handlers) return; 28 | for (const handlerFactory of handlers) { 29 | const handler = handlerFactory(); 30 | await handler.handle(event); 31 | } 32 | }; 33 | 34 | if (!handlers || handlers.length === 0) { 35 | await executeHandlers(domainEvent); 36 | return; 37 | } 38 | 39 | const middlewareChain = this.middlewares.reduceRight((next, middleware) => { 40 | return async (event: DomainEvent) => { 41 | await middleware.execute(event, next); 42 | }; 43 | }, executeHandlers); 44 | 45 | await middlewareChain(domainEvent); 46 | } 47 | 48 | async dispatchEvents(events: DomainEvent[]): Promise { 49 | for (const event of events) { 50 | await this.dispatch(event); 51 | } 52 | } 53 | 54 | dispatchEventsAsync(events: DomainEvent[]): void { 55 | setImmediate(async () => { 56 | for (const event of events) { 57 | await this.dispatch(event); 58 | } 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/eventBus/EventBusPort.ts: -------------------------------------------------------------------------------- 1 | import type { DomainEvent } from '@/domainEvent/DomainEvent'; 2 | import type { IEventHandler } from '@/domainEvent/IEventHandler'; 3 | import type { IEventMiddleware } from './IEventMiddleware'; 4 | 5 | export interface EventBusPort { 6 | use(middleware: IEventMiddleware): void; 7 | 8 | on(eventType: string, handler: () => IEventHandler): void; 9 | 10 | dispatch(domainEvent: DomainEvent): Promise; 11 | 12 | dispatchEvents(events: DomainEvent[]): Promise; 13 | 14 | dispatchEventsAsync(events: DomainEvent[]): void; 15 | } 16 | -------------------------------------------------------------------------------- /src/eventBus/EventLoggingMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type { DomainEvent } from '@/domainEvent/DomainEvent'; 2 | import type { IEventMiddleware } from '@/eventBus/IEventMiddleware'; 3 | import type { Logger } from '@/logger/Logger'; 4 | 5 | export class EventLoggingMiddleware implements IEventMiddleware { 6 | constructor(private readonly logger: Logger) {} 7 | 8 | execute(event: DomainEvent, next: (event: DomainEvent) => Promise): Promise { 9 | this.logger.log( 10 | `[${event.occurredOn.toISOString()}] Event "${event.__TAG}" occurred with ID "${ 11 | event.eventId 12 | }". Payload: ${JSON.stringify(event.payload)} - Metadata: ${JSON.stringify(event.metadata)}` 13 | ); 14 | 15 | return next(event); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/eventBus/IEventMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type { DomainEvent } from '@/domainEvent/DomainEvent'; 2 | 3 | export interface IEventMiddleware { 4 | execute(event: DomainEvent, next: (event: DomainEvent) => Promise): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './aggregate/Aggregate'; 2 | export * from './bus/Bus'; 3 | export * from './bus/Message'; 4 | export * from './bus/command/Command'; 5 | export * from './bus/command/CommandHandler'; 6 | export * from './bus/middleware/CommandLoggerMiddleware'; 7 | export * from './bus/middleware/QueryLoggerMiddleware'; 8 | export * from './bus/query/Query'; 9 | export * from './bus/query/QueryHandler'; 10 | export * from './domainEvent/DomainEvent'; 11 | export * from './domainEvent/IEventHandler'; 12 | export * from './domainEvent/Metadata'; 13 | export * from './domainEvent/Payload'; 14 | export * from './entity/Entity'; 15 | export * from './errors/CustomError'; 16 | export * from './errors/DomainError'; 17 | export * from './errors/TechnicalError'; 18 | export * from './eventBus/EventBus'; 19 | export * from './eventBus/EventBusPort'; 20 | export * from './logger/Logger'; 21 | export * from './result/Result'; 22 | export * from './useCase/IUseCase'; 23 | export * from './valueObject/ValueObject'; 24 | export * from './valueObject/uuid/UUIDData'; 25 | export * from './valueObject/uuid/UUID'; 26 | export * from './valueObject/uuid/UUIDFactory'; 27 | -------------------------------------------------------------------------------- /src/logger/Logger.ts: -------------------------------------------------------------------------------- 1 | export interface Logger { 2 | log: (message: string) => void; 3 | } 4 | -------------------------------------------------------------------------------- /src/result/Result.ts: -------------------------------------------------------------------------------- 1 | type Success = { _tag: 'success'; value: ValueType }; 2 | type Failure = { _tag: 'failure'; error: ErrorType }; 3 | 4 | export class Result { 5 | private readonly result: Success | Failure; 6 | 7 | private constructor(result: Success | Failure) { 8 | this.result = result; 9 | } 10 | 11 | public static ok(value: ValueType): Result { 12 | return new Result({ _tag: 'success', value }); 13 | } 14 | 15 | public static fail(error: ErrorType): Result { 16 | return new Result({ _tag: 'failure', error }); 17 | } 18 | 19 | public isOk(): this is Success { 20 | return this.result._tag === 'success'; 21 | } 22 | 23 | public isFail(): this is Failure { 24 | return this.result._tag === 'failure'; 25 | } 26 | 27 | public getValue(): ValueType { 28 | if (this.result._tag !== 'success') { 29 | throw new Error("Attempted to access 'value' of a failure result."); 30 | } 31 | return this.result.value; 32 | } 33 | 34 | public getError(): ErrorType { 35 | if (this.result._tag !== 'failure') { 36 | throw new Error("Attempted to access 'error' of a success result."); 37 | } 38 | return this.result.error; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/useCase/IUseCase.ts: -------------------------------------------------------------------------------- 1 | export interface IUseCase { 2 | execute(request?: Request): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/valueObject/ValueObject.ts: -------------------------------------------------------------------------------- 1 | export abstract class ValueObject { 2 | private readonly data: ValueObjectData; 3 | 4 | protected constructor(data: ValueObjectData) { 5 | this.data = this.deepFreeze(data); 6 | } 7 | 8 | equals(other: ValueObject): boolean { 9 | if (!other) { 10 | return false; 11 | } 12 | 13 | return this.reflectiveEqual(this.data, other.data); 14 | } 15 | 16 | get(key: T): ValueObjectData[T] { 17 | return this.data[key]; 18 | } 19 | 20 | private reflectiveEqual(obj1: unknown, obj2: unknown): boolean { 21 | if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) { 22 | return obj1 === obj2; 23 | } 24 | 25 | const obj1Keys = Reflect.ownKeys(obj1); 26 | const obj2Keys = Reflect.ownKeys(obj2); 27 | 28 | if (obj1Keys.length !== obj2Keys.length) { 29 | return false; 30 | } 31 | 32 | return obj1Keys.every((key) => { 33 | const val1 = Reflect.get(obj1, key); 34 | const val2 = Reflect.get(obj2, key); 35 | 36 | if (typeof val1 === 'object' && val1 !== null && typeof val2 === 'object' && val2 !== null) { 37 | return this.reflectiveEqual(val1, val2); 38 | } 39 | 40 | return val1 === val2; 41 | }); 42 | } 43 | 44 | private deepFreeze(object: T): T { 45 | if (typeof object !== 'object' || object === null) { 46 | return object; 47 | } 48 | 49 | const propNames = Object.getOwnPropertyNames(object) as (keyof T)[]; 50 | 51 | for (const name of propNames) { 52 | const property = object[name]; 53 | if (property && typeof property === 'object' && !Object.isFrozen(property)) { 54 | this.deepFreeze(property); 55 | } 56 | } 57 | 58 | return Object.freeze(object); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/valueObject/uuid/UUID.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from '../ValueObject'; 2 | import type { UUIDData } from './UUIDData'; 3 | 4 | export class UUID extends ValueObject { 5 | public static readonly UUID_PATTERN = /^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/; 6 | 7 | static create(value: string, isNew = true): UUID { 8 | if (!UUID.isValid(value)) { 9 | throw new Error('Invalid UUID'); 10 | } 11 | 12 | return new UUID({ 13 | value, 14 | isNew, 15 | }); 16 | } 17 | 18 | static createFrom(value: string): UUID { 19 | return UUID.create(value, false); 20 | } 21 | 22 | equals(other: ValueObject): boolean { 23 | return this.get('value') === other.get('value'); 24 | } 25 | 26 | static isValid(uuid: string): boolean { 27 | return uuid.match(UUID.UUID_PATTERN) !== null; 28 | } 29 | 30 | isNew(): boolean { 31 | return this.get('isNew'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/valueObject/uuid/UUIDData.ts: -------------------------------------------------------------------------------- 1 | export interface UUIDData { 2 | value: string; 3 | isNew: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /src/valueObject/uuid/UUIDFactory.ts: -------------------------------------------------------------------------------- 1 | import { UUID } from './UUID'; 2 | 3 | export function Uuid(value: string, isNew = true): UUID { 4 | return UUID.create(value, isNew); 5 | } 6 | 7 | export function UuidFrom(value: string): UUID { 8 | return UUID.createFrom(value); 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl" : "./", 4 | "allowJs": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "isolatedModules": true, 9 | "module": "ESNext", 10 | "moduleDetection": "force", 11 | "moduleResolution": "node", 12 | "noEmit": true, 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true, 15 | "sourceMap": true, 16 | "strict": true, 17 | "target": "es2022", 18 | "types": ["vitest/globals"], 19 | "paths": { 20 | "@/*": ["./src/*"] 21 | } 22 | }, 23 | "include": [ 24 | "src/**/*.ts", 25 | "specs/**/*.ts" 26 | ], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import * as path from "node:path"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | coverage: { 7 | provider: 'v8', 8 | exclude: ["specs/**/*", "**/index.ts", "dist/**/*", "**/*.mts", "**/*.mjs"] 9 | }, 10 | globals: true, 11 | }, 12 | resolve: { 13 | alias: { 14 | '@': path.resolve(__dirname, './src') 15 | } 16 | } 17 | }) --------------------------------------------------------------------------------